/** * Parse a downloaded Amazon Ads (Sponsored Ads) invoice PDF into an AdsInvoice. * * The console PDFs ("INVOICE-.pdf") are Chromium-rendered with a clean text * layer, so no OCR is needed: the pdfjs text items (via the inbox's * extractTextLayer) are regrouped into layout lines by their y position and read * with label regexes. German and English templates are supported (the account * language decides which one the console renders). * * Header layout: three columns (Von | Zu | Informationen zur Rechnung), so the * header block is split by x into columns before reading issuer / payer. * Campaign table: the campaign NAME wraps over several lines around the row that * carries type · clicks · CPC · amount — name fragments are assigned to the * nearest row by y. */ import { extractTextLayer, type TextItem, type TextPage } from '../inbox/pdfTextLayer' import type { AdsInvoice, AdsInvoiceLine } from './types' import { round2 } from './types' export interface Line { y: number; x: number; text: string; items: TextItem[] } /** Group text items of a page into visual lines (top → bottom, left → right). */ export const layoutLines = (page: TextPage, tolerance = 3): Line[] => { const sorted = [...page.items].sort((a, b) => (b.y - a.y) || (a.x - b.x)) const lines: Line[] = [] for (const it of sorted) { const last = lines[lines.length - 1] if (last && Math.abs(last.y - it.y) <= tolerance) { last.items.push(it); continue } lines.push({ y: it.y, x: it.x, text: '', items: [it] }) } for (const l of lines) { l.items.sort((a, b) => a.x - b.x) let text = '' let prevEnd: number | null = null for (const it of l.items) { const gap = prevEnd == null ? 0 : it.x - prevEnd if (text && gap > 1.5) text += gap > 12 ? ' ' : ' ' text += it.text prevEnd = it.x + (it.w || 0) } l.text = text.replace(/\s+/g, ' ').trim() l.x = l.items[0]?.x ?? 0 } return lines.filter(l => l.text) } const MONTHS: Record = { jan: '01', feb: '02', mar: '03', mär: '03', maerz: '03', apr: '04', may: '05', mai: '05', jun: '06', jul: '07', aug: '08', sep: '09', oct: '10', okt: '10', nov: '11', dec: '12', dez: '12' } /** dd-mm-yyyy · dd.mm.yyyy · yyyy-mm-dd · "31 Jan 2026" · "Jan 31, 2026" → YYYY-MM-DD */ export const parseAdsDate = (s: any): string | null => { if (!s) return null const str = String(s).trim() let m = str.match(/(\d{1,2})[-.\/](\d{1,2})[-.\/](\d{4})/) if (m) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}` m = str.match(/(\d{4})-(\d{2})-(\d{2})/) if (m) return `${m[1]}-${m[2]}-${m[3]}` m = str.match(/(\d{1,2})\.?\s+([A-Za-zäÄ]{3,})\.?\s+(\d{4})/) if (m) { const mo = MONTHS[m[2].slice(0, 3).toLowerCase()]; if (mo) return `${m[3]}-${mo}-${m[1].padStart(2, '0')}` } m = str.match(/([A-Za-z]{3,})\.?\s+(\d{1,2}),?\s+(\d{4})/) if (m) { const mo = MONTHS[m[1].slice(0, 3).toLowerCase()]; if (mo) return `${m[3]}-${mo}-${m[2].padStart(2, '0')}` } return null } /** "1.234,56" · "1,234.56" · "19,27" · "19.27" → number */ export const parseAdsAmount = (s: any): number | null => { if (s == null) return null let str = String(s).replace(/[^\d.,-]/g, '') if (!str) return null const lastComma = str.lastIndexOf(','), lastDot = str.lastIndexOf('.') if (lastComma > -1 && lastDot > -1) { if (lastComma > lastDot) str = str.replace(/\./g, '').replace(',', '.') else str = str.replace(/,/g, '') } else if (lastComma > -1) { // "19,27" (de decimal) vs "1,234" (en thousands) — a comma followed by exactly 3 digits at the end is ambiguous; treat as decimal only when 1-2 digits follow const after = str.length - lastComma - 1 str = after === 3 && !/,\d{3}$/.test(str) ? str.replace(/,/g, '') : (after === 3 ? str.replace(/,/g, '') : str.replace(',', '.')) } const n = Number(str) return Number.isFinite(n) ? n : null } const AMOUNT_RE = '(?:[A-Z]{3}\\s*|€\\s*)?(-?\\d{1,3}(?:[.,]\\d{3})*(?:[.,]\\d{2})|-?\\d+(?:[.,]\\d{2}))(?:\\s*(?:[A-Z]{3}|€))?' const PROGRAM_RE = '(SPONSORED\\s+(?:PRODUCTS?|BRANDS?|DISPLAY(?:\\s+FOR\\s+FIRE\\s+TV)?|TV)|AMAZON\\s+LIVE|CREATOR\\s+CONNECTIONS|AMAZON\\s+DSP)' const findAfter = (lines: Line[], labelRe: RegExp): string | null => { for (const l of lines) { const m = l.text.match(labelRe) if (m) { const rest = l.text.slice((m.index || 0) + m[0].length).trim(); if (rest) return rest } } return null } const amountAfter = (lines: Line[], labelRe: RegExp): number | null => { for (const l of lines) { const m = l.text.match(labelRe) if (!m) continue const rest = l.text.slice((m.index || 0) + m[0].length) const am = rest.match(new RegExp(AMOUNT_RE)) if (am) return parseAdsAmount(am[1]) } return null } export const parseAmazonAdsInvoicePdf = async (pdf: Buffer, fileName?: string): Promise => { const layer = await extractTextLayer(pdf) if (!layer.hasText || !layer.pages.length) return null const pageLines = layer.pages.map(p => layoutLines(p)) const all: Line[] = pageLines.flat() const first = pageLines[0] const pageW = layer.pages[0].width || 595 /* ---- invoice number ---- */ let invoiceNo: string | null = null for (const l of all) { const m = l.text.match(/(?:Nummer der Rechnung|Rechnungsnummer|Invoice (?:Number|No\.?|#)|Número de factura)\s*:?\s*([A-Z0-9-]{6,})/i) if (m) { invoiceNo = m[1]; break } } if (!invoiceNo) { const m = (fileName || '').match(/([0-9]{5,}[A-Z0-9]{4,})/) if (m) invoiceNo = m[1] } if (!invoiceNo) { // label and value on separate lines (right-aligned header block): take the next token-only line const idx = all.findIndex(l => /Nummer der Rechnung|Invoice Number/i.test(l.text)) if (idx > -1) { const cand = all.slice(idx + 1, idx + 3).map(l => l.text.trim()).find(t => /^[A-Z0-9-]{6,}$/.test(t)); if (cand) invoiceNo = cand } } if (!invoiceNo) return null /* ---- dates / currency ---- */ const invoiceDate = parseAdsDate(findAfter(all, /(?:Datum der Rechnung|Rechnungsdatum|Invoice Date|Fecha de (?:la )?factura)\s*:?/i)) const periodRaw = findAfter(all, /(?:Rechnungszeitraum|Abrechnungszeitraum|Billing Period|Invoice Period|Periodo de facturación)\s*:?/i) || '' const periodDates = periodRaw.match(/\d{1,2}[-.\/]\d{1,2}[-.\/]\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}\s+[A-Za-zäÄ]{3,}\.?\s+\d{4}/g) || [] const periodFrom = parseAdsDate(periodDates[0]) || null const periodTo = parseAdsDate(periodDates[1]) || periodFrom const currency = (findAfter(all, /(?:Währung der Rechnung|Rechnungswährung|Invoice Currency|Moneda de la factura)\s*:?/i) || '').match(/[A-Z]{3}/)?.[0] || (all.map(l => l.text).join(' ').match(/\b(EUR|USD|GBP|PLN|SEK)\b/)?.[1]) || 'EUR' /* ---- totals ---- */ const net = amountAfter(all, /(?:Zwischensumme|Subtotal|Sub-?total|Kampagnengebühren insgesamt|Total campaign charges)\s*:?/i) let tax: number | null = null let taxRate: number | null = null for (const l of all) { const m = l.text.match(/\b(?:VAT|MwSt\.?|USt\.?|IVA|Tax)\s*\((\d{1,2}(?:[.,]\d+)?)\s*%\)[^\d-]*?(?:-\s*[A-Z ]+)?\s*(?:[A-Z]{3}\s*|€\s*)?(-?\d[\d.,]*)/i) if (m) { taxRate = Number(m[1].replace(',', '.')); tax = parseAdsAmount(m[2]); break } } if (tax == null) tax = amountAfter(all, /(?:Steuerzwischensumme|Steuerzwische|Tax Subtotal|Total tax)\s*:?/i) const gross = amountAfter(all, /(?:Fälliger Gesamtbetrag|Fälliger Rechnungsbetrag|Gesamtbetrag|Total Amount Due|Amount Due|Total due|Importe total)\s*:?/i) /* ---- payment method ---- */ const fullText = all.map(l => l.text).join('\n') let paymentMethod: string | null = null if (/Zahlbar durch den Verkäufer|Seller Payable|Automatischer Abzug|Deduct(?:ed)? from (?:your )?(?:payment|disbursement)|Verkäuferkonto abgezogen/i.test(fullText)) paymentMethod = 'DEDUCT_FROM_PAYMENT' else if (/Kreditkarte|Credit Card/i.test(fullText)) paymentMethod = 'CREDIT_CARD' else if (/Lastschrift|Direct Debit/i.test(fullText)) paymentMethod = 'DIRECT_DEBIT' else if (/Überweisung|Wire|Funds Transfer/i.test(fullText)) paymentMethod = 'ELECTRONIC_FUNDS_TRANSFER' /* ---- header columns: issuer (Von/From) · payer (Zu/To) ---- */ const summaryIdx = first.findIndex(l => /Zusammenfassung|Invoice Summary|Summary/i.test(l.text)) const headerLines = first.slice(0, summaryIdx > 0 ? summaryIdx : Math.min(first.length, 14)) // measured on the console PDF: Von at x≈51, Zu at x≈187, Informationen at x≈323 (page 596 pt) const colOf = (x: number) => x < pageW * 0.29 ? 0 : x < pageW * 0.53 ? 1 : 2 const cols: string[][] = [[], [], []] for (const l of headerLines) { const byCol: Record = {} for (const it of l.items) { const c = colOf(it.x); (byCol[c] ||= []).push(it.text) } for (const c of [0, 1, 2]) if (byCol[c]) cols[c].push(byCol[c].join(' ').replace(/\s+/g, ' ').trim()) } const readParty = (rows: string[]) => { const clean = rows.filter(r => r && !/^(Von|From|Zu|To|An|De|Para)$/i.test(r)) const vat = clean.map(r => r.match(/\b([A-Z]{2}\s?[0-9A-Z]{8,12})\b/) ).find(Boolean)?.[1]?.replace(/\s/g, '') || undefined const name = clean.find(r => !/Steuernummer|Tax|VAT|USt|Nummer der Rechnung|Invoice/i.test(r)) || undefined const addr = clean.filter(r => r !== name && !/Steuernummer|Tax|VAT|USt/i.test(r)) const cityLine = addr.find(r => /\b[A-Z]{2}\s+\d{4,5}\b/.test(r)) || '' const cm = cityLine.match(/^(.*?)\s*\b([A-Z]{2})\s+(\d{4,5})\b/) return { name, vatId: vat, address1: addr.filter(r => r !== cityLine).join(' ').trim() || undefined, city: cm?.[1]?.trim() || undefined, countryCode: cm?.[2] || undefined, postal: cm?.[3] || undefined } } const issuer = readParty(cols[0]) const payer = readParty(cols[1]) const countryCode = issuer.countryCode || (/GERMANY|Deutschland/i.test(fullText) ? 'DE' : null) /* ---- campaign lines ---- */ const lines: AdsInvoiceLine[] = [] const rowRe = new RegExp(`^(.*?)\\s*${PROGRAM_RE}\\s+(\\d[\\d.,]*)\\s+${AMOUNT_RE}\\s+${AMOUNT_RE}\\s*$`, 'i') for (const page of pageLines) { const headIdx = page.findIndex(l => /(Kampagne|Campaign)\b.*(Kampagnentyp|Campaign Type|Type)/i.test(l.text)) if (headIdx < 0) continue const header = page[headIdx] const typeX = header.items.find(it => /Kampagnentyp|Type/i.test(it.text))?.x ?? pageW * 0.3 const endIdx = page.findIndex((l, i) => i > headIdx && /(Kampagnengebühren insgesamt|Total campaign charges|Häufig gestellte|Frequently asked)/i.test(l.text) && !new RegExp(PROGRAM_RE, 'i').test(l.text)) const body = page.slice(headIdx + 1, endIdx > 0 ? endIdx : page.length) const rows: { y: number; line: AdsInvoiceLine; nameParts: { y: number; text: string }[] }[] = [] const nameFrags: { y: number; text: string }[] = [] for (const l of body) { const m = l.text.match(rowRe) if (m) { const nameOnRow = l.items.filter(it => it.x < typeX - 2).map(it => it.text).join(' ').trim() rows.push({ y: l.y, nameParts: nameOnRow ? [{ y: l.y, text: nameOnRow }] : [], line: { campaignName: '', program: m[2].replace(/\s+/g, ' ').toUpperCase(), costEventType: 'CLICKS', costEventCount: Number(String(m[3]).replace(/[.,]/g, '')) || 0, costPerUnit: parseAdsAmount(m[4]), amount: parseAdsAmount(m[5]) ?? 0 } }) } else { const frag = l.items.filter(it => it.x < typeX - 2).map(it => it.text).join(' ').trim() if (frag && !/insgesamt|total/i.test(frag)) nameFrags.push({ y: l.y, text: frag }) } } for (const f of nameFrags) { let best: any = null, bestD = Infinity for (const r of rows) { const d = Math.abs(r.y - f.y); if (d < bestD) { bestD = d; best = r } } if (best && bestD < 40) best.nameParts.push(f) } for (const r of rows) { r.line.campaignName = r.nameParts.sort((a, b) => b.y - a.y).map(p => p.text).join(' ').replace(/\s+/g, ' ').trim() || 'Kampagne' lines.push(r.line) } } const lineSum = round2(lines.reduce((s, l) => s + (l.amount || 0), 0)) const netFinal = net ?? (lines.length ? lineSum : null) const grossFinal = gross ?? (netFinal != null && tax != null ? round2(netFinal + tax) : null) if (netFinal == null || grossFinal == null) return null const taxFinal = tax ?? round2(grossFinal - netFinal) return { invoiceNo, invoiceDate: invoiceDate || periodTo || new Date().toISOString().slice(0, 10), periodFrom, periodTo, currency, net: round2(netFinal), tax: round2(taxFinal), gross: round2(grossFinal), taxRate: taxRate ?? (netFinal > 0 ? Math.round((taxFinal / netFinal) * 1000) / 10 : null), paymentMethod, status: 'PAID_IN_FULL', countryCode, issuer, payer, lines, source: 'pdf', documentAvailable: true, fileName: fileName || `INVOICE-${invoiceNo}.pdf` } } export default parseAmazonAdsInvoicePdf