├── .gitignore ├── README.md ├── examples └── eBons │ ├── 1.pdf │ ├── 2.pdf │ ├── 3.pdf │ ├── 4.pdf │ └── 5.pdf ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── ebon-types.ts ├── example.ts ├── index.spec.ts └── index.ts ├── tsconfig.json └── types └── pdf-parse.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REWE eBon parser 2 | This library parses [REWE eBons](https://web.archive.org/web/20201231133621/https://www.rewe-group.com/de/newsroom/pressemitteilungen/1753-rewe-elektronischer-kassenbon) into JS objects. 3 | 4 | ## Installation 5 | ```shell 6 | $ npm install --save rewe-ebon-parser 7 | ``` 8 | 9 | ## Usage 10 | ```js 11 | async function main() { 12 | const dataBuffer = fs.readFileSync('ebon.pdf'); 13 | const receipt = await parseEBon(dataBuffer); 14 | console.log(JSON.stringify(receipt, undefined, 2)); 15 | } 16 | 17 | main().catch(console.error); 18 | ``` 19 | 20 | The parser will perform a quick check in order to find out if it missed an item of the eBon by comparing the eBon's total sum and its own total sum. If there is a mismatch, an `Error` is thrown: 21 | 22 | ``` 23 | Error: Something went wrong when parsing the eBon: The eBon states a total sum of 50.27 but the parser only found items worth 45.69. 24 | ``` 25 | 26 | If this happens to you, it is likely that your eBon uses a slightly different format. In this case, please open an issue on GitHub. 27 | 28 | ## Example 29 | ```json 30 | { 31 | "date": "2019-12-07T16:21:00.000Z", 32 | "market": "0449", 33 | "cashier": "545454", 34 | "checkout": "3", 35 | "vatin": "DE812706034", 36 | "items": [ 37 | { 38 | "taxCategory": "B", 39 | "name": "SCHW.SCHINKEN", 40 | "amount": 1, 41 | "subTotal": 1.99, 42 | "paybackQualified": true 43 | }, 44 | { 45 | "taxCategory": "B", 46 | "name": "GULASCHSUPPE", 47 | "amount": 1, 48 | "subTotal": 2.99, 49 | "paybackQualified": true 50 | }, 51 | { 52 | "taxCategory": "B", 53 | "name": "SCHINKENWURST", 54 | "amount": 1, 55 | "subTotal": 0.99, 56 | "paybackQualified": true 57 | }, 58 | { 59 | "taxCategory": "B", 60 | "name": "TEEWURST FEIN", 61 | "amount": 1, 62 | "subTotal": 0.99, 63 | "paybackQualified": true 64 | }, 65 | { 66 | "taxCategory": "B", 67 | "name": "ROHSCHINKEN GEW.", 68 | "amount": 1, 69 | "subTotal": 1.79, 70 | "paybackQualified": true 71 | }, 72 | { 73 | "taxCategory": "B", 74 | "name": "CARACTERE SCHEI.", 75 | "amount": 1, 76 | "subTotal": 1.49, 77 | "paybackQualified": true 78 | }, 79 | { 80 | "taxCategory": "B", 81 | "name": "OFENKAESE WUERZ.", 82 | "amount": 1, 83 | "subTotal": 2.69, 84 | "paybackQualified": true 85 | }, 86 | { 87 | "taxCategory": "B", 88 | "name": "1688 MEHRKORN", 89 | "amount": 1, 90 | "subTotal": 1.29, 91 | "paybackQualified": true 92 | }, 93 | { 94 | "taxCategory": "B", 95 | "name": "BUTTERTOAST", 96 | "amount": 1, 97 | "subTotal": 0.69, 98 | "paybackQualified": true 99 | }, 100 | { 101 | "taxCategory": "B", 102 | "name": "SONNTAGSBROETCH.", 103 | "amount": 1, 104 | "subTotal": 0.99, 105 | "paybackQualified": true 106 | }, 107 | { 108 | "taxCategory": "B", 109 | "name": "BIO EIER M+L KLA", 110 | "amount": 1, 111 | "subTotal": 2.15, 112 | "paybackQualified": true 113 | }, 114 | { 115 | "taxCategory": "B", 116 | "name": "RAMA M.BUTTER+ME", 117 | "amount": 1, 118 | "subTotal": 1.29, 119 | "paybackQualified": true 120 | }, 121 | { 122 | "taxCategory": "B", 123 | "name": "H-MILCH GVO-FREI", 124 | "amount": 4, 125 | "subTotal": 2.92, 126 | "paybackQualified": true, 127 | "unit": "Stk", 128 | "pricePerUnit": 0.73 129 | }, 130 | { 131 | "taxCategory": "B", 132 | "name": "TK HAE.CORD.BLEU", 133 | "amount": 1, 134 | "subTotal": 2.89, 135 | "paybackQualified": true 136 | }, 137 | { 138 | "taxCategory": "B", 139 | "name": "PIZZA SALAMI", 140 | "amount": 1, 141 | "subTotal": 2.29, 142 | "paybackQualified": true 143 | }, 144 | { 145 | "taxCategory": "B", 146 | "name": "STEINOFEN PIZZA", 147 | "amount": 1, 148 | "subTotal": 2.29, 149 | "paybackQualified": true 150 | }, 151 | { 152 | "taxCategory": "B", 153 | "name": "JA! WEIZENMEHL", 154 | "amount": 1, 155 | "subTotal": 0.39, 156 | "paybackQualified": true 157 | }, 158 | { 159 | "taxCategory": "B", 160 | "name": "FUSILLI", 161 | "amount": 1, 162 | "subTotal": 0.39, 163 | "paybackQualified": true 164 | }, 165 | { 166 | "taxCategory": "B", 167 | "name": "PENNE RIGATE", 168 | "amount": 1, 169 | "subTotal": 0.39, 170 | "paybackQualified": true 171 | }, 172 | { 173 | "taxCategory": "B", 174 | "name": "SPAGHETTIGERICHT", 175 | "amount": 1, 176 | "subTotal": 0.69, 177 | "paybackQualified": true 178 | }, 179 | { 180 | "taxCategory": "B", 181 | "name": "LECKER F. RUEH.", 182 | "amount": 1, 183 | "subTotal": 3.29, 184 | "paybackQualified": true 185 | }, 186 | { 187 | "taxCategory": "B", 188 | "name": "SAMT R. FRUECHTE", 189 | "amount": 1, 190 | "subTotal": 2.19, 191 | "paybackQualified": true 192 | }, 193 | { 194 | "taxCategory": "B", 195 | "name": "CRUNCHIPS WESTER", 196 | "amount": 1, 197 | "subTotal": 1.39, 198 | "paybackQualified": true 199 | }, 200 | { 201 | "taxCategory": "B", 202 | "name": "CRUNCHIPS CHEESE", 203 | "amount": 1, 204 | "subTotal": 1.39, 205 | "paybackQualified": true 206 | }, 207 | { 208 | "taxCategory": "A", 209 | "name": "FRUEH KOELSCH", 210 | "amount": 1, 211 | "subTotal": 4.29, 212 | "paybackQualified": true 213 | }, 214 | { 215 | "taxCategory": "A", 216 | "name": "PFAND 0,48 EUR", 217 | "amount": 1, 218 | "subTotal": 0.48, 219 | "paybackQualified": false 220 | }, 221 | { 222 | "taxCategory": "A", 223 | "name": "GAFFEL FASSBRAUS", 224 | "amount": 1, 225 | "subTotal": 3.79, 226 | "paybackQualified": true 227 | }, 228 | { 229 | "taxCategory": "A", 230 | "name": "PFAND 0,48 EUR", 231 | "amount": 1, 232 | "subTotal": 0.48, 233 | "paybackQualified": false 234 | }, 235 | { 236 | "taxCategory": "B", 237 | "name": "LUNGO KAPSELN", 238 | "amount": 2, 239 | "subTotal": 3.98, 240 | "paybackQualified": true, 241 | "unit": "Stk", 242 | "pricePerUnit": 1.99 243 | }, 244 | { 245 | "taxCategory": "A", 246 | "name": "Mitarbeiterrabatt 5%", 247 | "amount": 1, 248 | "subTotal": -0.4, 249 | "paybackQualified": false 250 | }, 251 | { 252 | "taxCategory": "B", 253 | "name": "Mitarbeiterrabatt 5%", 254 | "amount": 1, 255 | "subTotal": -2.2, 256 | "paybackQualified": false 257 | } 258 | ], 259 | "total": 50.27, 260 | "given": [ 261 | { 262 | "type": "REWE Guthaben", 263 | "value": 30 264 | }, 265 | { 266 | "type": "EC-Cash", 267 | "value": 20.27 268 | } 269 | ], 270 | "payback": { 271 | "card": "#########9334", 272 | "pointsBefore": 4, 273 | "earnedPoints": 405, 274 | "basePoints": 24, 275 | "couponPoints": 381, 276 | "qualifiedRevenue": 49.31, 277 | "usedCoupons": [ 278 | { 279 | "name": "eCoupon 10FACH P. Milch", 280 | "points": 9 281 | }, 282 | { 283 | "name": "eCoupon 10FACH Punkte Bier", 284 | "points": 27 285 | }, 286 | { 287 | "name": "eCoupon 15FACH Punkte", 288 | "points": 336 289 | }, 290 | { 291 | "name": "Coupon Brotaufstrich10-fach", 292 | "points": 9 293 | } 294 | ], 295 | "usedREWECredit": 30, 296 | "newREWECredit": 0 297 | }, 298 | "taxDetails": { 299 | "total": { 300 | "net": 46.17, 301 | "tax": 4.1, 302 | "gross": 50.27 303 | }, 304 | "A": { 305 | "taxPercent": 19, 306 | "net": 7.26, 307 | "tax": 1.38, 308 | "gross": 8.64 309 | }, 310 | "B": { 311 | "taxPercent": 7, 312 | "net": 38.91, 313 | "tax": 2.72, 314 | "gross": 41.63 315 | } 316 | } 317 | } 318 | 319 | ``` 320 | -------------------------------------------------------------------------------- /examples/eBons/1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webD97/rewe-ebon-parser/4a5e986ad96bf833bbb0e4c6e2ea36595c8a0169/examples/eBons/1.pdf -------------------------------------------------------------------------------- /examples/eBons/2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webD97/rewe-ebon-parser/4a5e986ad96bf833bbb0e4c6e2ea36595c8a0169/examples/eBons/2.pdf -------------------------------------------------------------------------------- /examples/eBons/3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webD97/rewe-ebon-parser/4a5e986ad96bf833bbb0e4c6e2ea36595c8a0169/examples/eBons/3.pdf -------------------------------------------------------------------------------- /examples/eBons/4.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webD97/rewe-ebon-parser/4a5e986ad96bf833bbb0e4c6e2ea36595c8a0169/examples/eBons/4.pdf -------------------------------------------------------------------------------- /examples/eBons/5.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webD97/rewe-ebon-parser/4a5e986ad96bf833bbb0e4c6e2ea36595c8a0169/examples/eBons/5.pdf -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { '^.+\\.ts?$': 'ts-jest' }, 3 | testEnvironment: 'node', 4 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] 5 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rewe-ebon-parser", 3 | "version": "0.4.0", 4 | "description": "A parser for REWE eBons", 5 | "keywords": [ 6 | "rewe", 7 | "ebon", 8 | "payback" 9 | ], 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "scripts": { 13 | "start": "node ./dist/index.js", 14 | "build": "./node_modules/.bin/tsc", 15 | "watch": "./node_modules/.bin/tsc -w", 16 | "test": "jest", 17 | "prepare": "npm run build", 18 | "prepublishOnly": "npm test", 19 | "postversion": "git push && git push --tags" 20 | }, 21 | "files": [ 22 | "dist/**/*" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/webd97/rewe-ebon-parser.git" 27 | }, 28 | "author": "Christian Danscheid", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/webd97/rewe-ebon-parser/issues" 32 | }, 33 | "homepage": "https://github.com/webd97/rewe-ebon-parser#readme", 34 | "dependencies": { 35 | "pdf-parse": "^1.1.1" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^29.5.3", 39 | "@types/node": "^18.0.0", 40 | "jest": "^29.6.2", 41 | "ts-jest": "^29.1.1", 42 | "typescript": "^5.1.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ebon-types.ts: -------------------------------------------------------------------------------- 1 | export type TaxCategory = 'A' | 'B'; 2 | 3 | export type ReceiptItem = { 4 | /** The tax category of this item, used to calculate the tax */ 5 | taxCategory: TaxCategory, 6 | 7 | /** Name of this item */ 8 | name: string, 9 | 10 | /** Subtotal value including multiple units */ 11 | subTotal: number, 12 | 13 | /** Whether the item qualifies for PayBack */ 14 | paybackQualified: boolean, 15 | 16 | /** Amount of this item; for unit, see the unit field */ 17 | amount: number, 18 | 19 | /** The unit of this item (piece, kg etc.) */ 20 | unit?: string, 21 | 22 | /** Price per unit of this item */ 23 | pricePerUnit?: number 24 | }; 25 | 26 | export type Payment = { 27 | /** Type of payment (cash, EC, REWE credit etc.) */ 28 | type: string, 29 | 30 | /** Value of this payment */ 31 | value: number 32 | } 33 | 34 | export type MarketAddress = { 35 | /** Street of market */ 36 | street: string; 37 | 38 | /** ZIP of market */ 39 | zip: string; 40 | 41 | /** City of market */ 42 | city: string; 43 | }; 44 | 45 | export type Receipt = { 46 | /** Date and time of the purchase */ 47 | date: Date, 48 | 49 | /** Market identifier */ 50 | market: string, 51 | 52 | /** Market address */ 53 | marketAddress?: MarketAddress; 54 | 55 | /** Cashier identifier */ 56 | cashier: string, 57 | 58 | /** Checkout identifier */ 59 | checkout: string, 60 | 61 | /** Value Added Tax Identification Number */ 62 | vatin: string, 63 | 64 | /** Items of this purchase */ 65 | items: ReceiptItem[], 66 | 67 | /** Total sum */ 68 | total: number, 69 | 70 | /** The customer's payments */ 71 | given: Payment[], 72 | 73 | /** Change given to the customer if he paid cash */ 74 | change?: number, 75 | 76 | /** Cash money paid out; only defined if the customer used this feature */ 77 | payout?: number, 78 | 79 | /** PayBack-related data, only defined if the customer used a PayBack card */ 80 | payback?: PaybackData, 81 | 82 | /** Tax data */ 83 | taxDetails: TaxDetails 84 | }; 85 | 86 | export type PaybackCoupon = { 87 | /** Name of this coupon */ 88 | name: string, 89 | 90 | /** PayBack Points generated with this coupon */ 91 | points: number 92 | }; 93 | 94 | export type PaybackData = { 95 | /** Card identification */ 96 | card: string, 97 | 98 | /** Amount of PayBack points before this purchase */ 99 | pointsBefore: number, 100 | 101 | /** PayBack points earned with this purchase */ 102 | earnedPoints: number, 103 | 104 | /** PayBack points excluding points generated by coupns */ 105 | basePoints: number, 106 | 107 | /** PayBack points generated with coupons */ 108 | couponPoints: number, 109 | 110 | /** PayBack-qualified revenue in this purchase */ 111 | qualifiedRevenue: number, 112 | 113 | /** Coupons used in this purchase */ 114 | usedCoupons: PaybackCoupon[], 115 | 116 | /** If the customer payed with REWE PayBack credit, this is the amount used, otherwise undefined */ 117 | usedREWECredit?: number, 118 | 119 | /** If the customer payed with REWE PayBack credit, this is the amount of REWE PayBack credit left after the purchase */ 120 | newREWECredit?: number 121 | }; 122 | 123 | export type TaxDetailsEntry = { 124 | /** Tax percentage, e.g. 19 */ 125 | taxPercent: number, 126 | 127 | /** Net value */ 128 | net: number, 129 | 130 | /** Tax amount */ 131 | tax: number, 132 | 133 | /** Gross value (net + tax) */ 134 | gross: number 135 | }; 136 | 137 | export type TaxDetails = { 138 | /** Total tax info */ 139 | total: Omit, 140 | /** Tax on category A items */ 141 | A?: TaxDetailsEntry, 142 | 143 | /** Tax on category B items */ 144 | B?: TaxDetailsEntry 145 | }; 146 | -------------------------------------------------------------------------------- /src/example.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { parseEBon } from './index'; 3 | 4 | async function main() { 5 | const data = readFileSync('examples/eBons/3.pdf'); 6 | console.log( 7 | JSON.stringify(await parseEBon(data), undefined, 2) 8 | ) 9 | } 10 | 11 | main(); -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { parseEBon } from '.'; 3 | import { PaybackData, Receipt } from './ebon-types'; 4 | 5 | describe('given example pdf #5', () => { 6 | let ebon: Receipt; 7 | 8 | beforeAll(async () => { 9 | ebon = await parseEBon(readFileSync('./examples/eBons/5.pdf')); 10 | }); 11 | 12 | describe('purchase metadata', () => { 13 | test('finds correct date', () => { 14 | expect(ebon.date).toBeInstanceOf(Date); 15 | expect(ebon.date.toISOString()).toBe("2023-08-11T16:09:00.000Z"); 16 | }); 17 | 18 | test('finds correct cashier', () => { 19 | expect(ebon.cashier).toBe('303030'); 20 | }); 21 | 22 | test('finds correct checkout', () => { 23 | expect(ebon.checkout).toBe('3'); 24 | }); 25 | 26 | test('finds correct store', () => { 27 | expect(ebon.market).toBe('5472'); 28 | }); 29 | 30 | test('finds correct store address', () => { 31 | expect(ebon.marketAddress).toEqual({ 32 | street: "Im Weidenbruch 136", 33 | zip: "51061", 34 | city: "Köln" 35 | }); 36 | }); 37 | }); 38 | 39 | describe('purchase', () => { 40 | test('finds correct amount of items', () => { 41 | expect(ebon.items.length).toBe(18); 42 | }); 43 | 44 | test('correctly identifies item #1 (single)', () => { 45 | const item = ebon.items[0]; 46 | expect(item.name).toBe('SALAMI SPITZENQ.'); 47 | expect(item.amount).toBe(1); 48 | expect(item.paybackQualified).toBe(true); 49 | expect(item.pricePerUnit).toBeUndefined(); 50 | expect(item.taxCategory).toBe('B'); 51 | expect(item.unit).toBeUndefined(); 52 | expect(item.subTotal).toBeCloseTo(1.79); 53 | }); 54 | 55 | test('correctly identifies item #7 (multiple)', () => { 56 | const item = ebon.items[6]; 57 | expect(item.name).toBe('BAG. SPECIALE'); 58 | expect(item.amount).toBe(2); 59 | expect(item.paybackQualified).toBe(true); 60 | expect(item.pricePerUnit).toBeCloseTo(2.29); 61 | expect(item.taxCategory).toBe('B'); 62 | expect(item.unit).toBe('Stk'); 63 | expect(item.subTotal).toBeCloseTo(4.58); 64 | }); 65 | 66 | test('correctly identifies item #17 (discount)', () => { 67 | const item = ebon.items[16]; 68 | expect(item.name).toBe('Mitarbeiterrabatt 5%'); 69 | expect(item.paybackQualified).toBe(false); 70 | expect(item.taxCategory).toBe('A'); 71 | expect(item.subTotal).toBeCloseTo(-0.29); 72 | }); 73 | 74 | test('returns payout as undefined', () => { 75 | expect(ebon.payout).toBeUndefined(); 76 | }); 77 | 78 | test('correctly identifies total', () => { 79 | expect(ebon.total).toBeCloseTo(39.44); 80 | }); 81 | 82 | test('correctly identifies Payback information', () => { 83 | expect(ebon.payback?.card).toBe('#########9334'); 84 | expect(ebon.payback?.basePoints).toBe(19); 85 | expect(ebon.payback?.couponPoints).toBe(0); 86 | expect(ebon.payback?.earnedPoints).toBe(19); 87 | expect(ebon.payback?.pointsBefore).toBe(7638); 88 | expect(ebon.payback?.qualifiedRevenue).toBeCloseTo(39.44); 89 | expect(ebon.payback?.usedCoupons).toEqual([]); 90 | expect(ebon.payback?.usedREWECredit).toBeUndefined; 91 | expect(ebon.payback?.newREWECredit).toBeUndefined; 92 | }); 93 | }); 94 | 95 | describe('customer payment', () => { 96 | test('finds correct given money', () => { 97 | expect(ebon.given.length).toBe(2); 98 | }); 99 | 100 | test('correctly handles "Inflationsprämie"', () => { 101 | expect(ebon.given[0].type).toBe("Inflationsprämie"); 102 | expect(ebon.given[0].value).toBeCloseTo(2.08); 103 | }); 104 | 105 | test('correctly handles "EC-Cash"', () => { 106 | expect(ebon.given[1].type).toBe("EC-Cash"); 107 | expect(ebon.given[1].value).toBeCloseTo(37.36); 108 | }); 109 | 110 | test('determines change as undefined', () => { 111 | expect(ebon.change).toBeUndefined(); 112 | }); 113 | }); 114 | 115 | }); 116 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import pdf from 'pdf-parse'; 2 | import { MarketAddress, PaybackCoupon, Payment, Receipt, ReceiptItem, TaxCategory, TaxDetails } from './ebon-types'; 3 | 4 | /** 5 | * Create a Receipt object from a REWE eBon PDF file. 6 | * @param dataBuffer PDF data 7 | */ 8 | export async function parseEBon(dataBuffer: Buffer): Promise { 9 | const data: { text: string } = await pdf(dataBuffer); 10 | const lines = data.text 11 | .replace(/ +/g, ' ') 12 | .split('\n') 13 | .map(line => line.trim()) 14 | .filter(line => line !== ''); 15 | 16 | let date: Date = new Date(), 17 | market: string = '?', 18 | marketAddress: MarketAddress | undefined = undefined, 19 | cashier: string = '?', 20 | checkout: string = '?', 21 | uid: string = '?', 22 | items: ReceiptItem[] = [], 23 | total: number = Number.NaN, 24 | given: Payment[] = [], 25 | change: number = Number.NaN, 26 | payout: number = Number.NaN, 27 | paybackPointsBefore = Number.NaN, 28 | paybackPoints: number = Number.NaN, 29 | paybackRevenue: number = Number.NaN, 30 | paybackCardNumber: string = '?', 31 | paybackCoupons: PaybackCoupon[] = [], 32 | usedREWECredit: number = Number.NaN, 33 | newREWECredit: number = Number.NaN, 34 | taxDetails: TaxDetails = { 35 | total: { net: Number.NaN, tax: Number.NaN, gross: Number.NaN } 36 | }; 37 | 38 | const addressMatch = data.text.match(/[\s\*]*([a-zäöüß \d.,-]+?)[\s\*]*(\d{5})\s*([a-zäöüß \d.,-]+)[\s\*]*/im); 39 | 40 | if (addressMatch) { 41 | marketAddress = { 42 | street: addressMatch[1].replace(/\s+/g, ' ').replace(/,$/, '').trim(), 43 | zip: addressMatch[2], 44 | city: addressMatch[3].trim(), 45 | }; 46 | } 47 | 48 | lines.forEach(line => { 49 | const itemHit = line.match(/([0-9A-Za-zäöüÄÖÜß &%.!+,\-]*) (-?\d*,\d\d) ([AB]) ?(\*?)/); 50 | 51 | if (itemHit) { 52 | const item = itemHit[1]; 53 | const price = parseFloat(itemHit[2].replace(',', '.')); 54 | const category = itemHit[3] as TaxCategory; 55 | const paybackQualified = !itemHit[4] && price > 0; 56 | 57 | items.push({ 58 | taxCategory: category, 59 | name: item, 60 | amount: 1, 61 | subTotal: price, 62 | paybackQualified: paybackQualified 63 | }); 64 | 65 | return; 66 | } 67 | 68 | const mengeHit = line.match(/(.*) (.*) x (.*).*/); 69 | 70 | if (mengeHit) { 71 | items[items.length - 1] = { 72 | ...items[items.length - 1], 73 | amount: parseFloat(mengeHit[1].replace(',', '.')), 74 | unit: mengeHit[2], 75 | pricePerUnit: parseFloat(mengeHit[3].replace(',', '.')) 76 | } 77 | 78 | return; 79 | } 80 | 81 | const totalHit = line.match(/SUMME EUR (-?\d*,\d\d)/); 82 | 83 | if (totalHit) { 84 | total = parseFloat(totalHit[1].replace(',', '.')); 85 | 86 | return; 87 | } 88 | 89 | const gegebenHit = line.match(/Geg\. (.*) EUR ([0-9,]*)/); 90 | 91 | if (gegebenHit) { 92 | given.push({ 93 | type: gegebenHit[1], 94 | value: parseFloat(gegebenHit[2].replace(',', '.')) 95 | }); 96 | 97 | return; 98 | } 99 | 100 | const returnHit = line.match(/Rückgeld BAR EUR ([0-9,]*)/); 101 | 102 | if (returnHit) { 103 | change = parseFloat(returnHit[1].replace(',', '.')); 104 | 105 | return; 106 | } 107 | 108 | const payoutMatch = line.match(/AUSZAHLUNG EUR ([0-9,]*)/); 109 | 110 | if (payoutMatch) { 111 | payout = parseFloat(payoutMatch[1].replace(',', '.')); 112 | 113 | return; 114 | } 115 | 116 | const timestampHit = line.match(/(\d*)\.(\d*)\.(\d*) (\d*):(\d*) Bon-Nr\.:(.*)/); 117 | 118 | if (timestampHit) { 119 | date = new Date(Date.UTC( 120 | parseInt(timestampHit[3]), 121 | parseInt(timestampHit[2]) - 1, 122 | parseInt(timestampHit[1]), 123 | parseInt(timestampHit[4]), 124 | parseInt(timestampHit[5]) 125 | )); 126 | 127 | return; 128 | } 129 | 130 | const marktMatch = line.match(/Markt:(.*) Kasse:(.*) Bed\.:(.*)/); 131 | 132 | if (marktMatch) { 133 | market = marktMatch[1]; 134 | checkout = marktMatch[2]; 135 | cashier = marktMatch[3]; 136 | 137 | return; 138 | } 139 | 140 | const uidMatch = line.match(/UID Nr.: (.*)/); 141 | 142 | if (uidMatch) { 143 | uid = uidMatch[1]; 144 | 145 | return; 146 | } 147 | 148 | const paybackPointsMatch = line.match(/Sie erhalten (\d*) PAYBACK Punkte? auf|Mit diesem Einkauf gesammelt: (\d*) Punkte?/); 149 | 150 | if (paybackPointsMatch) { 151 | const match = paybackPointsMatch.slice(1).find(group => group != null)!; 152 | paybackPoints = parseInt(match); 153 | 154 | return; 155 | } 156 | 157 | const paybackRevenueMatch = line.match(/einen PAYBACK Umsatz von (.*) EUR!/); 158 | 159 | if (paybackRevenueMatch) { 160 | paybackRevenue = parseFloat(paybackRevenueMatch[1].replace(',', '.')); 161 | 162 | return; 163 | } 164 | 165 | const paybackPointsBeforeMatch = line.match(/Punktestand vor Einkauf: ([0-9.]*)|Punkte vor dem Einkauf: ([0-9.]*)/); 166 | 167 | if (paybackPointsBeforeMatch) { 168 | const match = paybackPointsBeforeMatch.slice(1).find(group => group != null)!; 169 | paybackPointsBefore = parseFloat(match.replace('.', '')); 170 | 171 | return; 172 | } 173 | 174 | const paybackCardNumberMatch = line.match(/PAYBACK Karten-Nr\.: ([0-9#]*)/); 175 | 176 | if (paybackCardNumberMatch) { 177 | paybackCardNumber = paybackCardNumberMatch[1]; 178 | 179 | return; 180 | } 181 | 182 | const paybackCouponMatch = line.match(/(.*) ([0-9.]*) Punkte?/); 183 | 184 | if (paybackCouponMatch) { 185 | paybackCoupons.push({ 186 | name: paybackCouponMatch[1], 187 | points: parseInt(paybackCouponMatch[2].replace('.', '')) 188 | }); 189 | 190 | return; 191 | } 192 | 193 | const taxDetailsMatch = line.match(/([AB])= ([0-9,]*)% ([0-9,]*) ([0-9,]*) ([0-9,]*)/); 194 | 195 | if (taxDetailsMatch) { 196 | taxDetails[taxDetailsMatch[1] as TaxCategory] = { 197 | taxPercent: parseFloat(taxDetailsMatch[2].replace(',', '.')), 198 | net: parseFloat(taxDetailsMatch[3].replace(',', '.')), 199 | tax: parseFloat(taxDetailsMatch[4].replace(',', '.')), 200 | gross: parseFloat(taxDetailsMatch[5].replace(',', '.')), 201 | } 202 | 203 | return; 204 | } 205 | 206 | const totalTaxMatch = line.match(/Gesamtbetrag ([0-9,]*) ([0-9,]*) ([0-9,]*)/); 207 | 208 | if (totalTaxMatch) { 209 | taxDetails.total = { 210 | net: parseFloat(totalTaxMatch[1].replace(',', '.')), 211 | tax: parseFloat(totalTaxMatch[2].replace(',', '.')), 212 | gross: parseFloat(totalTaxMatch[3].replace(',', '.')), 213 | }; 214 | 215 | return; 216 | } 217 | 218 | const usedREWECreditMatch = line.match(/Eingesetztes REWE Guthaben: ([0-9,]*) EUR/); 219 | 220 | if (usedREWECreditMatch) { 221 | usedREWECredit = parseFloat(usedREWECreditMatch[1].replace(',', '.')); 222 | } 223 | 224 | const newREWECreditMatch = line.match(/Neues REWE Guthaben: ([0-9,]*) EUR/); 225 | 226 | if (newREWECreditMatch) { 227 | newREWECredit = parseFloat(newREWECreditMatch[1].replace(',', '.')); 228 | } 229 | }); 230 | 231 | // Check if we missed an item 232 | const realTotalInCents = items.reduce((accumulator, nextItem) => accumulator + nextItem.subTotal * 100, 0); 233 | const totalInCents = total * 100; 234 | 235 | if (realTotalInCents.toFixed(2) !== totalInCents.toFixed(2)) { 236 | throw new Error(`Something went wrong when parsing the eBon: The eBon states a total sum of ${totalInCents} but the parser only found items worth ${realTotalInCents}.`); 237 | } 238 | 239 | return { 240 | date: date, 241 | market: market, 242 | marketAddress: marketAddress, 243 | cashier: cashier, 244 | checkout: checkout, 245 | vatin: uid, 246 | items: items, 247 | total: total, 248 | given: given, 249 | change: change ? change : undefined, 250 | payout: payout ? payout : undefined, 251 | payback: paybackCardNumber ? ({ 252 | card: paybackCardNumber, 253 | pointsBefore: paybackPointsBefore, 254 | earnedPoints: paybackPoints, 255 | get basePoints() { 256 | return this.earnedPoints - this.couponPoints 257 | }, 258 | get couponPoints() { 259 | return this.usedCoupons.reduce((accumulator, nextCoupon) => accumulator + nextCoupon.points, 0) 260 | }, 261 | get qualifiedRevenue() { 262 | if (!Number.isNaN(paybackRevenue)) return paybackRevenue; 263 | return items.filter(item => item.paybackQualified || item.subTotal < 0).reduce((prev, next) => prev + next.subTotal, 0) 264 | }, 265 | usedCoupons: paybackCoupons, 266 | usedREWECredit: usedREWECredit ? usedREWECredit : undefined, 267 | newREWECredit: !isNaN(newREWECredit) ? newREWECredit : undefined 268 | }) : undefined, 269 | taxDetails: taxDetails 270 | }; 271 | } 272 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | "lib": ["ES5"], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist", /* Redirect output structure to the directory. */ 16 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | "removeComments": false, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | 63 | /* Advanced Options */ 64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /types/pdf-parse.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pdf-parse'; 2 | --------------------------------------------------------------------------------