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:
@@ -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() {};
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user