/** * Parse an Amazon SELLER FEE invoice PDF (Seller Central → Reports → Tax Document * Library → "Seller Invoices" tab, e.g. "DE-AEU-2026-1205679.pdf") into the same * AdsInvoice shape the Ads importer works with (kind: 'fee'). * * Amazon offers NO API for these documents in Europe (the SP-API Invoices API is * Brazil-only, no report type covers them), so the downloaded PDF is the only * source. The PDFs carry a clean text layer — no OCR. * * Layout (measured on the German template, 612 pt page): * - title RECHNUNG / GUTSCHRIFT top right; * - "Rechnungsdatum: 31/05/2026", "Rechnungszeitraum: 01/05/2026 to 31/05/2026", * "Rechnungsnummer: DE-AEU-2026-1205679" — label and value on ONE line; * - two party columns: recipient left (x≈44), supplier right (x≈308). Each * "label:" line is followed by its value on the NEXT line of the same column; * only the supplier VAT id sits on the label line itself; * - service table: EUR net rate% EUR vat EUR gross, one row per fee * type (rows may carry different rates), closed by a "Gesamtsumme" row with * three amounts and no rate. Amounts use the English format (1,234.56). * Amazon renamed the party labels over time — the 2025 template says "Rechnungsnr", * "Name des Anbieters" / "Name des Verkaeufers", "Adresse des Anbieters", "UStID des * Anbieters" (no recipient VAT line); the 2026 one "Rechnungsnummer", "Leistungserbringer" * / "Leistungsempfänger". Both are listed, and as a safety net for the next rename the * supplier falls back to the first right-column line naming an Amazon entity and the VAT * id to the first VAT-id-shaped token in that column — so recognition does not hinge on * the exact label wording. * * CREDIT NOTES ("STEUERGUTSCHRIFT", e.g. DE-CN-AEU-2026-134720 — refunds of seller * fees) use the same layout with their own labels ("Gutschriftennummer", * "Ausstellungsdatum der Gutschrift", "Zeitraum der Gutschrift"), NEGATIVE amounts * written with the sign BEFORE the currency ("-EUR 2.61") and an extra block * "Ursprüngliche Rechnungsnummer" + the number of the invoice they correct * (→ originalInvoiceNo). They come back with isCreditNote: true and negative totals. * English / other-language labels are included as best guesses — only the German * templates (invoice + credit note) have been verified against real documents. */ import { extractTextLayer } from '../inbox/pdfTextLayer' import { layoutLines, parseAdsDate, parseAdsAmount, type Line } from './parseInvoicePdf' import type { AdsInvoice, AdsInvoiceLine } from './types' import { round2 } from './types' // one amount TOKEN, captured whole: "EUR 4.42" · "-EUR 2.61" (credit notes: sign before the currency) · "EUR -2.61" · "4,42" const AMT = '(-?\\s*(?:[A-Z]{3}\\s*)?-?(?:\\d{1,3}(?:[.,]\\d{3})*[.,]\\d{2}|\\d+[.,]\\d{2}))' const ROW_RE = new RegExp(`^(.*?)\\s*${AMT}\\s+(-?\\d{1,2}(?:[.,]\\d+)?)\\s*%\\s+${AMT}\\s+${AMT}\\s*$`) const TOTAL_RE = new RegExp(`^(?:Gesamtsumme|Gesamt|Summe|Total(?:\\s+amount)?|Totale|Totaal|Suma)\\b[^\\d-]*?${AMT}\\s+${AMT}\\s+${AMT}\\s*$`, 'i') /** "-EUR 2.61" → -2.61 (parseAdsAmount drops letters/spaces and keeps the sign) */ const amtOf = (token?: string | null) => parseAdsAmount(token) ?? 0 const curOf = (...tokens: (string | undefined)[]) => tokens.map(t => t?.match(/[A-Z]{3}/)?.[0]).find(Boolean) || null const LABEL = { date: /(?:Rechnungsdatum|Ausstellungsdatum der Gutschrift|Gutschrift(?:s|en)?datum|Datum der Gutschrift|Invoice date|Credit note issue date|Credit note date|Date de (?:la )?facture|Fecha de (?:la )?factura|Data (?:della )?fattura|Factuurdatum)\s*:?/i, period: /(?:Rechnungszeitraum|Zeitraum der Gutschrift|Gutschrift(?:s|en)?zeitraum|Abrechnungszeitraum|Leistungszeitraum|Invoice period|Credit note period|Billing period|Période de facturation|Periodo de facturación|Periodo di fatturazione)\s*:?/i, number: /(? = { deutschland: 'DE', germany: 'DE', luxemburg: 'LU', luxembourg: 'LU', 'vereinigtes königreich': 'GB', 'united kingdom': 'GB', frankreich: 'FR', france: 'FR', italien: 'IT', italy: 'IT', italia: 'IT', spanien: 'ES', spain: 'ES', 'españa': 'ES', niederlande: 'NL', netherlands: 'NL', polen: 'PL', poland: 'PL', schweden: 'SE', sweden: 'SE', belgien: 'BE', belgium: 'BE', irland: 'IE', ireland: 'IE' } const parseAddress = (raw?: string | null) => { const out: { address1?: string; postal?: string; city?: string; countryCode?: string } = {} if (!raw) return out const parts = raw.split(',').map(s => s.trim()).filter(Boolean) const rest: string[] = [] for (const p of parts) { const cc = COUNTRY_CODES[p.toLowerCase()] || (/^[A-Z]{2}$/.test(p) ? p : null) if (cc) { out.countryCode = cc; continue } const m = p.match(/^(?:[A-Z]{1,2}-)?(\d{4,5})(?:\s+(.+))?$/) if (m && !out.postal) { out.postal = m[1]; if (m[2]) out.city = m[2]; continue } rest.push(p) } // "Mühlenweg, 4, Butzbach, 35510, DE" → the part right before a bare postal code is the city if (!out.city && out.postal && rest.length > 1) out.city = rest.pop() out.address1 = rest.join(' ').trim() || undefined return out } export const parseAmazonSellerFeeInvoicePdf = 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 fullText = all.map(l => l.text).join('\n') // cheap gate: an Amazon document with the supplier/recipient block (an Ads invoice has neither) if (!/Amazon/i.test(fullText)) return null const hasPartyBlock = all.some(l => LABEL.supplier.test(l.text.trim()) || LABEL.supplierVat.test(l.text)) const hasFeeTable = all.some(l => LABEL.tableHead.test(l.text) && /%/.test(l.text) && /USt|VAT|TVA|IVA|MwSt/i.test(l.text)) if (!hasPartyBlock && !hasFeeTable) return null /* ---- number / dates ---- */ let invoiceNo: string | null = null for (const l of all) { const m = l.text.match(new RegExp(LABEL.number.source + '([A-Z0-9][A-Z0-9/_-]{5,})', 'i')) if (m) { invoiceNo = m[1]; break } } if (!invoiceNo) { const m = (fileName || '').match(/([A-Z]{2}-[A-Z]{2,5}-\d{4}-\d{3,})/i) if (m) invoiceNo = m[1].toUpperCase() } if (!invoiceNo) return null const after = (re: RegExp) => { for (const l of all) { const m = l.text.match(re); if (m) { const rest = l.text.slice((m.index || 0) + m[0].length).trim(); if (rest) return rest } } return null } const invoiceDate = parseAdsDate(after(LABEL.date)) const periodDates = (after(LABEL.period) || '').match(/\d{1,2}[-.\/]\d{1,2}[-.\/]\d{4}|\d{4}-\d{2}-\d{2}/g) || [] const periodFrom = parseAdsDate(periodDates[0]) || null const periodTo = parseAdsDate(periodDates[1]) || periodFrom const isCreditNote = all.slice(0, 6).some(l => /^(?:(?:STEUER)?GUTSCHRIFT|(?:TAX\s+)?CREDIT\s*(?:NOTE|MEMO)|RECHNUNGSKORREKTUR|STORNORECHNUNG|AVOIR|NOTA DE CR[EÉ]DITO|NOTA DI CREDITO)\b/i.test(l.text.trim())) let originalInvoiceNo: string | null = null const oIdx = all.findIndex(l => LABEL.originalInvoice.test(l.text.trim())) if (oIdx > -1) { const same = all[oIdx].text.trim().replace(LABEL.originalInvoice, '').trim() originalInvoiceNo = [same, all[oIdx + 1]?.text.trim() || ''].map(v => v.match(/^[A-Z0-9][A-Z0-9/_-]{5,}$/)?.[0]).find(Boolean) || null } /* ---- parties: two columns on page 1, value = next line of the same column ---- */ const first = pageLines[0] const pageW = layer.pages[0].width || 612 const tableIdx = first.findIndex(l => LABEL.tableHead.test(l.text) && /%|USt|VAT|TVA|IVA/i.test(l.text)) const head = first.slice(0, tableIdx > 0 ? tableIdx : first.length) const leftCol = head.filter(l => l.x < pageW * 0.45) const rightCol = head.filter(l => l.x >= pageW * 0.45) const valueAfter = (col: Line[], re: RegExp) => { const i = col.findIndex(l => re.test(l.text.trim())); return i > -1 ? (col[i + 1]?.text.trim() || null) : null } const vatOf = (col: Line[], re: RegExp) => { const i = col.findIndex(l => re.test(l.text)) if (i < 0) return undefined const m = col[i].text.match(re)! const same = col[i].text.slice((m.index || 0) + m[0].length).match(VAT_ID_RE)?.[1] return (same || col[i + 1]?.text.match(VAT_ID_RE)?.[1] || '').replace(/\s/g, '') || undefined } const supplierName = valueAfter(rightCol, LABEL.supplier) || valueAfter(head, LABEL.supplier) || rightCol.map(l => l.text.trim()).find(tx => /Amazon/i.test(tx) && !/:$/.test(tx)) || null const supplierAddr = parseAddress(valueAfter(rightCol, LABEL.supplierAddr) || valueAfter(head, LABEL.supplierAddr)) const supplierVat = vatOf(rightCol, LABEL.supplierVat) || vatOf(head, LABEL.supplierVat) || rightCol.map(l => l.text.match(VAT_ID_RE)?.[1]?.replace(/\s/g, '')).find(v => !!v && /\d{5,}/.test(v)) const issuer = { name: supplierName || undefined, vatId: supplierVat, ...supplierAddr } const payer = { name: valueAfter(leftCol, LABEL.recipient) || undefined, vatId: vatOf(leftCol, LABEL.recipientVat) } if (!issuer.name || !/Amazon/i.test(issuer.name)) return null /* ---- service rows + total ---- */ const lines: AdsInvoiceLine[] = [] let currency: string | null = null let total: { net: number; tax: number; gross: number } | null = null for (const page of pageLines) { const hIdx = page.findIndex(l => LABEL.tableHead.test(l.text) && /%|USt|VAT|TVA|IVA/i.test(l.text)) if (hIdx < 0) continue let pendingName = '' for (const l of page.slice(hIdx + 1)) { const t = l.text.match(TOTAL_RE) if (t) { currency = currency || curOf(t[1], t[2], t[3]) total = { net: amtOf(t[1]), tax: amtOf(t[2]), gross: amtOf(t[3]) } break } const m = l.text.match(ROW_RE) if (m) { currency = currency || curOf(m[2], m[4], m[5]) lines.push({ campaignName: `${pendingName} ${m[1]}`.replace(/\s+/g, ' ').trim() || 'Amazon Gebühren', amount: amtOf(m[2]), taxRate: Math.abs(Number(String(m[3]).replace(',', '.'))), tax: amtOf(m[4]) }) pendingName = '' } else if (LABEL.originalInvoice.test(l.text.trim())) { break } else if (!/^\(|^Preis$|^Price$/i.test(l.text) && !new RegExp(AMT).test(l.text)) { // a wrapped service name: text-only line between the rows if (l.x < pageW * 0.4) pendingName = `${pendingName} ${l.text}`.trim() } } if (total) break } if (!lines.length && !total) return null const lineNet = round2(lines.reduce((s, l) => s + (l.amount || 0), 0)) const lineTax = round2(lines.reduce((s, l) => s + (l.tax || 0), 0)) const net = round2(total?.net ?? lineNet) const tax = round2(total?.tax ?? lineTax) const gross = round2(total?.gross ?? (net + tax)) const rates = [...new Set(lines.map(l => l.taxRate).filter(r => r != null))] as number[] return { kind: 'fee', isCreditNote: isCreditNote || gross < 0 || net < 0, originalInvoiceNo, invoiceNo, invoiceDate: invoiceDate || periodTo || new Date().toISOString().slice(0, 10), periodFrom, periodTo, currency: currency || 'EUR', net, tax, gross, taxRate: rates.length === 1 ? rates[0] : (net ? Math.abs(Math.round((tax / net) * 1000) / 10) : null), paymentMethod: 'DEDUCT_FROM_PAYMENT', // fees are withheld from the settlement — "keine Zahlungsaufforderung" status: 'PAID_IN_FULL', countryCode: issuer.countryCode || (fullText.match(/Ort der Betriebsstätte[^\n]*-\s*([A-Z]{2})\b/)?.[1] ?? null), issuer, payer, lines, source: 'pdf', documentAvailable: true, fileName: fileName || `${invoiceNo}.pdf` } } export default parseAmazonSellerFeeInvoicePdf