feat(accounting): re-bill proof attachment, CRM panel & hours↔re-bills cross-add (#979)
Closes #866. Three features, all behind the `incomingInvoices` feature flag: 1. Attach the stored supplier proof PDF to the client-invoice email when a captured invoice is re-billed/passed through, as a SEPARATE attachment so invoice immutability holds. Global default (off), per-customer tri-state override, and per-file selection in a new Send dialog. A missing proof at issue time stamps inbound_documents.proof_attach_error rather than silently dropping, and never blocks the send. Proof filename is a configurable template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens. 2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid with status derived from the linked invoice lifecycle rather than a duplicated column. 3. Cross-add dialog rolling open hours and open re-bills into one invoice, symmetric from both entry points. The two stay distinct, contiguous line groups — never merged into shared line items. Migration 169 is additive, hasColumn-guarded and idempotent. Review (two rounds) closed two concerns: - Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a covering invoice was cancelled, so a Storno'd re-bill showed as Open in the new panel while every billing path filters on that column being NULL — the supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now detaches the linkage on both invoice-cancel paths, with a regression test on the issued-cancel path. - Permission gating: the new controls rendered on data presence alone while their endpoints require accounting.view / accounting.manage / customers.edit. Now gated at both the query and render layers. Known follow-up: two cross-add counter queries are gated on a permission their endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) — degrades safely, one line each.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Migration 169: re-bill proof-attachment support (issue #866).
|
||||
*
|
||||
* - inbound_documents.proof_attach_error : best-effort failure marker. When a
|
||||
* re-billed supplier invoice's stored
|
||||
* proof PDF is missing/unreadable at
|
||||
* the moment the client invoice is
|
||||
* issued, we DON'T silently drop it —
|
||||
* we stamp the reason here so the
|
||||
* re-bill row in CRM → Customer shows
|
||||
* a recovery banner.
|
||||
* - customer_accounts.rebill_attach_proof: per-customer tri-state override for
|
||||
* "attach the supplier proof to the
|
||||
* client-invoice email".
|
||||
* NULL = inherit the global default
|
||||
* true = always attach
|
||||
* false = never attach
|
||||
* The global default itself lives in
|
||||
* app_settings (accounting_rebill_
|
||||
* attach_proof, default off) and needs
|
||||
* no seed row — an absent key coerces
|
||||
* to false, exactly like
|
||||
* accounting_require_proof.
|
||||
*
|
||||
* Additive + hasColumn-guarded so re-runs are safe.
|
||||
*/
|
||||
async function addColumn(knex, table, column, builder) {
|
||||
if (!(await knex.schema.hasColumn(table, column))) {
|
||||
await knex.schema.alterTable(table, builder);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (await knex.schema.hasTable('inbound_documents')) {
|
||||
await addColumn(knex, 'inbound_documents', 'proof_attach_error', (t) => t.text('proof_attach_error'));
|
||||
}
|
||||
if (await knex.schema.hasTable('customer_accounts')) {
|
||||
// Nullable boolean = tri-state (NULL inherit / true on / false off).
|
||||
await addColumn(knex, 'customer_accounts', 'rebill_attach_proof', (t) => t.boolean('rebill_attach_proof').nullable());
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('inbound_documents') && await knex.schema.hasColumn('inbound_documents', 'proof_attach_error')) {
|
||||
await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn('proof_attach_error'));
|
||||
}
|
||||
if (await knex.schema.hasTable('customer_accounts') && await knex.schema.hasColumn('customer_accounts', 'rebill_attach_proof')) {
|
||||
await knex.schema.alterTable('customer_accounts', (t) => t.dropColumn('rebill_attach_proof'));
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user