diff --git a/backend/migrations/core/111_backfill_imported_invoice_dates.js b/backend/migrations/core/111_backfill_imported_invoice_dates.js new file mode 100644 index 00000000..47068067 --- /dev/null +++ b/backend/migrations/core/111_backfill_imported_invoice_dates.js @@ -0,0 +1,47 @@ +/** + * Migration: backfill historical send/payment dates on already-imported + * invoices. + * + * Background: the invoice-import endpoint (POST /admin/invoices/import) + * used to stamp `sent_at` and `paid_at` with the moment of import + * (`new Date()`) rather than the document's own historical dates. The + * CRM dashboard's "Revenue · last 30 days" card keys on `paid_at`, so a + * year-old paid invoice imported today wrongly counted toward the + * rolling window. The route now anchors both timestamps to `issue_date` + * (with an optional explicit `paidAt`); this migration brings the rows + * imported under the old behaviour in line. + * + * Scope: rows with `imported_pdf_path` set — i.e. historical documents, + * never invoices issued by picpeak itself. For those, no real payment + * date was ever captured (the column held the import timestamp), so the + * issue date is the best available anchor. Note: `paid_at`/`sent_at` are + * operational timestamps, not part of the invoice's immutable legal + * content — correcting an import-time bug on them doesn't alter the + * issued document. + * + * Idempotent: re-runs just re-assign the same issue_date value. + */ + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('invoices'))) return; + const cols = ['imported_pdf_path', 'issue_date', 'sent_at', 'paid_at']; + for (const c of cols) { + if (!(await knex.schema.hasColumn('invoices', c))) return; + } + + // sent_at → issue_date for every imported row that has one. + await knex('invoices') + .whereNotNull('imported_pdf_path') + .whereNotNull('sent_at') + .update({ sent_at: knex.ref('issue_date') }); + + // paid_at → issue_date for imported rows that recorded a payment. + await knex('invoices') + .whereNotNull('imported_pdf_path') + .whereNotNull('paid_at') + .update({ paid_at: knex.ref('issue_date') }); +}; + +// Irreversible by design: the original import-time stamps were wrong +// data, and there's no record of them to restore. +exports.down = async function() {}; diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index c8f4e7c5..dd077a10 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -417,6 +417,8 @@ router.post( // currency 3-letter ISO (optional, default profile/CHF) // status 'sent' | 'paid' | 'overdue' (default 'sent') // paidAmountMinor int (optional, for status='paid') +// paidAt ISO date (optional — the real historical payment +// date; defaults to issueDate, never import time) // language string (optional, default 'de') router.post( '/import', @@ -431,6 +433,7 @@ router.post( body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }), body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']), body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }), + body('paidAt').optional({ values: 'falsy' }).isISO8601(), body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }), ], handleAsync(async (req, res) => { @@ -470,6 +473,13 @@ router.post( const status = req.body.status || 'sent'; const issueDate = req.body.issueDate; const dueDate = req.body.dueDate || issueDate; + // Imported docs are historical: their real send/payment dates are + // the document's own dates, NOT the moment of import. Stamping + // import-time here put year-old paid invoices inside the dashboard's + // rolling "Revenue · last 30 days" window (which keys on paid_at). + // Anchor to the historical date; let the admin override paid_at when + // they know the exact payment date. + const paidAt = req.body.paidAt || issueDate; const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase(); const language = req.body.language || customer.preferred_language || 'de'; @@ -488,14 +498,14 @@ router.post( installment_trigger: null, status, scheduled_send_at: null, - sent_at: status !== 'scheduled' ? new Date() : null, + sent_at: new Date(issueDate), net_amount_minor: totalMinor, // imported docs lack a breakdown vat_rate: 0, // VAT info lives in the imported PDF vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: totalMinor, paid_amount_minor: paidMinor, - paid_at: status === 'paid' ? new Date() : null, + paid_at: status === 'paid' ? new Date(paidAt) : null, // Store the path RELATIVE to STORAGE_PATH so the value survives // a host migration (Docker volume remount on a new host with a // different absolute path).