import { amazonFeeCode, amazonMarketplaceCode } from '~/utils/amazonFeeCodes' import { formatLexofficeDate } from '~/utils/lexofficeExport' // ONE classifier for Amazon settlement rows, used by BOTH the settlement grid // (Kürzel / Klassifikation columns) and the lexoffice CSV builder, so what the // bookkeeper sees in the table is exactly what ends up in the Verwendungszweck. // Buckets and codes follow PayJoe's Amazon layout: // Bestellung (gross sale, matches the invoice) // Erstattung (refund, matches the credit memo) // Bestellgebühr AMA-BG-- (commission / FBA fee of an order) // Servicegebühr AMA-SG-- (subscription, return postage, RefundCommission, …) // Werbekosten AMA-SG--CoAdv (one row per Ads invoice) // Auszahlung AUSZ-- (payout, negative) // Sonstiges AMA-SG-- (reserve movements, unknown events) export type AmazonRowBucket = 'sale' | 'refund' | 'orderFee' | 'serviceFee' | 'advertising' | 'other' | 'payout' export const AMAZON_BUCKET_LABELS: Record = { sale: 'Bestellung', refund: 'Erstattung', orderFee: 'Bestellgebühr', serviceFee: 'Servicegebühr', advertising: 'Werbekosten', other: 'Sonstiges', payout: 'Auszahlung' } // Zusatzinfo column: the action / tax treatment a row needs in lexoffice. // Fees are invoiced by Amazon EU S.à r.l. (LU) → Reverse Charge §13b UStG; // Amazon Ads invoices for DE sellers carry German VAT (PayJoe: 4600 with // tax key 9 = 19 % Vorsteuer) → attach the Ads invoice instead. export const AMAZON_BUCKET_HINTS: Record = { sale: 'Rechnung zuordnen', refund: 'Gutschrift zuordnen', orderFee: 'Provision · RC §13b', serviceFee: 'Gebühr · RC §13b', advertising: 'Werbung · Ads-Rechnung', other: 'Prüfen', payout: 'Geldtransit' } export interface AmazonSettlementRowLike { category: string transactionType?: string amountType?: string amountDescription?: string amount: number orderId?: string merchantOrderId?: string postedDate?: string marketplace?: string internalDocumentNo?: string invoiceDocumentNo?: string bucket?: AmazonRowBucket } const SERVICE_FEE_TYPES_WITH_ORDER = new Set(['RefundCommission']) export const classifyAmazonRow = (row: AmazonSettlementRowLike): AmazonRowBucket => { if (row.bucket === 'payout') return 'payout' const hasOrder = !!row.orderId switch (row.category) { case 'umsaetze': return 'sale' case 'retouren': // ReturnPostageBilling adjustments carry no order id → PayJoe's AMA-SG-…-RetPosBi return hasOrder ? 'refund' : 'serviceFee' case 'amazonFees': case 'fbaFees': if (!hasOrder) return 'serviceFee' if (row.transactionType === 'ServiceFee') return 'serviceFee' if (SERVICE_FEE_TYPES_WITH_ORDER.has(String(row.amountDescription || ''))) return 'serviceFee' return 'orderFee' case 'werbekosten': return 'advertising' default: return 'other' } } // The fee-type name a Servicegebühr / Sonstiges row is coded by. const feeNameOf = (row: AmazonSettlementRowLike, bucket: AmazonRowBucket) => { if (bucket === 'advertising') return 'Cost of Advertising' return row.amountDescription || row.amountType || row.transactionType || 'Other' } // Most frequent marketplace among rows that carry one ("Amazon.de" → "DE"); // used as the fallback for rows without a marketplace (fees, ads, adjustments). export const dominantAmazonMarketplaceCode = (rows: AmazonSettlementRowLike[], fallback = 'DE'): string => { const counts = new Map() for (const r of rows || []) { if (!r.marketplace) continue const code = amazonMarketplaceCode(r.marketplace, '') if (!code) continue counts.set(code, (counts.get(code) || 0) + 1) } let best = '' let bestN = 0 for (const [code, n] of counts) { if (n > bestN) { best = code; bestN = n } } return best || fallback } // PayJoe "Abkürzung" — the leading token of the Verwendungszweck. export const amazonRowCode = (row: AmazonSettlementRowLike, marketplaceFallback = 'DE'): string => { const bucket = classifyAmazonRow(row) const mp = amazonMarketplaceCode(row.marketplace || '', marketplaceFallback) switch (bucket) { case 'sale': case 'refund': return row.orderId || '' case 'orderFee': return `AMA-BG-${mp}-${row.orderId}` case 'payout': return `AUSZ-${mp}-${formatLexofficeDate(row.postedDate)}` default: return `AMA-SG-${mp}-${amazonFeeCode(feeNameOf(row, bucket))}` } } export const amazonBucketLabel = (bucket: AmazonRowBucket) => AMAZON_BUCKET_LABELS[bucket] || bucket export const amazonBucketHint = (bucket: AmazonRowBucket) => AMAZON_BUCKET_HINTS[bucket] || 'Prüfen' const isoDay = (value?: string) => { if (!value) return '' const d = new Date(value) return isNaN(d.getTime()) ? '' : d.toISOString().split('T')[0] } const earlier = (a?: string, b?: string) => { if (!a) return b || '' if (!b) return a return new Date(a).getTime() <= new Date(b).getTime() ? a : b } export interface AmazonLexofficeGroup { bucket: AmazonRowBucket code: string orderId: string reference: string // ads invoice id / adjustment reference feeTypes: Set description: string amount: number postedDate: string internalDocumentNo: string invoiceDocumentNo: string } // Collapses detail rows into export lines the way PayJoe does: one gross line // per order, one fee line per order, one line per service fee / Ads invoice / // adjustment (keyed by code + reference + day). export const groupAmazonRowsForLexoffice = (rows: AmazonSettlementRowLike[], marketplaceFallback = 'DE'): AmazonLexofficeGroup[] => { const groups = new Map() for (const r of rows || []) { const bucket = classifyAmazonRow(r) if (bucket === 'payout') continue const code = amazonRowCode(r, marketplaceFallback) const day = isoDay(r.postedDate) let key: string switch (bucket) { case 'sale': case 'refund': case 'orderFee': key = `${bucket}|${r.orderId}` break case 'advertising': key = `${bucket}|${r.merchantOrderId || ''}|${day}` break default: key = `${bucket}|${code}|${r.orderId || ''}|${r.merchantOrderId || ''}|${day}` } const g = groups.get(key) || { bucket, code, orderId: r.orderId || '', reference: r.merchantOrderId || '', feeTypes: new Set(), description: r.amountDescription || r.amountType || r.transactionType || '', amount: 0, postedDate: '', internalDocumentNo: r.internalDocumentNo || '', invoiceDocumentNo: r.invoiceDocumentNo || '' } g.amount += Number(r.amount) || 0 g.postedDate = earlier(g.postedDate, r.postedDate) if (r.amountDescription) g.feeTypes.add(r.amountDescription) if (!g.internalDocumentNo && r.internalDocumentNo) g.internalDocumentNo = r.internalDocumentNo if (!g.invoiceDocumentNo && r.invoiceDocumentNo) g.invoiceDocumentNo = r.invoiceDocumentNo groups.set(key, g) } return [...groups.values()].filter(g => Math.round(g.amount * 100) !== 0) } // Verwendungszweck for one export group. Sales carry the internal invoice // number (what lexoffice's invoice matching keys on); fee lines only reference // it for traceability. export const amazonGroupPurpose = (g: AmazonLexofficeGroup): string => { const ref = g.invoiceDocumentNo ? ` · Rechnung ${g.invoiceDocumentNo}` : g.internalDocumentNo ? ` · Auftrag ${g.internalDocumentNo}` : ' · nicht zugeordnet' const fees = [...g.feeTypes].filter(Boolean).join(', ') switch (g.bucket) { case 'sale': return `${g.orderId} · Amazon Verkauf${ref}` case 'refund': return `${g.orderId} · Amazon Erstattung${ref}` case 'orderFee': return `${g.code} · Amazon Bestellgebühr${fees ? ` (${fees})` : ''}${g.invoiceDocumentNo ? ` · zu Rechnung ${g.invoiceDocumentNo}` : g.internalDocumentNo ? ` · zu Auftrag ${g.internalDocumentNo}` : ''}` case 'advertising': return `${g.code} · Amazon Werbekosten${g.reference ? ` · Rechnung ${g.reference}` : ''}` case 'serviceFee': return `${g.code} · Amazon Servicegebühr ${g.description}${g.orderId ? ` · ${g.orderId}` : ''}${g.reference ? ` · ${g.reference}` : ''}` default: return `${g.code} · Amazon ${g.description || 'Sonstiges'}${g.orderId ? ` · ${g.orderId}` : ''}${g.reference ? ` · ${g.reference}` : ''}` } }