import { downloadLexofficeCsv, type LexofficeCsvRow } from '~/utils/lexofficeExport' // Shared export orchestration for the marketplace settlement pages (Amazon // settlement periods / eBay payouts) → lexoffice bank-statement CSV. // // Three variants, all driven by the SAME list of ticked periods: // - combined : positions + the closing payout ("Geldtransit") row per period // in ONE file — PayJoe's "Sammelkonto" layout, every period nets // to exactly 0,00 on the clearing account. Recommended default. // - payouts : only the payout rows (one per period). Pure list data. // - positions : only the position rows (per order / fee / ad invoice). // The page supplies the marketplace-specific pieces (how to fetch a period's // detail rows and how to turn them into CSV rows); everything else — progress, // warnings, foreign-currency skips, netting check, sorting, file naming — lives // here once instead of twice. export interface LexofficeRowsResult { rows: LexofficeCsvRow[] warning?: string // The rows are a deliberate subset of the period (grid filter applied), so the // period is not expected to net to 0,00 — skips the combined-export netting check. partial?: boolean } export interface LexofficeSettlementExportOptions { // Loads the detail rows + summary of one period. Return { error } to skip it. fetchDetail: (item: TItem) => Promise<{ rows?: any[]; summary?: any; error?: string }> buildPositionRows: (item: TItem, detailRows: any[], summary: any) => LexofficeRowsResult buildPayoutRow: (item: TItem) => LexofficeRowsResult // Short human label for warnings ("Zeitraum 01.06.–15.06.2026"). itemLabel: (item: TItem) => string // ISO date used for the file name range (payout date). itemDate: (item: TItem) => string | null | undefined filePrefix: string // e.g. 'lexoffice-amazon' onAlert: (alert: { status: number; message: string }) => void emptySelectionMessage: () => string // Optional: called AFTER the CSV file was handed to the browser, once per // period that contributed at least one row (positions and/or payout line). // The pages use it to mark the exported orders as "CSV-exportiert" (C_Order // marker) and to record the period in the export log (useSettlementExportLog). onExported?: (variant: LexofficeExportVariant, exported: LexofficeExportedPeriod[], meta: { fileName: string; rowCount: number }) => void | Promise } export type LexofficeExportVariant = 'combined' | 'payouts' | 'positions' export interface LexofficeExportedPeriod { item: TItem // Detail rows the position lines were built from (empty for payouts-only) rows: any[] positions: boolean // position lines of this period are in the file partial: boolean // …but only a filtered subset of the period payout: boolean // the payout (Geldtransit) line is in the file // Net of this period's lines in a combined, complete export (0,00 = the // clearing account closes); null otherwise net: number | null } const isoDay = (value: string | null | undefined) => { if (!value) return '' const d = new Date(value) return isNaN(d.getTime()) ? '' : d.toISOString().split('T')[0] } const rowTime = (row: LexofficeCsvRow) => { const d = row.bookingDate instanceof Date ? row.bookingDate : new Date(row.bookingDate || '') return isNaN(d.getTime()) ? Number.MAX_SAFE_INTEGER : d.getTime() } const round2 = (n: number) => Math.round((Number(n) || 0) * 100) / 100 export const useLexofficeSettlementExport = (options: LexofficeSettlementExportOptions) => { const exporting = ref(false) const progress = ref({ done: 0, total: 0 }) const buildFileName = (variant: LexofficeExportVariant, items: TItem[]) => { const days = items.map(i => isoDay(options.itemDate(i))).filter(Boolean).sort() const range = days.length ? `${days[0]}_${days[days.length - 1]}` : new Date().toISOString().split('T')[0] const part = variant === 'combined' ? 'sammelkonto' : variant === 'payouts' ? 'auszahlungen' : 'positionen' return `${options.filePrefix}-${part}-${range}.csv` } const finish = (variant: LexofficeExportVariant, items: TItem[], allRows: LexofficeCsvRow[], warnings: string[], partialCount = 0): { fileName: string; rowCount: number } | null => { if (!allRows.length) { options.onAlert({ status: 400, message: 'Keine exportierbaren Zeilen gefunden.' + (warnings.length ? ' — ' + warnings.join(' | ') : '') }) return null } // Stable chronological order (Buchungstag), payout row of a period last // among equal dates because it is pushed after the positions. const sorted = allRows .map((row, idx) => ({ row, idx, t: rowTime(row) })) .sort((a, b) => a.t - b.t || a.idx - b.idx) .map(x => x.row) const fileName = buildFileName(variant, items) const result = downloadLexofficeCsv(sorted, fileName) const base = variant === 'combined' ? (partialCount ? `Lexoffice-CSV erstellt: ${result.rowCount} Zeilen für ${items.length} Zeitraum/Zeiträume — gefilterter Export: ${partialCount} Zeitraum/Zeiträume nur mit den sichtbaren Positionen und OHNE Auszahlungszeile${partialCount < items.length ? `, ${items.length - partialCount} vollständig inkl. Auszahlungszeile (Saldo 0,00)` : ''}. In lexoffice auf das Verrechnungskonto importieren; fehlende Auszahlungszeilen bei Bedarf über „Nur Auszahlungszeilen“ exportieren.` : `Lexoffice-CSV erstellt: ${result.rowCount} Zeilen für ${items.length} Zeitraum/Zeiträume (Positionen + Auszahlung, je Zeitraum Saldo 0,00). In lexoffice auf das Verrechnungskonto importieren; die Auszahlungszeile als Geldtransit mit dem Bankeingang verknüpfen.`) : variant === 'payouts' ? `Auszahlungs-CSV erstellt: ${result.rowCount} Zeilen. Jede Zeile in lexoffice als Geldtransit kategorisieren und mit dem passenden Eingang auf dem Bankkonto verknüpfen.` : `Positions-CSV erstellt: ${result.rowCount} Zeilen.` options.onAlert({ status: 200, message: warnings.length ? `${base} Warnungen: ${warnings.join(' | ')}` : base }) return { fileName, rowCount: result.rowCount } } // `extraWarnings`: notes the caller already knows before the run starts (e.g. // periods skipped because they are not loaded / not ticked) — reported with // the result alert like any other warning. const run = async (variant: LexofficeExportVariant, items: TItem[], extraWarnings: string[] = []) => { if (exporting.value) return if (!items.length) { options.onAlert({ status: 400, message: [options.emptySelectionMessage(), ...extraWarnings].join(' — ') }) return } const needsDetail = variant !== 'payouts' exporting.value = true progress.value = { done: 0, total: needsDetail ? items.length : 0 } const allRows: LexofficeCsvRow[] = [] const warnings: string[] = [...extraWarnings] // Periods that contributed rows to the file (for onExported). const exportedDetails: LexofficeExportedPeriod[] = [] let partialCount = 0 try { for (const item of items) { const label = options.itemLabel(item) const periodRows: LexofficeCsvRow[] = [] let partial = false let detailRows: any[] = [] let positionCount = 0 let payoutCount = 0 let net: number | null = null try { if (needsDetail) { const detail = await options.fetchDetail(item) if (detail?.error) { warnings.push(`${label}: ${detail.error}`) continue } detailRows = detail?.rows || [] const pos = options.buildPositionRows(item, detailRows, detail?.summary) if (pos.warning) warnings.push(pos.warning) partial = !!pos.partial if (partial) partialCount++ periodRows.push(...pos.rows) positionCount = pos.rows.length } if (variant !== 'positions') { const pay = options.buildPayoutRow(item) // The position builder usually raised the same warning already // (foreign currency); only add what is new (e.g. "payout row left out"). if (pay.warning && !warnings.includes(pay.warning)) warnings.push(pay.warning) periodRows.push(...pay.rows) payoutCount = pay.rows.length } if (variant === 'combined' && periodRows.length && !partial) { // Every period must net to 0,00 on the clearing account. The position // builder already surfaces any reconciliation residue as its own row, // so a non-zero here means a genuine data problem worth flagging. net = round2(periodRows.reduce((s, r) => s + round2(r.amount), 0)) if (Math.abs(net) >= 0.01) { warnings.push(`${label}: Saldo ${net.toFixed(2).replace('.', ',')} ≠ 0,00 — bitte prüfen`) } } allRows.push(...periodRows) if (periodRows.length) { exportedDetails.push({ item, rows: positionCount ? detailRows : [], positions: positionCount > 0, partial: positionCount > 0 && partial, payout: payoutCount > 0, net: payoutCount > 0 && positionCount > 0 && !partial ? net : null }) } } catch (error: any) { warnings.push(`${label}: ${error?.data?.message || error?.message || 'Fehler beim Laden'}`) } finally { if (needsDetail) progress.value.done++ } } } finally { exporting.value = false } const downloaded = finish(variant, items, allRows, warnings, partialCount) if (downloaded && exportedDetails.length && options.onExported) { await options.onExported(variant, exportedDetails, downloaded) } } return { exporting, progress, exportCombined: (items: TItem[], extraWarnings?: string[]) => run('combined', items, extraWarnings), exportPayoutsOnly: (items: TItem[], extraWarnings?: string[]) => run('payouts', items, extraWarnings), exportPositionsOnly: (items: TItem[], extraWarnings?: string[]) => run('positions', items, extraWarnings) } }