fix(crm): anchor imported invoice dates to issue_date, not import time

The invoice-import endpoint stamped sent_at and paid_at with the moment
of import (new Date()) instead of the document's historical dates. The
CRM dashboard "Revenue · last 30 days" card keys on paid_at, so a
year-old paid invoice imported today wrongly counted toward the rolling
window. The dashboard windowing is correct (cash-basis "received in the
window") — the bug was the wrong paid_at on imported rows.

POST /admin/invoices/import now anchors sent_at to issue_date and
paid_at to issue_date (or an optional new paidAt param when the admin
knows the real payment date), never to import time.

Migration 111 backfills rows imported under the old behaviour: for every
invoice with imported_pdf_path set, sent_at/paid_at are reset to
issue_date. The old code never captured a real payment date, so
issue_date is the only sensible anchor. Idempotent and scoped strictly
to imported rows, so picpeak-issued invoices are untouched.

paid_at/sent_at are operational timestamps, not the invoice's immutable
legal content, so correcting the import-time error is safe under the
§14/§11 UStG immutability rule.
This commit is contained in:
Luca
2026-06-02 01:36:52 +02:00
parent db2c482ae9
commit c6b8cc9199
2 changed files with 59 additions and 2 deletions
+12 -2
View File
@@ -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).