├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── images └── ticket.jpg ├── index.js ├── lib ├── extract.js └── parse │ ├── body.js │ ├── certificate.js │ ├── date.js │ ├── header.js │ ├── identification.js │ └── index.js ├── package.json └── test ├── extract.js ├── index.js └── parse ├── body.js ├── certificate.js ├── date.js ├── header.js └── identification.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "rules": { 4 | "comma-dangle": [2, "always-multiline"] 5 | }, 6 | "env": { 7 | "es6": true, 8 | "node": true, 9 | "mocha": true 10 | }, 11 | "plugins": [] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/private/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Philipp Bock 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ticket-parser 2 | 3 | Have you ever travelled with an online ticket from Deutsche Bahn and wondered what information is inside the 2D code? This library tells you. (Note that it doesn't *read* the image of the Aztec code itself; [ZebraCrossing][zebra-crossing] can help with that.) 4 | 5 | ![The Aztec code on a home-printed ticket](images/ticket.jpg) 6 | 7 | ```sh 8 | npm install ticket-parser 9 | ``` 10 | 11 | ## API 12 | 13 | ```js 14 | const TicketParser = require('ticketparser'); 15 | TicketParser.parse(dataFromAztecCode) 16 | .then(ticket => { /* do something */ }) 17 | ``` 18 | 19 | The API is asynchronous because TicketParser invokes `zlib` to decompress the payload. Parsing is currently done with the buffered payload, but the format lends itself very well to being parsed as a stream. 20 | 21 | The returned Promise is rejected if no payload is found, otherwise it resolves with output like this: 22 | 23 | ```js 24 | { 25 | version: "01", 26 | pnr: "QW2A66", 27 | issueDate: new Date("2015-06-24T15:44:00.000Z"), 28 | primaryLanguage: "DE", 29 | secondaryLanguage: "DE", 30 | identification: { 31 | carrier: "0080", 32 | type: "BahnCard", // === TicketParser.IDENTIFICATION_TYPES.BAHNCARD 33 | lastDigits: "1234" 34 | }, 35 | fareName: "Sparpreis Aktion", 36 | productClass: { 37 | overall: "IC/EC", // === TicketParser.PRODUCT_CLASSES.IC 38 | outbound: "IC/EC", 39 | inbound: "IC/EC" 40 | }, 41 | adults: 2, 42 | bahnCards: [ 43 | { 44 | count: 2, 45 | type: "BahnCard 25" // === TicketParser.BAHNCARD_TYPES.BC25 46 | } 47 | ], 48 | children: 0, 49 | fareClass: "2", 50 | outbound: { 51 | from: "Berlin+City", 52 | to: "Freiburg(Brsg)+City" 53 | }, 54 | inbound: { 55 | from: "Freiburg(Brsg)+City", 56 | to: "Berlin+City" 57 | }, 58 | via: "H: B-Hbf 22:14 CNL1258-IC61258 R: FR-Hbf 21:58 CNL40459", 59 | owner: { 60 | fullName: "Beispiel Sofie", 61 | firstName: "Sofie", 62 | lastName: "Beispiel" 63 | }, 64 | fareType: "Sparpreis", // === TicketParser.FARE_TYPES.SAVER 65 | identificationNumber: "***************1234", 66 | valid: { 67 | from: new Date("2015-06-30T22:00:00.000Z"), 68 | until: new Date("2015-07-01T22:00:00.000Z") 69 | }, 70 | stationIds: { 71 | from: "11160", 72 | to: "107" 73 | }, 74 | carrier: "0080", 75 | certificates: [ 76 | { 77 | id: "208XEFFLLM3", 78 | validFrom: new Date("2015-06-30T22:00:00.000Z"), 79 | validUntil: new Date("2015-07-01T22:00:00.000Z"), 80 | serialNumber: "1234567890" 81 | }, 82 | { 83 | id: "208AQRNBBE4", 84 | validFrom: new Date("2015-07-02T22:00:00.000Z"), 85 | validUntil: new Date("2015-07-03T22:00:00.000Z"), 86 | serialNumber: "1234567890" 87 | } 88 | ] 89 | } 90 | ``` 91 | 92 | ## Limitations 93 | 94 | Because the specification is not public, all the information has been gained through reverse-engineering; mainly thanks to [the efforts of rumpeltux][rumpeltux]. Most things work, nothing's guaranteed. 95 | 96 | Some information is not contained in the Aztec code, notably prices and seat reservations. If you care about those, you'll have no choice but to parse the text in the PDF. 97 | 98 | TicketParser currently ignores extensions such as the "+City Ticket" or "Ländertickets", even though that is part of the encoded information. 99 | 100 | TicketParser does not and cannot check if a ticket is genuine. Each ticket is cryptographically signed (almost certainly using asymmetric encryption), but the public key is, well, not public. TicketParser currently throws away the signature – if you have the means of verifying it, you probably also have better means of parsing the ticket. 101 | 102 | TicketParser cannot currently parse tickets for local transport (VDV barcodes or _statische Berechtigung_). A [decoder written in C++][vdv-decoder] exists but it isn’t integrated with this library. Help would be welcome; see [the discussion on the related issue][issue2]. 103 | 104 | [zebra-crossing]: https://github.com/pbock/zebra-crossing 105 | [rumpeltux]: https://github.com/rumpeltux/onlineticket/blob/master/onlineticket.py 106 | [vdv-decoder]: https://github.com/KDE/kitinerary/tree/master/src/vdv 107 | [issue2]: https://github.com/pbock/ticket-parser/issues/2 108 | -------------------------------------------------------------------------------- /images/ticket.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pbock/ticket-parser/8e885466ec4f8acd8166a28c4dbbc375503f18e7/images/ticket.jpg -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const extract = require('./lib/extract'); 4 | const parsePayload = require('./lib/parse'); 5 | 6 | const parseBody = require('./lib/parse/body'); 7 | const PRODUCT_CLASSES = parseBody.PRODUCT_CLASSES; 8 | const BAHNCARD_TYPES = parseBody.BAHNCARD_TYPES; 9 | const FARE_TYPES = parseBody.FARE_TYPES; 10 | const IDENTIFICATION_TYPES = require('./lib/parse/identification').IDENTIFICATION_TYPES; 11 | 12 | const parse = function(aztecCodeContents) { 13 | return extract.extract(aztecCodeContents).then(parsePayload); 14 | } 15 | 16 | module.exports = { 17 | parse, 18 | 19 | PRODUCT_CLASSES, 20 | BAHNCARD_TYPES, 21 | FARE_TYPES, 22 | IDENTIFICATION_TYPES, 23 | }; 24 | -------------------------------------------------------------------------------- /lib/extract.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const zlib = require('zlib'); 4 | const ZLIB_HEADER = new Buffer([ 0x78, 0x9c ]); 5 | 6 | function extract(data) { 7 | return new Promise((resolve, reject) => { 8 | const index = data.indexOf(ZLIB_HEADER); 9 | if (index === -1) return reject(new Error('No zlib payload found')); 10 | zlib.inflate(data.slice(index), (err, inflated) => { 11 | if (err) return reject(err); 12 | resolve(inflated); 13 | }) 14 | }) 15 | } 16 | 17 | module.exports = { extract } 18 | -------------------------------------------------------------------------------- /lib/parse/body.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parseDate = require('./date'); 4 | const parseCertificate = require('./certificate'); 5 | 6 | const REGIONAL = 'Regional'; 7 | const IC = 'IC/EC'; 8 | const ICE = 'ICE'; 9 | const productClassLookup = { 10 | '0': REGIONAL, 11 | '1': IC, 12 | '2': ICE, 13 | C: REGIONAL, 14 | B: IC, 15 | A: ICE, 16 | } 17 | 18 | const NONE = 'keine BahnCard'; 19 | const BC50 = 'BahnCard 50'; 20 | const BC25 = 'BahnCard 25'; 21 | const BC25EN = 'Einsteiger BahnCard 25 ohne Abo'; 22 | const BC25E = 'Einsteiger BahnCard 25'; 23 | const bahnCardTypeLookup = { 24 | 0: NONE, 25 | 19: BC50, 26 | 78: BC50, 27 | 49: BC25, 28 | 27: BC25EN, 29 | 39: BC25E, 30 | } 31 | 32 | const FARE_TYPES = { 33 | RAIL_AND_FLY: 'Rail&Fly', 34 | FLEXIBLE: 'Flexpreis', 35 | SAVER: 'Sparpreis', 36 | } 37 | const fareTypeLookup = { 38 | 3: FARE_TYPES.RAIL_AND_FLY, 39 | 12: FARE_TYPES.FLEXIBLE, 40 | 13: FARE_TYPES.SAVER, 41 | } 42 | 43 | function parseSBlocks(buffer) { 44 | let cursor = 0; 45 | let data = {}; 46 | while (cursor < buffer.length) { 47 | if (buffer[cursor] !== 0x53) break; 48 | const length = 8 + parseInt(buffer.slice(cursor + 4, cursor + 8), 10); 49 | const type = buffer.slice(cursor + 1, cursor + 4).toString(); 50 | const body = buffer.slice(cursor + 8, cursor + length).toString(); 51 | 52 | switch (type) { 53 | case '001': // Fare type 54 | data.fareName = body; 55 | break; 56 | case '002': // Product class for entire ticket (0, 1, 2) 57 | data.productClass = data.productClass || {}; 58 | data.productClass.overall = productClassLookup[body]; 59 | break; 60 | case '003': // Product class outbound (C, B, A) 61 | data.productClass = data.productClass || {}; 62 | data.productClass.outbound = productClassLookup[body]; 63 | break; 64 | case '004': // Product class outbound (C, B, A) 65 | data.productClass = data.productClass || {}; 66 | data.productClass.inbound = productClassLookup[body]; 67 | break; 68 | case '009': // Passengers 69 | data.adults = +body.split('-')[0]; 70 | data.bahnCards = [{ 71 | count: +body.split('-')[1], 72 | type: bahnCardTypeLookup[body.split('-')[2]], 73 | }]; 74 | break; 75 | case '012': // Children 76 | data.children = parseInt(body, 10); 77 | break; 78 | case '014': // Fare class 79 | data.fareClass = body.substr(-1); 80 | break; 81 | case '015': // Outbound from station 82 | data.outbound = data.outbound || {}; 83 | data.outbound.from = body; 84 | break; 85 | case '016': // Outbound to station 86 | data.outbound = data.outbound || {}; 87 | data.outbound.to = body; 88 | break; 89 | case '017': // Inbound from station 90 | data.inbound = data.inbound || {}; 91 | data.inbound.from = body; 92 | break; 93 | case '018': // Inbound to station 94 | data.inbound = data.inbound || {}; 95 | data.inbound.to = body; 96 | break; 97 | case '021': // VIA 98 | data.via = body; 99 | break; 100 | case '023': // Full Name 101 | data.owner = data.owner || {}; 102 | data.owner.fullName = body; 103 | break; 104 | case '026': 105 | data.fareType = fareTypeLookup[body]; 106 | break; 107 | case '027': 108 | data.identificationNumber = body; 109 | break; 110 | case '028': 111 | data.owner = data.owner || {}; 112 | data.owner.firstName = body.split('#')[0]; 113 | data.owner.lastName = body.split('#')[1]; 114 | break; 115 | case '031': 116 | data.valid = data.valid || {}; 117 | data.valid.from = parseDate(body); 118 | break; 119 | case '032': 120 | data.valid = data.valid || {}; 121 | data.valid.until = parseDate(body, '2400'); 122 | break; 123 | case '035': 124 | data.stationIds = data.stationIds || {}; 125 | data.stationIds.from = body; 126 | break; 127 | case '036': 128 | data.stationIds = data.stationIds || {}; 129 | data.stationIds.to = body; 130 | break; 131 | default: 132 | data['S' + type] = body.toString(); 133 | break; 134 | } 135 | 136 | cursor += length; 137 | } 138 | return data; 139 | } 140 | 141 | function parseSegments(segmentsFragment) { 142 | const length = parseInt(segmentsFragment.slice(8, 12), 10); 143 | const buffer = segmentsFragment.slice(0, length); 144 | 145 | const carrier = buffer.slice(0, 4).toString(); 146 | const certificateCount = +buffer.slice(14, 15).toString(); 147 | const certificates = []; 148 | const certificateLength = 46; 149 | for (let i = 0; i < certificateCount; i++) { 150 | const start = 15 + i * certificateLength; 151 | const end = start + certificateLength; 152 | const certificate = parseCertificate(buffer.slice(start, end)); 153 | certificates.push(certificate); 154 | } 155 | 156 | let cursor = 15 + certificateCount * certificateLength; 157 | // const sBlockCount = parseInt(buffer.slice(cursor, cursor + 2).toString(), 10); 158 | cursor += 2; 159 | 160 | const data = parseSBlocks(buffer.slice(cursor)) 161 | data.carrier = carrier; 162 | data.certificates = certificates; 163 | 164 | return { data, length }; 165 | } 166 | parseSegments.parseSBlocks = parseSBlocks; 167 | parseSegments.PRODUCT_CLASSES = { REGIONAL, IC, ICE }; 168 | parseSegments.BAHNCARD_TYPES = { NONE, BC25, BC50, BC25E, BC25EN }; 169 | parseSegments.FARE_TYPES = FARE_TYPES; 170 | module.exports = parseSegments; 171 | -------------------------------------------------------------------------------- /lib/parse/certificate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parseDate = require('./date'); 4 | 5 | module.exports = function parseCertificate(buffer) { 6 | const id = buffer.slice(0, 11).toString(); 7 | const validFrom = parseDate(buffer.slice(22, 30).toString()); 8 | const validUntil = parseDate(buffer.slice(30, 38).toString(), '2400'); 9 | const serialNumber = buffer.slice(38).toString(); 10 | return { id, validFrom, validUntil, serialNumber }; 11 | } 12 | -------------------------------------------------------------------------------- /lib/parse/date.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function parseDate(date, time) { 4 | // Offset skips over '.' if there are any in the date string, 5 | // so 24122016 == 24.12.2016 6 | const offset = (date.length > 8) ? 1 : 0; 7 | const day = parseInt(date.substr(0, 2), 10); 8 | const month = parseInt(date.substr(2 + offset, 2), 10); 9 | const year = parseInt(date.substr(4 + 2 * offset, 4), 10); 10 | 11 | let hours = 0, minutes = 0; 12 | if (time) { 13 | hours = parseInt(time.substr(0, 2), 10); 14 | minutes = parseInt(time.substr(2, 2), 10); 15 | } 16 | 17 | return new Date(year, month - 1, day, hours, minutes); 18 | } 19 | -------------------------------------------------------------------------------- /lib/parse/header.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parseDate = require('./date'); 4 | 5 | module.exports = function parseHeader(payload) { 6 | const version = payload.slice(6, 8).toString(); 7 | const length = parseInt(payload.slice(8, 12).toString(), 10); 8 | const buffer = payload.slice(0, length); 9 | 10 | const pnr = buffer.slice(16, 22).toString(); 11 | const issueDate = buffer.slice(36, 44).toString(); 12 | const issueTime = buffer.slice(44, 48).toString(); 13 | const date = parseDate(issueDate, issueTime); 14 | const primaryLanguage = buffer.slice(49, 51).toString(); 15 | const secondaryLanguage = buffer.slice(51, 53).toString(); 16 | 17 | const data = { 18 | version, 19 | pnr, 20 | issueDate: date, 21 | primaryLanguage, 22 | secondaryLanguage, 23 | }; 24 | return { length, data }; 25 | } 26 | -------------------------------------------------------------------------------- /lib/parse/identification.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // We're using 'identification' and not 'id' throughout because 'id' is 4 | // normally used for unique identifiers, e.g. a ticketId 5 | 6 | const CREDIT_CARD = 'Credit Card'; 7 | const BAHNCARD = 'BahnCard'; 8 | const DEBIT_CARD = 'Debit Card'; 9 | const BONUSCARD_BUSINESS = 'BonusCard Business'; 10 | const NATIONAL_ID = 'National ID Card'; 11 | const PASSPORT = 'Passport'; 12 | const BAHNBONUS_CARD = 'bahn.bonus Card'; 13 | 14 | const typeLookup = { 15 | 1: CREDIT_CARD, 16 | 4: BAHNCARD, 17 | 7: DEBIT_CARD, 18 | 8: BONUSCARD_BUSINESS, 19 | 9: NATIONAL_ID, 20 | 10: PASSPORT, 21 | 11: BAHNBONUS_CARD, 22 | } 23 | function parseIdentification(payloadFragment) { 24 | const length = parseInt(payloadFragment.slice(8, 12), 10); 25 | const buffer = payloadFragment.slice(0, length); 26 | 27 | const carrier = buffer.slice(0, 4).toString(); 28 | const type = typeLookup[parseInt(buffer.slice(12, 14), 10)]; 29 | const lastDigits = buffer.slice(14, 18).toString(); 30 | 31 | const data = { 32 | carrier, 33 | type, 34 | lastDigits, 35 | } 36 | return { length, data }; 37 | } 38 | parseIdentification.IDENTIFICATION_TYPES = { 39 | CREDIT_CARD, BAHNCARD, DEBIT_CARD, BONUSCARD_BUSINESS, NATIONAL_ID, 40 | PASSPORT, BAHNBONUS_CARD, 41 | }; 42 | module.exports = parseIdentification; 43 | -------------------------------------------------------------------------------- /lib/parse/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parseHeader = require('./header'); 4 | const parseIdentification = require('./identification'); 5 | const parseBody = require('./body'); 6 | 7 | function parse(payload) { 8 | // Parse ticket header 9 | let currentOffset = 0; 10 | const { data: header, length: headerLength } = parseHeader(payload); 11 | 12 | currentOffset += headerLength; 13 | 14 | // Parse identification block 15 | const { data: identification, length: identificationLength } = 16 | parseIdentification(payload.slice(currentOffset)); 17 | 18 | currentOffset += identificationLength; 19 | 20 | // Parse segments 21 | const { data: body, length: bodyLength } = 22 | parseBody(payload.slice(currentOffset)); 23 | 24 | return Object.assign(header, { identification }, body); 25 | } 26 | 27 | module.exports = parse; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ticket-parser", 3 | "version": "0.1.0", 4 | "description": "Parse the data contained in Deutsche Bahn's aztec codes", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha --recursive" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/pbock/ticket-parser.git" 12 | }, 13 | "keywords": [ 14 | "db", 15 | "train", 16 | "ticket", 17 | "parse", 18 | "2d", 19 | "barcode", 20 | "aztec" 21 | ], 22 | "author": "Philipp Bock (http://philippbock.de/)", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/pbock/ticket-parser/issues" 26 | }, 27 | "homepage": "https://github.com/pbock/ticket-parser#readme", 28 | "devDependencies": { 29 | "mocha": "*", 30 | "chai": "*", 31 | "chai-as-promised": "*" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/extract.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chai = require('chai'); 4 | chai.use(require('chai-as-promised')); 5 | const expect = chai.expect; 6 | 7 | const extract = require('../lib/extract').extract; 8 | const extractSync = require('../lib/extract').extractSync; 9 | 10 | const deflatedBuffer = new Buffer([0x78,0x9c,0x4b,0xcb,0xcf,0x4f,0x4a,0x2c, 11 | 0x4a,0x43,0x22,0x01,0x46,0x83,0x07,0x6c]); // deflate("foobarfoobarfoobar") 12 | const irrelevantData = new Buffer([0x00,0x11,0x22,0x33,0x44]); 13 | const inflatedBuffer = Buffer.from('foobarfoobarfoobar'); 14 | 15 | describe('extract', function () { 16 | it(`rejects if something other than a buffer is passed`, function () { 17 | return Promise.all([ 18 | expect(extract()).to.be.rejected, 19 | expect(extract('foobar')).to.be.rejected, 20 | expect(extract(deflatedBuffer.toString())).to.be.rejected, 21 | ]) 22 | }) 23 | 24 | it(`looks for a zlib-deflated payload and inflates it`, function () { 25 | return Promise.all([ 26 | expect(extract(deflatedBuffer)).to.become(inflatedBuffer), 27 | expect(extract(Buffer.concat([irrelevantData, deflatedBuffer]))) 28 | .to.become(inflatedBuffer), 29 | expect(extract(Buffer.concat([deflatedBuffer, inflatedBuffer]))) 30 | .to.become(inflatedBuffer), 31 | ]) 32 | }) 33 | 34 | it(`rejects if no zlib payload is found`, function () { 35 | return expect(extract(irrelevantData)).to.be.rejected; 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const TicketParser = require('../'); 5 | 6 | describe('TicketParser', function () { 7 | it('exposes globals', function () { 8 | expect(TicketParser).to.have.property('IDENTIFICATION_TYPES'); 9 | expect(TicketParser).to.have.property('BAHNCARD_TYPES'); 10 | expect(TicketParser).to.have.property('FARE_TYPES'); 11 | expect(TicketParser).to.have.property('PRODUCT_CLASSES'); 12 | }) 13 | it('exposes a #parse() method', function () { 14 | expect(TicketParser.parse).to.be.a('function'); 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /test/parse/body.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const parse = require('../../lib/parse/body'); 5 | const parseSBlocks = parse.parseSBlocks; 6 | 7 | describe('parseBody', function () { 8 | const exampleData = Buffer.from('0080BL020126052AAAABBBBCCC___________200320142003200123456789DDDDEEEEFFF___________23032014230320012345679001S0010009Sparpreis') 9 | 10 | it('returns the parsed data and the length of the block', function () { 11 | const parsed = parse(exampleData); 12 | expect(parsed).to.have.property('data'); 13 | expect(parsed).to.have.length(exampleData.length); 14 | }) 15 | it('stops parsing when the block ends', function () { 16 | // Add garbage data; it should be ignored in the `length` property 17 | expect( parse(Buffer.concat([ exampleData, Buffer.from('FOOBAR') ])) ) 18 | .to.have.length(exampleData.length); 19 | }) 20 | it('parses header data', function () { 21 | expect(parse(exampleData).data).to.deep.equal({ 22 | carrier: '0080', 23 | certificates: [ 24 | { 25 | id: 'AAAABBBBCCC', 26 | validFrom: new Date(2014, 2, 20), 27 | validUntil: new Date(2001, 2, 21), 28 | serialNumber: '23456789', 29 | }, 30 | { 31 | id: 'DDDDEEEEFFF', 32 | validFrom: new Date(2014, 2, 23), 33 | validUntil: new Date(2001, 2, 24), 34 | serialNumber: '23456790', 35 | }, 36 | ], 37 | fareName: 'Sparpreis', 38 | }) 39 | }) 40 | 41 | it('exposes PRODUCT_CLASSES, BAHNCARD_TYPES and FARE_TYPES', function () { 42 | expect(parse.PRODUCT_CLASSES).to.deep.equal({ 43 | REGIONAL: 'Regional', 44 | IC: 'IC/EC', 45 | ICE: 'ICE', 46 | }) 47 | expect(parse.BAHNCARD_TYPES).to.deep.equal({ 48 | NONE: 'keine BahnCard', 49 | BC50: 'BahnCard 50', 50 | BC25: 'BahnCard 25', 51 | BC25EN: 'Einsteiger BahnCard 25 ohne Abo', 52 | BC25E: 'Einsteiger BahnCard 25', 53 | }) 54 | expect(parse.FARE_TYPES).to.deep.equal({ 55 | RAIL_AND_FLY: 'Rail&Fly', 56 | FLEXIBLE: 'Flexpreis', 57 | SAVER: 'Sparpreis', 58 | }) 59 | }) 60 | }) 61 | 62 | describe('parseSBlocks', function () { 63 | const PRODUCT_CLASSES = parse.PRODUCT_CLASSES; 64 | const BAHNCARD_TYPES = parse.BAHNCARD_TYPES; 65 | const FARE_TYPES = parse.FARE_TYPES; 66 | function p(string) { 67 | return parseSBlocks(Buffer.from(string)); 68 | } 69 | 70 | it('parses fare names', function () { 71 | expect(p('S0010009Sparpreis')).to.deep.equal({ fareName: 'Sparpreis' }); 72 | expect(p('S0010011Normalpreis')).to.deep.equal({ fareName: 'Normalpreis' }); 73 | expect(p('S0010003foo')).to.deep.equal({ fareName: 'foo' }); 74 | }) 75 | 76 | it('parses product classes', function () { 77 | expect(p('S00200012')).to.deep.equal({ productClass: { overall: PRODUCT_CLASSES.ICE } }); 78 | expect(p('S00200011')).to.deep.equal({ productClass: { overall: PRODUCT_CLASSES.IC } }); 79 | expect(p('S00200010')).to.deep.equal({ productClass: { overall: PRODUCT_CLASSES.REGIONAL } }); 80 | expect(p('S00200013')).to.deep.equal({ productClass: { overall: undefined } }); 81 | 82 | expect(p('S0030001A')).to.deep.equal({ productClass: { outbound: PRODUCT_CLASSES.ICE } }); 83 | expect(p('S0030001B')).to.deep.equal({ productClass: { outbound: PRODUCT_CLASSES.IC } }); 84 | expect(p('S0030001C')).to.deep.equal({ productClass: { outbound: PRODUCT_CLASSES.REGIONAL } }); 85 | expect(p('S0030001D')).to.deep.equal({ productClass: { outbound: undefined } }); 86 | 87 | expect(p('S0040001A')).to.deep.equal({ productClass: { inbound: PRODUCT_CLASSES.ICE } }); 88 | expect(p('S0040001B')).to.deep.equal({ productClass: { inbound: PRODUCT_CLASSES.IC } }); 89 | expect(p('S0040001C')).to.deep.equal({ productClass: { inbound: PRODUCT_CLASSES.REGIONAL } }); 90 | expect(p('S0040001D')).to.deep.equal({ productClass: { inbound: undefined } }); 91 | 92 | expect(p('S00200012S0030001AS0040001B')).to.deep.equal({ 93 | productClass: { 94 | overall: PRODUCT_CLASSES.ICE, 95 | outbound: PRODUCT_CLASSES.ICE, 96 | inbound: PRODUCT_CLASSES.IC, 97 | }, 98 | }) 99 | }) 100 | 101 | it('parses passenger information', function () { 102 | expect(p('S00900061-1-49')).to.deep.equal({ adults: 1, bahnCards: [{ count: 1, type: BAHNCARD_TYPES.BC25 }] }) 103 | expect(p('S00900052-0-0' )).to.deep.equal({ adults: 2, bahnCards: [{ count: 0, type: BAHNCARD_TYPES.NONE }] }) 104 | expect(p('S00900062-2-19')).to.deep.equal({ adults: 2, bahnCards: [{ count: 2, type: BAHNCARD_TYPES.BC50 }] }) 105 | expect(p('S00900062-2-78')).to.deep.equal({ adults: 2, bahnCards: [{ count: 2, type: BAHNCARD_TYPES.BC50 }] }) 106 | expect(p('S00900064-2-27')).to.deep.equal({ adults: 4, bahnCards: [{ count: 2, type: BAHNCARD_TYPES.BC25EN }] }) 107 | expect(p('S00900061-1-39')).to.deep.equal({ adults: 1, bahnCards: [{ count: 1, type: BAHNCARD_TYPES.BC25E }] }) 108 | }) 109 | 110 | it('parses children count', function () { 111 | expect(p('S01200011')).to.deep.equal({ children: 1 }); 112 | expect(p('S01200010')).to.deep.equal({ children: 0 }); 113 | expect(p('S01200013')).to.deep.equal({ children: 3 }); 114 | }) 115 | 116 | it('parses fare class', function () { 117 | expect(p('S0140002S2')).to.deep.equal({ fareClass: '2' }); 118 | expect(p('S0140002S1')).to.deep.equal({ fareClass: '1' }); 119 | }) 120 | 121 | it('parses outbound station information', function () { 122 | expect(p('S0150020Münster(Westf)+City')).to.deep.equal({ outbound: { from: 'Münster(Westf)+City' } }); 123 | expect(p('S0160020Münster(Westf)+City')).to.deep.equal({ outbound: { to: 'Münster(Westf)+City' } }); 124 | expect(p('S0150020Münster(Westf)+CityS0160011Berlin+City')).to.deep.equal({ 125 | outbound: { from: 'Münster(Westf)+City', to: 'Berlin+City' }, 126 | }) 127 | }) 128 | 129 | it('parses inbound station information', function () { 130 | expect(p('S0170020Münster(Westf)+City')).to.deep.equal({ inbound: { from: 'Münster(Westf)+City' } }); 131 | expect(p('S0180020Münster(Westf)+City')).to.deep.equal({ inbound: { to: 'Münster(Westf)+City' } }); 132 | expect(p('S0170020Münster(Westf)+CityS0180011Berlin+City')).to.deep.equal({ 133 | inbound: { from: 'Münster(Westf)+City', to: 'Berlin+City' }, 134 | }) 135 | }) 136 | 137 | it('parses VIA information', function () { 138 | const via = 'H: NV*HammWf 20:11 ICE645-ICE655 R: B-Hbf 20:36 IC2240'; 139 | expect(p('S0210054'+via)).to.deep.equal({ via }); 140 | }) 141 | 142 | it('parses owner name', function () { 143 | expect(p('S0230016Musterfrau Maria')).to.deep.equal({ owner: { 144 | fullName: 'Musterfrau Maria' } }); 145 | expect(p('S0230014Mustermann Max')).to.deep.equal({ owner: { 146 | fullName: 'Mustermann Max' } }); 147 | expect(p('S0280013Anna#Beispiel')).to.deep.equal({ owner: { 148 | firstName: 'Anna', lastName: 'Beispiel' } }); 149 | expect(p('S0230013Beispiel AnnaS0280013Anna#Beispiel')).to.deep.equal({ owner: { 150 | fullName: 'Beispiel Anna', firstName: 'Anna', lastName: 'Beispiel' } }); 151 | }) 152 | 153 | it('parses fare type', function () { 154 | expect(p('S02600013')).to.deep.equal({ fareType: FARE_TYPES.RAIL_AND_FLY }); 155 | expect(p('S026000212')).to.deep.equal({ fareType: FARE_TYPES.FLEXIBLE }); 156 | expect(p('S026000213')).to.deep.equal({ fareType: FARE_TYPES.SAVER }); 157 | }) 158 | 159 | it('parses the identification number', function () { 160 | expect(p('S0270004ABCD')).to.deep.equal({ identificationNumber: 'ABCD' }); 161 | }) 162 | 163 | it('parses validity information', function () { 164 | expect(p('S031001004.04.2015')).to.deep.equal({ valid: { from: new Date(2015, 3, 4) }}); 165 | expect(p('S032001004.04.2015')).to.deep.equal({ valid: { until: new Date(2015, 3, 5) }}); 166 | expect(p('S031001004.04.2015S032001004.04.2015')).to.deep.equal({ 167 | valid: { from: new Date(2015, 3, 4), until: new Date(2015, 3, 5) }}); 168 | }) 169 | 170 | it('parses station IDs', function () { 171 | expect(p('S0350003foo')).to.deep.equal({ stationIds: { from: 'foo' } }); 172 | expect(p('S0360003foo')).to.deep.equal({ stationIds: { to: 'foo' } }); 173 | expect(p('S035000512345S0360003678')).to.deep.equal({ stationIds: { from: '12345', to: '678' } }); 174 | }) 175 | 176 | it('passes through unknown properties', function () { 177 | expect(p('S9990006foobar')).to.deep.equal({ S999: 'foobar' }); 178 | expect(p('Sfoo0003fooSbar000242')).to.deep.equal({ Sfoo: 'foo', Sbar: '42' }); 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /test/parse/certificate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const parse = require('../../lib/parse/certificate'); 5 | 6 | const certificateBlock = Buffer.concat([ 7 | Buffer.from('ABCD1234EFG'), 8 | new Buffer([0,0,0,0,0,0,0,0,0,0,0]), 9 | Buffer.from('27122014271220140123456789'), 10 | ]); 11 | 12 | describe('parseCertificate', function () { 13 | it('parses certificate data', function () { 14 | expect(parse(certificateBlock)).to.deep.equal({ 15 | id: 'ABCD1234EFG', 16 | validFrom: new Date(2014, 11, 27), 17 | validUntil: new Date(2014, 11, 28), 18 | serialNumber: '0123456789', 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /test/parse/date.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | 5 | const parseDate = require('../../lib/parse/date'); 6 | 7 | describe('parseDate', function () { 8 | it('parses a DD.MM.YYYY or DDMMYYYY string to a Date object', function () { 9 | expect(parseDate('29.02.2016')).to.deep.equal(new Date(2016, 1, 29)); 10 | expect(parseDate('01011970')).to.deep.equal(new Date(1970, 0, 1)); 11 | expect(parseDate('31122010')).to.deep.equal(new Date(2010, 11, 31)); 12 | }) 13 | 14 | it('accepts HHMM as a second argument for hours and minutes', function () { 15 | expect(parseDate('29.02.2016', '1200')).to.deep.equal(new Date(2016, 1, 29, 12, 0)); 16 | expect(parseDate('01011970', '2400')).to.deep.equal(new Date(1970, 0, 2, 0, 0)); 17 | expect(parseDate('31122010', '1234')).to.deep.equal(new Date(2010, 11, 31, 12, 34)); 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test/parse/header.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const parse = require('../../lib/parse/header'); 5 | 6 | const headerData = Buffer.concat([ 7 | Buffer.from('U_HEAD0100530080AABBCC-3'), 8 | Buffer([0,0,0,0,0,0,0,0,0,0,0,0]), 9 | Buffer.from('0112201412340DEDE'), 10 | ]) 11 | 12 | describe('parseHeader', function () { 13 | it('returns the parsed data and the length of the block', function () { 14 | const parsed = parse(headerData); 15 | expect(parsed).to.have.property('data'); 16 | expect(parsed).to.have.length(headerData.length); 17 | }) 18 | it('stops parsing when the header block ends', function () { 19 | // Add garbage data; it should be ignored in the `length` property 20 | expect( parse(Buffer.concat([ headerData, Buffer.from('FOOBAR') ])) ) 21 | .to.have.length(headerData.length); 22 | }) 23 | it('parses header data', function () { 24 | expect(parse(headerData).data).to.deep.equal({ 25 | version: '01', 26 | pnr: 'AABBCC', 27 | issueDate: new Date(2014, 11, 1, 12, 34), 28 | primaryLanguage: 'DE', 29 | secondaryLanguage: 'DE', 30 | }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/parse/identification.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const parse = require('../../lib/parse/identification'); 5 | const IDENTIFICATION_TYPES = parse.IDENTIFICATION_TYPES; 6 | 7 | describe('parseIdentification', function () { 8 | it('returns the parsed data and the length of the block', function () { 9 | const parsed = parse(Buffer.from('0080ID000018040000')); 10 | expect(parsed).to.have.property('data'); 11 | expect(parsed).to.have.length(18); 12 | }) 13 | it('stops parsing when the header block ends', function () { 14 | // Add garbage data; it should be ignored in the `length` property 15 | expect( parse(Buffer.from('0080ID000018040000FOOBAR')) ) 16 | .to.have.length(18); 17 | }) 18 | it('parses header data', function () { 19 | expect(parse(Buffer.from('0080ID000018041234')).data).to.deep.equal({ 20 | carrier: '0080', 21 | type: IDENTIFICATION_TYPES.BAHNCARD, 22 | lastDigits: '1234', 23 | }) 24 | expect(parse(Buffer.from('0080ID000018011234'))).to.have.deep.property('data.type', IDENTIFICATION_TYPES.CREDIT_CARD); 25 | expect(parse(Buffer.from('0080ID000018041234'))).to.have.deep.property('data.type', IDENTIFICATION_TYPES.BAHNCARD); 26 | expect(parse(Buffer.from('0080ID000018071234'))).to.have.deep.property('data.type', IDENTIFICATION_TYPES.DEBIT_CARD); 27 | expect(parse(Buffer.from('0080ID000018081234'))).to.have.deep.property('data.type', IDENTIFICATION_TYPES.BONUSCARD_BUSINESS); 28 | expect(parse(Buffer.from('0080ID000018091234'))).to.have.deep.property('data.type', IDENTIFICATION_TYPES.NATIONAL_ID); 29 | expect(parse(Buffer.from('0080ID000018101234'))).to.have.deep.property('data.type', IDENTIFICATION_TYPES.PASSPORT); 30 | expect(parse(Buffer.from('0080ID000018111234'))).to.have.deep.property('data.type', IDENTIFICATION_TYPES.BAHNBONUS_CARD); 31 | }) 32 | it('exposes all possible IDENTIFICATION_TYPES', function () { 33 | expect(parse.IDENTIFICATION_TYPES).to.deep.equal({ 34 | CREDIT_CARD: 'Credit Card', 35 | BAHNCARD: 'BahnCard', 36 | DEBIT_CARD: 'Debit Card', 37 | BONUSCARD_BUSINESS: 'BonusCard Business', 38 | NATIONAL_ID: 'National ID Card', 39 | PASSPORT: 'Passport', 40 | BAHNBONUS_CARD: 'bahn.bonus Card', 41 | }) 42 | }) 43 | }) 44 | --------------------------------------------------------------------------------