/** * Book ONE Amazon Ads invoice as an iDempiere AP invoice (c_invoice, * IsSOTrx=false) and attach the original Amazon PDF to it. * * Port of the Eingangsrechnungen confirm route (accounting/incoming-invoices/ * [id]/confirm.post.ts) reduced to what this vendor needs: * - vendor is the FIXED Amazon Ads partner (AMAZON_ADS_VENDOR_BPARTNER_ID, * default 1043801 "Amazon Online Germany GmbH", org * → usable from every * org). Self-heals the partner on first use: IsVendor, TaxID (only when * empty), a bill-to location from the invoice's issuer address; * - one CHARGE line per campaign line (no products) + an adjustment line when * the campaign sum differs from the invoice net (promotions/rounding), so the * booked net always equals the document; * - line tax resolved by RATE (19 % on a German Ads invoice) so GrandTotal * equals the document's gross — the bank reconciliation proposes the gross; * - DocumentNo = POReference = the Amazon invoice number; duplicate guard on * (vendor, DocumentNo, IsSOTrx=false); VO/RE predecessors get their number * freed (freeVoidedInvoiceNumbers) so a voided invoice can be re-imported; * - complete (doc-action CO) fail-soft; attach the PDF under PO_Invoice * (the table the procurements invoice page + the Lexoffice upload read). * EXACT VAT (cent differences): Amazon rounds the VAT DOWN (74,66 × 19 % = 14,1854 → 14,18), iDempiere * rounds half-up (14,19) — and with a DOCUMENT-LEVEL tax rate it recomputes the VAT at completion, so a * value we send is overwritten and GrandTotal ends up one cent off the Amazon document. iDempiere keeps a * manually entered line TaxAmt only on PURCHASE documents whose tax rate is NOT document level * (MInvoiceLine.beforeSave / MInvoiceTax.calculateTaxFromLines, "manual tax should never be amended"). * So the importer prefers a purchase tax of the right rate with IsDocumentLevel = N (a dedicated rate the * user creates, e.g. "19% Vorsteuer (Zeilensteuer)" — the shared "19% Mwst." must stay document level for * sales) and then writes the VAT from the PDF on every line; without such a rate it behaves as before and * reports the cent difference as a warning. * * Never throws for business errors — returns { status, message } so the batch * route can continue with the next invoice. * * SELLER FEE invoices (inv.kind === 'fee', parseSellerFeeInvoicePdf.ts) run through * the same importer with three differences: * - the vendor is NOT fixed: several Amazon entities issue them (Amazon EU * S.à r.l. Niederlassung Deutschland, Amazon Services Europe S.à r.l., …), so * it is resolved by the issuer's VAT id (resolveFeeVendor) and created under * org * on first use; * - the tax is resolved PER LINE (rows of one invoice can carry different rates); * - CREDIT NOTES (Amazon "Steuergutschrift", inv.isCreditNote / negative totals — * refunds of seller fees) are booked as a vendor credit memo: doctype with * DocBaseType 'APC' ("AP CreditMemo" 1000006) and POSITIVE amounts — the * document type carries the sign, same as the existing APC documents. Amazon * is the supplier correcting its own fee invoice, so it is a PURCHASE credit * note (we get money back), never an AR credit memo. OPOS and /sales/invoices * count 1000006 as customer-side only for partners flagged IsCustomer, so these * vendor documents stay on the payables side. */ import { string } from 'alga-js' import fetchHelper from '../fetchHelper' import { attachToStrapi } from '../inbox/strapiAttach' import { freeVoidedInvoiceNumbers } from '../inbox/freeInvoiceNo' import type { AdsInvoice } from './types' import { PAYMENT_METHOD_LABELS, round2 } from './types' const fkId = (v: any) => (v && typeof v === 'object') ? (v.id ?? null) : (v ?? null) const enc = (s: string) => string.urlEncode(s) const isYes = (v: any) => v === true || v === 'Y' || v === 'true' export interface ImportOptions { organizationId: number vendorId: number chargeId: number taxId?: number | null pdf?: Buffer | null pdfName?: string | null user?: string | null } export interface ImportResult { status: number message?: string cInvoiceId?: number documentNo?: string completed?: boolean attached?: boolean warnings?: string[] } /* ---- seller-fee invoices: vendor by issuer VAT id (find → create) ---- */ const feeVendorCache = new Map() export const findFeeVendorId = async (event: any, token: string, vatId?: string | null): Promise => { const vat = String(vatId || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase() if (!vat) return null const hit = feeVendorCache.get(vat) if (hit && Date.now() - hit.at < 10 * 60 * 1000) return hit.id const res: any = await fetchHelper(event, `models/c_bpartner?$filter=${enc(`TaxID eq '${vat}' AND IsActive eq true`)}&$top=10`, 'GET', token, null) const recs: any[] = res?.records || [] // prefer an existing vendor, then the shared (org *) record const pick = recs.find(r => isYes(r.IsVendor) && fkId(r.AD_Org_ID) === 0) || recs.find(r => isYes(r.IsVendor)) || recs.find(r => fkId(r.AD_Org_ID) === 0) || recs[0] if (!pick?.id) return null feeVendorCache.set(vat, { id: pick.id, at: Date.now() }) return pick.id } export const resolveFeeVendor = async (event: any, token: string, organizationId: number, inv: AdsInvoice, warnings: string[]): Promise => { const vat = String(inv.issuer?.vatId || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase() if (!vat) throw new Error(`Rechnung ${inv.invoiceNo}: keine USt-ID des Leistungserbringers erkannt — Lieferant kann nicht zugeordnet werden`) const found = await findFeeVendorId(event, token, vat) if (found) return found // shared across orgs like the Ads vendor → org * first, login org as fallback let lastErr: any = null for (const orgId of [0, organizationId]) { try { const res: any = await fetchHelper(event, 'models/c_bpartner', 'POST', token, { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, isActive: true, name: (inv.issuer?.name || 'Amazon').slice(0, 120), isVendor: true, isCustomer: false, isProspect: false, taxID: vat, tableName: 'C_Bpartner' }) if (res?.id) { feeVendorCache.set(vat, { id: res.id, at: Date.now() }) warnings.push(`Lieferant „${inv.issuer?.name}" (${vat}) neu angelegt: #${res.id}`) return res.id } } catch (e: any) { lastErr = e } } throw new Error(`Lieferant „${inv.issuer?.name}" (${vat}) konnte nicht angelegt werden: ${lastErr?.data?.detail || lastErr?.message || 'unbekannter Fehler'}`) } /* ---- vendor self-heal ---- */ const ensureVendor = async (event: any, token: string, organizationId: number, vendorId: number, inv: AdsInvoice, warnings: string[]) => { const p: any = await fetchHelper(event, `models/c_bpartner/${vendorId}`, 'GET', token, null) if (!p?.id) throw new Error(`Amazon-Lieferant C_BPartner_ID ${vendorId} nicht gefunden`) const patch: any = {} if (!isYes(p.IsVendor)) patch.isVendor = true if (!p.TaxID && inv.issuer?.vatId) patch.taxID = inv.issuer.vatId if (Object.keys(patch).length) { try { await fetchHelper(event, `models/c_bpartner/${vendorId}`, 'PUT', token, patch) } catch (e: any) { warnings.push(`Lieferant konnte nicht ergänzt werden (${Object.keys(patch).join(', ')}): ${e?.data?.detail || e?.message || e}`) } } // bill-to location let locationId: number | null = null try { const res: any = await fetchHelper(event, `models/c_bpartner_location?$filter=${enc(`C_BPartner_ID eq ${vendorId} AND (IsActive eq true OR IsActive eq false)`)}`, 'GET', token, null) const recs: any[] = res?.records || [] locationId = (recs.find(r => isYes(r.IsBillTo)) || recs[0])?.id || null } catch {} if (!locationId) { const a = inv.issuer || {} let countryId: number | null = null try { const cres: any = await fetchHelper(event, `models/c_country?$filter=${enc(`CountryCode eq '${(a.countryCode || 'DE').replace(/'/g, '')}'`)}&$top=1`, 'GET', token, null) countryId = cres?.records?.[0]?.id || null } catch {} // the partner lives at org * — try the shared org first, fall back to the login org for (const orgId of [0, organizationId]) { try { const loc: any = await fetchHelper(event, 'models/c_location', 'POST', token, { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, isActive: true, address1: a.address1 || 'Marcel-Breuer-Str. 12', city: a.city || 'München', postal: a.postal || '80807', ...(countryId ? { C_Country_ID: { id: countryId, tableName: 'C_Country' } } : {}), tableName: 'C_Location' }) if (!loc?.id) continue const bpLoc: any = await fetchHelper(event, 'models/c_bpartner_location', 'POST', token, { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, isActive: true, name: a.city || 'Rechnungsanschrift', isBillTo: true, isShipTo: true, isPayFrom: true, isRemitTo: true, C_BPartner_ID: { id: vendorId, tableName: 'C_BPartner' }, C_Location_ID: { id: loc.id, tableName: 'C_Location' }, tableName: 'C_BPartner_Location' }) if (bpLoc?.id) { locationId = bpLoc.id; break } } catch (e: any) { warnings.push(`Adresse unter Org ${orgId} nicht anlegbar: ${e?.data?.detail || e?.message || e}`) } } } return { partner: p, locationId } } /* ---- tax by rate ---- */ const isLineLevelTax = (t: any) => !(t?.IsDocumentLevel === true || t?.IsDocumentLevel === 'Y') const sopoOf = (t: any) => t?.SOPOType?.id || t?.SOPOType || 'B' /** Right rate, usable for purchases — a LINE-LEVEL rate first (keeps the VAT from the PDF, see header). */ const pickPurchaseTax = (taxes: any[], rate: number) => { const cands = taxes.filter(t => Math.abs(Number(t.Rate ?? 0) - rate) <= 0.6 && !(t.IsSummary === true || t.IsSummary === 'Y')) const purchase = cands.filter(t => ['B', 'P'].includes(sopoOf(t))) return purchase.find(t => isLineLevelTax(t) && sopoOf(t) === 'P') || purchase.find(isLineLevelTax) || purchase[0] || cands[0] || null } const resolveTax = async (event: any, token: string, inv: AdsInvoice, taxId?: number | null) => { const res: any = await fetchHelper(event, `models/c_tax?$filter=${enc('IsActive eq true')}&$top=100`, 'GET', token, null) const taxes: any[] = res?.records || [] const rate = inv.taxRate != null ? Number(inv.taxRate) : (inv.net > 0 ? (inv.tax / inv.net) * 100 : 0) if (taxId) { const chosen = taxes.find(t => t.id === Number(taxId)) if (chosen && Math.abs(Number(chosen.Rate ?? 0) - rate) <= 0.6) return { id: chosen.id, name: chosen.Name, lineLevel: isLineLevelTax(chosen) } } // prefer purchase-side / both taxes, then any with the right rate const pick = pickPurchaseTax(taxes, rate) if (!pick) throw new Error(`Kein aktiver Steuersatz mit ${rate.toFixed(1)} % gefunden`) return { id: pick.id, name: pick.Name, lineLevel: isLineLevelTax(pick) } } /** Seller-fee invoices: one tax per distinct line rate (rows can differ). */ const resolveTaxesByRate = async (event: any, token: string, rates: number[]) => { const res: any = await fetchHelper(event, `models/c_tax?$filter=${enc('IsActive eq true')}&$top=100`, 'GET', token, null) const taxes: any[] = res?.records || [] const out = new Map() let allLineLevel = true for (const rate of rates) { const pick = pickPurchaseTax(taxes, rate) if (!pick) throw new Error(`Kein aktiver Steuersatz mit ${rate.toFixed(1)} % gefunden`) out.set(rate, pick.id) if (!isLineLevelTax(pick)) allLineLevel = false } return Object.assign(out, { allLineLevel }) } const resolveFks = async (event: any, token: string, organizationId: number, partner: any, currency: string, docBaseType: 'API' | 'APC' = 'API') => { const out: any = { priceListId: fkId(partner?.PO_PriceList_ID) || null, paymentTermId: fkId(partner?.PO_PaymentTerm_ID) || fkId(partner?.C_PaymentTerm_ID) || null, doctypeId: null, currencyId: null } if (!out.priceListId) { try { const res: any = await fetchHelper(event, `models/m_pricelist?$filter=${enc(`IsSOPriceList eq false AND IsActive eq true`)}&$top=20`, 'GET', token, null) const recs: any[] = res?.records || [] out.priceListId = (recs.find(r => fkId(r.AD_Org_ID) === Number(organizationId)) || recs.find(r => fkId(r.AD_Org_ID) === 0) || recs[0])?.id || null } catch {} } try { const res: any = await fetchHelper(event, `models/c_doctype?$filter=${enc(`IsSOTrx eq false AND DocBaseType eq '${docBaseType}' AND IsActive eq true`)}`, 'GET', token, null) out.doctypeId = res?.records?.[0]?.id || null } catch {} try { const res: any = await fetchHelper(event, `models/c_currency?$filter=${enc(`ISO_Code eq '${(currency || 'EUR').replace(/'/g, '')}'`)}&$top=1`, 'GET', token, null) out.currencyId = res?.records?.[0]?.id || (currency === 'EUR' ? 102 : null) } catch { out.currencyId = currency === 'EUR' ? 102 : null } return out } const fmtDe = (d?: string | null) => d ? d.split('-').reverse().join('.') : '' const money = (n: number, cur: string) => `${round2(n).toFixed(2).replace('.', ',')} ${cur}` export const buildAdsInvoiceLines = (inv: AdsInvoice, organizationId: number, chargeId: number, taxId: number, taxByRate?: Map, exactTax = false) => { const cur = inv.currency || 'EUR' const taxOf = (l: any) => (l?.taxRate != null && taxByRate?.get(Number(l.taxRate))) || taxId const src = inv.lines?.length ? inv.lines : [{ campaignName: `${inv.kind === 'fee' ? (inv.isCreditNote ? 'Erstattung Amazon Gebühren' : 'Amazon Gebühren') : 'Amazon Ads Werbekosten'} ${fmtDe(inv.periodFrom)}${inv.periodTo && inv.periodTo !== inv.periodFrom ? ' – ' + fmtDe(inv.periodTo) : ''}`, amount: inv.net } as any] const lines: any[] = src.map((l: any, idx: number) => { const parts = [l.campaignName] if (l.program) parts.push(String(l.program).replace(/_/g, ' ')) if (l.costEventCount != null) parts.push(`${l.costEventCount} ${(l.costEventType || 'CLICKS') === 'IMPRESSIONS' ? 'Impressionen' : 'Klicks'}${l.costPerUnit != null ? ' × ' + money(l.costPerUnit, cur) : ''}`) return { AD_Org_ID: { id: organizationId, tableName: 'AD_Org' }, line: (idx + 1) * 10, isActive: true, description: parts.filter(Boolean).join(' · ').slice(0, 255), qtyEntered: 1, qtyInvoiced: 1, priceEntered: round2(l.amount), priceActual: round2(l.amount), lineNetAmt: round2(l.amount), C_Charge_ID: { id: chargeId, tableName: 'C_Charge' }, C_Tax_ID: { id: taxOf(l), tableName: 'C_Tax' } } }) const sum = round2(lines.reduce((s, l) => s + l.lineNetAmt, 0)) const diff = round2(inv.net - sum) if (Math.abs(diff) >= 0.005) { lines.push({ AD_Org_ID: { id: organizationId, tableName: 'AD_Org' }, line: (lines.length + 1) * 10, isActive: true, description: inv.kind === 'fee' ? 'Rundungsdifferenz lt. Rechnung' : (diff < 0 ? 'Werbeaktion / Gutschrift lt. Rechnung' : 'Anpassung / Gebühren lt. Rechnung'), qtyEntered: 1, qtyInvoiced: 1, priceEntered: diff, priceActual: diff, lineNetAmt: diff, C_Charge_ID: { id: chargeId, tableName: 'C_Charge' }, C_Tax_ID: { id: taxId, tableName: 'C_Tax' } }) } // exact VAT from the PDF (only with a line-level purchase tax — otherwise iDempiere overwrites it): // per-line VAT where the PDF has it, else proportional; the remainder goes on the largest line so the // sum equals the document's VAT to the cent. A zero line VAT is left to iDempiere (0 = "calculate"). if (exactTax && lines.length) { const total = round2(inv.tax) const pdfTax = src.map((l: any) => (l.tax != null ? round2(l.tax) : null)) const usePdf = pdfTax.length === src.length && pdfTax.every((v: any) => v != null) const taxes: number[] = lines.map((l, i) => (usePdf && i < src.length) ? (pdfTax[i] as number) : round2(inv.net ? total * (l.lineNetAmt / inv.net) : 0)) const rest = round2(total - taxes.reduce((a, b) => a + b, 0)) if (Math.abs(rest) >= 0.005) { let big = 0 lines.forEach((l, i) => { if (Math.abs(l.lineNetAmt) > Math.abs(lines[big].lineNetAmt)) big = i }) taxes[big] = round2(taxes[big] + rest) } lines.forEach((l, i) => { if (Math.abs(taxes[i]) >= 0.005) l.taxAmt = taxes[i] }) } return lines } /** A credit note arrives with negative totals/lines; an APC document is entered with POSITIVE amounts. */ const absInvoice = (inv: AdsInvoice): AdsInvoice => ({ ...inv, net: Math.abs(inv.net), tax: Math.abs(inv.tax), gross: Math.abs(inv.gross), lines: (inv.lines || []).map(l => ({ ...l, amount: Math.abs(l.amount), tax: l.tax != null ? Math.abs(l.tax) : l.tax })) }) export const importAdsInvoice = async (event: any, token: string, source: AdsInvoice, opts: ImportOptions): Promise => { const warnings: string[] = [] const isCredit = source.kind === 'fee' && (!!source.isCreditNote || source.gross < 0 || source.net < 0) const inv = isCredit ? absInvoice(source) : source const docNo = String(inv.invoiceNo || '').replace(/'/g, '').trim() if (!docNo) return { status: 422, message: 'Rechnungsnummer fehlt' } if (!(inv.gross > 0) && !(inv.net > 0)) return { status: 422, message: `Rechnung ${docNo}: kein Betrag` } if (!opts.chargeId) return { status: 422, message: 'Bitte eine Charge (Kostenart) für die Buchung wählen' } const isFee = inv.kind === 'fee' if (!isCredit && (inv.gross < 0 || inv.net < 0)) return { status: 422, message: `${docNo}: negativer Betrag auf einer Rechnung, die keine Gutschrift ist — bitte manuell prüfen.` } // 1. vendor const { partner, locationId } = await ensureVendor(event, token, opts.organizationId, opts.vendorId, inv, warnings) if (!locationId) return { status: 422, message: `Rechnung ${docNo}: der Amazon-Lieferant hat keine Rechnungsanschrift und sie konnte nicht angelegt werden`, warnings } // 2. duplicate guard (same rule as the inbox confirm) try { const dup: any = await fetchHelper(event, `models/c_invoice?$filter=${enc(`C_BPartner_ID eq ${opts.vendorId} AND DocumentNo eq '${docNo}' AND IsSOTrx eq false`)}`, 'GET', token, null) const recs: any[] = dup?.records || [] const blocking = recs.find(r => !['VO', 'RE'].includes(r?.DocStatus?.id || r?.DocStatus)) if (blocking) return { status: 409, message: `Rechnung ${docNo} ist bereits als Eingangsrechnung erfasst (Beleg #${blocking.id}, ${blocking?.DocStatus?.identifier || blocking?.DocStatus?.id || ''})`, cInvoiceId: blocking.id, documentNo: docNo } const voided = recs.filter(r => ['VO', 'RE'].includes(r?.DocStatus?.id || r?.DocStatus)) if (voided.length) { const freed = await freeVoidedInvoiceNumbers({ bpartnerId: opts.vendorId, documentNo: docNo }) if (!freed.available) return { status: 409, message: `Rechnung ${docNo}: die stornierte Rechnung #${voided[0].id} belegt die Nummer noch und die Freigabe ist auf diesem Server nicht möglich (keine Datenbankverbindung)`, cInvoiceId: voided[0].id, documentNo: docNo } if (freed.freed < voided.length) return { status: 409, message: `Rechnung ${docNo}: Nummer noch durch stornierte Rechnung #${voided[0].id} belegt`, cInvoiceId: voided[0].id, documentNo: docNo } } } catch (e: any) { warnings.push(`Duplikatprüfung fehlgeschlagen: ${e?.message || e}`) } // 3. tax / FKs / lines // fee invoices: tax per line rate (a mixed-rate document has no single matching rate → resolveTax would throw) const lineRates = isFee ? [...new Set((inv.lines || []).map(l => l.taxRate).filter((r): r is number => r != null).map(Number))] : [] const taxByRate = lineRates.length ? await resolveTaxesByRate(event, token, lineRates) : undefined const tax: any = taxByRate ? { id: taxByRate.get(lineRates[0])!, name: '', lineLevel: (taxByRate as any).allLineLevel === true } : await resolveTax(event, token, inv, opts.taxId) const exactTax = tax.lineLevel === true const fks = await resolveFks(event, token, opts.organizationId, partner, inv.currency, isCredit ? 'APC' : 'API') if (!fks.doctypeId) return { status: 422, message: isCredit ? 'Kein Belegtyp "AP CreditMemo" (DocBaseType APC) gefunden' : 'Kein Belegtyp "AP Invoice" gefunden', warnings } const lines = buildAdsInvoiceLines(inv, opts.organizationId, opts.chargeId, tax.id, taxByRate, exactTax) const pmLabel = inv.paymentMethodLabel || (inv.paymentMethod ? (PAYMENT_METHOD_LABELS[inv.paymentMethod] || inv.paymentMethod) : '') const period = inv.periodFrom ? `${fmtDe(inv.periodFrom)}${inv.periodTo && inv.periodTo !== inv.periodFrom ? ' – ' + fmtDe(inv.periodTo) : ''}` : '' const header: any = { AD_Org_ID: { id: opts.organizationId, tableName: 'AD_Org' }, isActive: true, isSOTrx: false, documentNo: docNo, dateInvoiced: inv.invoiceDate, dateOrdered: inv.invoiceDate, dateAcct: inv.invoiceDate, description: [`${isCredit ? 'Amazon Steuergutschrift' : (isFee ? 'Amazon Gebührenrechnung' : 'Amazon Ads Rechnung')} ${docNo}`, isCredit && inv.originalInvoiceNo ? `zu Rechnung ${inv.originalInvoiceNo}` : '', period ? `Zeitraum ${period}` : '', pmLabel ? `Zahlung: ${pmLabel}` : ''].filter(Boolean).join(' · ').slice(0, 255), isTaxIncluded: false, grandTotal: round2(inv.gross), totalLines: round2(inv.net), POReference: docNo, DocStatus: { id: 'DR' }, C_DocType_ID: { id: fks.doctypeId, tableName: 'C_DocType' }, C_DocTypeTarget_ID: { id: fks.doctypeId, tableName: 'C_DocTypeTarget' }, C_BPartner_ID: { id: opts.vendorId, tableName: 'C_BPartner' }, C_BPartner_Location_ID: { id: locationId, tableName: 'C_BPartner_Location' }, ...(fks.priceListId ? { M_PriceList_ID: { id: fks.priceListId, tableName: 'M_PriceList' } } : {}), ...(fks.currencyId ? { C_Currency_ID: { id: fks.currencyId, tableName: 'C_Currency' } } : {}), ...(fks.paymentTermId ? { C_PaymentTerm_ID: { id: fks.paymentTermId, tableName: 'C_PaymentTerm' } } : {}), C_InvoiceLine: lines, tableName: 'C_Invoice' } let created: any try { created = await fetchHelper(event, 'models/c_invoice', 'POST', token, header) } catch (e: any) { return { status: Number(e?.status || e?.statusCode) || 500, message: `Rechnung ${docNo}: ${e?.data?.detail || e?.data?.title || e?.message || 'Anlage fehlgeschlagen'}`, warnings } } if (!created?.id) return { status: 500, message: `Rechnung ${docNo}: iDempiere hat keine ID zurückgegeben`, warnings } // 4. complete — fail-soft (draft kept); must not throw into the token-refresh retry let completed = false try { await fetchHelper(event, `models/c_invoice/${created.id}`, 'PUT', token, { 'doc-action': 'CO' }) completed = true } catch (e: any) { warnings.push(`Fertigstellen (CO) fehlgeschlagen — Entwurf bleibt: ${e?.data?.detail || e?.data?.title || e?.message || e}`) } // 4b. the booked total must equal the Amazon document — report a difference instead of hiding it try { const chk: any = await fetchHelper(event, `models/c_invoice?$filter=${enc(`C_Invoice_ID eq ${created.id}`)}&$select=GrandTotal,TotalLines&$top=1`, 'GET', token, null) const booked = Number(chk?.records?.[0]?.GrandTotal) if (Number.isFinite(booked) && Math.abs(booked - round2(inv.gross)) >= 0.005) { warnings.push(`Bruttobetrag weicht ab: iDempiere ${money(booked, inv.currency)} / Amazon ${money(inv.gross, inv.currency)}` + (exactTax ? '' : ' — der verwendete Steuersatz ist ein Belegsteuersatz, iDempiere berechnet die USt. neu (Amazon rundet ab). Für centgenaue Beträge einen Einkaufs-Steuersatz mit „Belegebene = Nein“ anlegen.')) } } catch {} // 5. original PDF → attachment (PO_Invoice = purchase-invoice attachment table) let attached = false if (opts.pdf?.length) { try { await attachToStrapi(event, token, { tableName: 'PO_Invoice', recordId: created.id, recordUu: created.uid, buffer: opts.pdf, filename: opts.pdfName || (isCredit ? `Amazon-Gutschrift-${docNo}.pdf` : (isFee ? `Amazon-Gebuehren-${docNo}.pdf` : `Amazon-Ads-INVOICE-${docNo}.pdf`)), mimeType: 'application/pdf' }) attached = true } catch (e: any) { warnings.push(`PDF konnte nicht angehängt werden: ${e?.message || e}`) } } else { warnings.push('Kein Amazon-PDF vorhanden — Rechnung ohne Anhang angelegt (Lexoffice-Upload wird sie überspringen).') } return { status: 200, cInvoiceId: created.id, documentNo: created.DocumentNo || docNo, completed, attached, warnings } } /** Void a booked Ads invoice and free its number for a later re-import. */ export const voidAdsInvoice = async (event: any, token: string, cInvoiceId: number): Promise => { const warnings: string[] = [] const inv: any = await fetchHelper(event, `models/c_invoice/${cInvoiceId}`, 'GET', token, null) if (!inv?.id) return { status: 404, message: `Rechnung #${cInvoiceId} nicht gefunden` } const st = inv.DocStatus?.id || inv.DocStatus if (!['VO', 'RE'].includes(st)) { try { await fetchHelper(event, `models/c_invoice/${cInvoiceId}`, 'PUT', token, { 'doc-action': 'VO' }) } catch (e: any) { return { status: 500, message: `Storno fehlgeschlagen: ${e?.data?.detail || e?.data?.title || e?.message || e}` } } } const freed = await freeVoidedInvoiceNumbers({ invoiceId: cInvoiceId }) if (!freed.available) warnings.push('Rechnungsnummer konnte nicht freigegeben werden (keine direkte Datenbankverbindung auf diesem Server) — ein erneuter Import scheitert, bis sie umbenannt ist.') else if (!freed.freed) warnings.push('Rechnungsnummer war bereits freigegeben oder der Beleg ist noch nicht storniert.') return { status: 200, cInvoiceId, documentNo: inv.DocumentNo, warnings } }