@voyant-travel/finance 0.119.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/dist/action-ledger-drift.d.ts +29 -0
- package/dist/action-ledger-drift.d.ts.map +1 -0
- package/dist/action-ledger-drift.js +166 -0
- package/dist/booking-tax.d.ts +124 -0
- package/dist/booking-tax.d.ts.map +1 -0
- package/dist/booking-tax.js +264 -0
- package/dist/checkout-routes.d.ts +1154 -0
- package/dist/checkout-routes.d.ts.map +1 -0
- package/dist/checkout-routes.js +116 -0
- package/dist/checkout-service-plan.d.ts +137 -0
- package/dist/checkout-service-plan.d.ts.map +1 -0
- package/dist/checkout-service-plan.js +119 -0
- package/dist/checkout-service.d.ts +9 -0
- package/dist/checkout-service.d.ts.map +1 -0
- package/dist/checkout-service.js +324 -0
- package/dist/checkout-validation.d.ts +1682 -0
- package/dist/checkout-validation.d.ts.map +1 -0
- package/dist/checkout-validation.js +228 -0
- package/dist/document-download.d.ts +3 -0
- package/dist/document-download.d.ts.map +1 -0
- package/dist/document-download.js +1 -0
- package/dist/fx-money.d.ts +17 -0
- package/dist/fx-money.d.ts.map +1 -0
- package/dist/fx-money.js +194 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/invoice-fx.d.ts +134 -0
- package/dist/invoice-fx.d.ts.map +1 -0
- package/dist/invoice-fx.js +240 -0
- package/dist/invoice-number-errors.d.ts +2 -0
- package/dist/invoice-number-errors.d.ts.map +1 -0
- package/dist/invoice-number-errors.js +58 -0
- package/dist/markets-ref.d.ts +149 -0
- package/dist/markets-ref.d.ts.map +1 -0
- package/dist/markets-ref.js +17 -0
- package/dist/payment-link.d.ts +23 -0
- package/dist/payment-link.d.ts.map +1 -0
- package/dist/payment-link.js +30 -0
- package/dist/payment-policy.d.ts +113 -0
- package/dist/payment-policy.d.ts.map +1 -0
- package/dist/payment-policy.js +193 -0
- package/dist/route-runtime.d.ts +22 -0
- package/dist/route-runtime.d.ts.map +1 -0
- package/dist/route-runtime.js +18 -0
- package/dist/routes-action-ledger.d.ts +181 -0
- package/dist/routes-action-ledger.d.ts.map +1 -0
- package/dist/routes-action-ledger.js +142 -0
- package/dist/routes-booking-billing.d.ts +852 -0
- package/dist/routes-booking-billing.d.ts.map +1 -0
- package/dist/routes-booking-billing.js +223 -0
- package/dist/routes-booking-create.d.ts +3 -0
- package/dist/routes-booking-create.d.ts.map +1 -0
- package/dist/routes-booking-create.js +194 -0
- package/dist/routes-booking-reads.d.ts +46 -0
- package/dist/routes-booking-reads.d.ts.map +1 -0
- package/dist/routes-booking-reads.js +20 -0
- package/dist/routes-documents.d.ts +195 -0
- package/dist/routes-documents.d.ts.map +1 -0
- package/dist/routes-documents.js +93 -0
- package/dist/routes-invoice-core.d.ts +794 -0
- package/dist/routes-invoice-core.d.ts.map +1 -0
- package/dist/routes-invoice-core.js +238 -0
- package/dist/routes-invoice-documents.d.ts +401 -0
- package/dist/routes-invoice-documents.d.ts.map +1 -0
- package/dist/routes-invoice-documents.js +91 -0
- package/dist/routes-invoice-issue.d.ts +384 -0
- package/dist/routes-invoice-issue.d.ts.map +1 -0
- package/dist/routes-invoice-issue.js +208 -0
- package/dist/routes-payment-processing.d.ts +1193 -0
- package/dist/routes-payment-processing.d.ts.map +1 -0
- package/dist/routes-payment-processing.js +238 -0
- package/dist/routes-payments.d.ts +309 -0
- package/dist/routes-payments.d.ts.map +1 -0
- package/dist/routes-payments.js +94 -0
- package/dist/routes-public.d.ts +1948 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +275 -0
- package/dist/routes-reference-data.d.ts +977 -0
- package/dist/routes-reference-data.d.ts.map +1 -0
- package/dist/routes-reference-data.js +191 -0
- package/dist/routes-reports.d.ts +344 -0
- package/dist/routes-reports.d.ts.map +1 -0
- package/dist/routes-reports.js +93 -0
- package/dist/routes-runtime.d.ts +71 -0
- package/dist/routes-runtime.d.ts.map +1 -0
- package/dist/routes-runtime.js +59 -0
- package/dist/routes-settlement.d.ts +67 -0
- package/dist/routes-settlement.d.ts.map +1 -0
- package/dist/routes-settlement.js +23 -0
- package/dist/routes-shared.d.ts +35 -0
- package/dist/routes-shared.d.ts.map +1 -0
- package/dist/routes-shared.js +10 -0
- package/dist/routes-supplier-invoices.d.ts +778 -0
- package/dist/routes-supplier-invoices.d.ts.map +1 -0
- package/dist/routes-supplier-invoices.js +159 -0
- package/dist/routes-vouchers.d.ts +228 -0
- package/dist/routes-vouchers.d.ts.map +1 -0
- package/dist/routes-vouchers.js +54 -0
- package/dist/routes.d.ts +5577 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +44 -0
- package/dist/schema/booking-billing.d.ts +1006 -0
- package/dist/schema/booking-billing.d.ts.map +1 -0
- package/dist/schema/booking-billing.js +106 -0
- package/dist/schema/enums.d.ts +48 -0
- package/dist/schema/enums.d.ts.map +1 -0
- package/dist/schema/enums.js +237 -0
- package/dist/schema/invoice-documents.d.ts +1245 -0
- package/dist/schema/invoice-documents.d.ts.map +1 -0
- package/dist/schema/invoice-documents.js +140 -0
- package/dist/schema/payment-instruments.d.ts +418 -0
- package/dist/schema/payment-instruments.d.ts.map +1 -0
- package/dist/schema/payment-instruments.js +45 -0
- package/dist/schema/payment-processing.d.ts +563 -0
- package/dist/schema/payment-processing.d.ts.map +1 -0
- package/dist/schema/payment-processing.js +65 -0
- package/dist/schema/payment-sessions.d.ts +728 -0
- package/dist/schema/payment-sessions.d.ts.map +1 -0
- package/dist/schema/payment-sessions.js +79 -0
- package/dist/schema/receivables.d.ts +1474 -0
- package/dist/schema/receivables.d.ts.map +1 -0
- package/dist/schema/receivables.js +179 -0
- package/dist/schema/relations.d.ts +82 -0
- package/dist/schema/relations.d.ts.map +1 -0
- package/dist/schema/relations.js +144 -0
- package/dist/schema/supplier-invoices.d.ts +1619 -0
- package/dist/schema/supplier-invoices.d.ts.map +1 -0
- package/dist/schema/supplier-invoices.js +228 -0
- package/dist/schema/tax.d.ts +712 -0
- package/dist/schema/tax.d.ts.map +1 -0
- package/dist/schema/tax.js +98 -0
- package/dist/schema/vouchers.d.ts +444 -0
- package/dist/schema/vouchers.d.ts.map +1 -0
- package/dist/schema/vouchers.js +64 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +11 -0
- package/dist/service-accountant-shares.d.ts +106 -0
- package/dist/service-accountant-shares.d.ts.map +1 -0
- package/dist/service-accountant-shares.js +331 -0
- package/dist/service-action-ledger-accounting.d.ts +104 -0
- package/dist/service-action-ledger-accounting.d.ts.map +1 -0
- package/dist/service-action-ledger-accounting.js +386 -0
- package/dist/service-action-ledger-booking-payments.d.ts +48 -0
- package/dist/service-action-ledger-booking-payments.d.ts.map +1 -0
- package/dist/service-action-ledger-booking-payments.js +178 -0
- package/dist/service-action-ledger-bookings.d.ts +44 -0
- package/dist/service-action-ledger-bookings.d.ts.map +1 -0
- package/dist/service-action-ledger-bookings.js +81 -0
- package/dist/service-action-ledger-payment-authorizations.d.ts +48 -0
- package/dist/service-action-ledger-payment-authorizations.d.ts.map +1 -0
- package/dist/service-action-ledger-payment-authorizations.js +209 -0
- package/dist/service-action-ledger-payment-sessions.d.ts +83 -0
- package/dist/service-action-ledger-payment-sessions.d.ts.map +1 -0
- package/dist/service-action-ledger-payment-sessions.js +294 -0
- package/dist/service-action-ledger-supplier-invoices.d.ts +27 -0
- package/dist/service-action-ledger-supplier-invoices.d.ts.map +1 -0
- package/dist/service-action-ledger-supplier-invoices.js +111 -0
- package/dist/service-action-ledger-supplier-payments.d.ts +21 -0
- package/dist/service-action-ledger-supplier-payments.d.ts.map +1 -0
- package/dist/service-action-ledger-supplier-payments.js +97 -0
- package/dist/service-action-ledger.d.ts +7 -0
- package/dist/service-action-ledger.d.ts.map +1 -0
- package/dist/service-action-ledger.js +6 -0
- package/dist/service-aggregates.d.ts +96 -0
- package/dist/service-aggregates.d.ts.map +1 -0
- package/dist/service-aggregates.js +294 -0
- package/dist/service-booking-billing.d.ts +2322 -0
- package/dist/service-booking-billing.d.ts.map +1 -0
- package/dist/service-booking-billing.js +8 -0
- package/dist/service-booking-create.d.ts +410 -0
- package/dist/service-booking-create.d.ts.map +1 -0
- package/dist/service-booking-create.js +1256 -0
- package/dist/service-booking-guarantees.d.ts +725 -0
- package/dist/service-booking-guarantees.d.ts.map +1 -0
- package/dist/service-booking-guarantees.js +153 -0
- package/dist/service-booking-item-billing.d.ts +1062 -0
- package/dist/service-booking-item-billing.d.ts.map +1 -0
- package/dist/service-booking-item-billing.js +77 -0
- package/dist/service-booking-payment-schedules.d.ts +557 -0
- package/dist/service-booking-payment-schedules.d.ts.map +1 -0
- package/dist/service-booking-payment-schedules.js +372 -0
- package/dist/service-bookings-dual-create.d.ts +308 -0
- package/dist/service-bookings-dual-create.d.ts.map +1 -0
- package/dist/service-bookings-dual-create.js +131 -0
- package/dist/service-boundary-sql.d.ts +6 -0
- package/dist/service-boundary-sql.d.ts.map +1 -0
- package/dist/service-boundary-sql.js +15 -0
- package/dist/service-cost-categories.d.ts +26 -0
- package/dist/service-cost-categories.d.ts.map +1 -0
- package/dist/service-cost-categories.js +76 -0
- package/dist/service-documents.d.ts +80 -0
- package/dist/service-documents.d.ts.map +1 -0
- package/dist/service-documents.js +228 -0
- package/dist/service-invoice-artifacts.d.ts +246 -0
- package/dist/service-invoice-artifacts.d.ts.map +1 -0
- package/dist/service-invoice-artifacts.js +277 -0
- package/dist/service-invoice-core.d.ts +405 -0
- package/dist/service-invoice-core.d.ts.map +1 -0
- package/dist/service-invoice-core.js +290 -0
- package/dist/service-invoice-credit-notes.d.ts +973 -0
- package/dist/service-invoice-credit-notes.d.ts.map +1 -0
- package/dist/service-invoice-credit-notes.js +142 -0
- package/dist/service-invoice-from-booking.d.ts +41 -0
- package/dist/service-invoice-from-booking.d.ts.map +1 -0
- package/dist/service-invoice-from-booking.js +267 -0
- package/dist/service-invoice-line-items.d.ts +432 -0
- package/dist/service-invoice-line-items.d.ts.map +1 -0
- package/dist/service-invoice-line-items.js +102 -0
- package/dist/service-invoice-numbering.d.ts +227 -0
- package/dist/service-invoice-numbering.d.ts.map +1 -0
- package/dist/service-invoice-numbering.js +260 -0
- package/dist/service-invoice-payments.d.ts +673 -0
- package/dist/service-invoice-payments.d.ts.map +1 -0
- package/dist/service-invoice-payments.js +398 -0
- package/dist/service-invoices.d.ts +2501 -0
- package/dist/service-invoices.d.ts.map +1 -0
- package/dist/service-invoices.js +12 -0
- package/dist/service-issue.d.ts +207 -0
- package/dist/service-issue.d.ts.map +1 -0
- package/dist/service-issue.js +431 -0
- package/dist/service-payment-authorizations.d.ts +164 -0
- package/dist/service-payment-authorizations.d.ts.map +1 -0
- package/dist/service-payment-authorizations.js +227 -0
- package/dist/service-payment-instruments.d.ts +116 -0
- package/dist/service-payment-instruments.d.ts.map +1 -0
- package/dist/service-payment-instruments.js +99 -0
- package/dist/service-payment-processing.d.ts +676 -0
- package/dist/service-payment-processing.d.ts.map +1 -0
- package/dist/service-payment-processing.js +10 -0
- package/dist/service-payment-session-completion.d.ts +48 -0
- package/dist/service-payment-session-completion.d.ts.map +1 -0
- package/dist/service-payment-session-completion.js +238 -0
- package/dist/service-payment-sessions.d.ts +361 -0
- package/dist/service-payment-sessions.d.ts.map +1 -0
- package/dist/service-payment-sessions.js +280 -0
- package/dist/service-profitability.d.ts +114 -0
- package/dist/service-profitability.d.ts.map +1 -0
- package/dist/service-profitability.js +794 -0
- package/dist/service-public.d.ts +553 -0
- package/dist/service-public.d.ts.map +1 -0
- package/dist/service-public.js +583 -0
- package/dist/service-reference-data.d.ts +272 -0
- package/dist/service-reference-data.d.ts.map +1 -0
- package/dist/service-reference-data.js +280 -0
- package/dist/service-rendition-wait.d.ts +38 -0
- package/dist/service-rendition-wait.d.ts.map +1 -0
- package/dist/service-rendition-wait.js +67 -0
- package/dist/service-reports.d.ts +37 -0
- package/dist/service-reports.d.ts.map +1 -0
- package/dist/service-reports.js +62 -0
- package/dist/service-settlement.d.ts +46 -0
- package/dist/service-settlement.d.ts.map +1 -0
- package/dist/service-settlement.js +185 -0
- package/dist/service-shared.d.ts +541 -0
- package/dist/service-shared.d.ts.map +1 -0
- package/dist/service-shared.js +764 -0
- package/dist/service-supplier-invoices.d.ts +871 -0
- package/dist/service-supplier-invoices.d.ts.map +1 -0
- package/dist/service-supplier-invoices.js +744 -0
- package/dist/service-supplier-payments.d.ts +69 -0
- package/dist/service-supplier-payments.d.ts.map +1 -0
- package/dist/service-supplier-payments.js +136 -0
- package/dist/service-vouchers-migration.d.ts +44 -0
- package/dist/service-vouchers-migration.d.ts.map +1 -0
- package/dist/service-vouchers-migration.js +148 -0
- package/dist/service-vouchers.d.ts +157 -0
- package/dist/service-vouchers.d.ts.map +1 -0
- package/dist/service-vouchers.js +191 -0
- package/dist/service.d.ts +6490 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +29 -0
- package/dist/validation-billing.d.ts +2 -0
- package/dist/validation-billing.d.ts.map +1 -0
- package/dist/validation-billing.js +1 -0
- package/dist/validation-payments.d.ts +2 -0
- package/dist/validation-payments.d.ts.map +1 -0
- package/dist/validation-payments.js +1 -0
- package/dist/validation-public.d.ts +2 -0
- package/dist/validation-public.d.ts.map +1 -0
- package/dist/validation-public.js +1 -0
- package/dist/validation-shared.d.ts +2 -0
- package/dist/validation-shared.d.ts.map +1 -0
- package/dist/validation-shared.js +1 -0
- package/dist/validation-vouchers.d.ts +2 -0
- package/dist/validation-vouchers.d.ts.map +1 -0
- package/dist/validation-vouchers.js +1 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +1 -0
- package/package.json +121 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
// agent-quality: file-size exception -- owner: finance; existing service module stays co-located until a dedicated split preserves behavior and tests.
|
|
2
|
+
import { appendActionLedgerMutation, } from "@voyant-travel/action-ledger";
|
|
3
|
+
import { bookings } from "@voyant-travel/bookings/schema";
|
|
4
|
+
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
|
5
|
+
import { resolveFxMoneyBaseAmount } from "./fx-money.js";
|
|
6
|
+
import { resolveInvoiceFxSettingsOrDefault } from "./invoice-fx.js";
|
|
7
|
+
import { supplierCostAllocations, supplierInvoiceAttachments, supplierInvoiceLines, supplierInvoices, supplierPayments, } from "./schema.js";
|
|
8
|
+
import { buildSupplierInvoiceAllocationsActionLedgerInput, buildSupplierInvoiceCreateActionLedgerInput, buildSupplierInvoiceDeleteActionLedgerInput, buildSupplierInvoiceUpdateActionLedgerInput, } from "./service-action-ledger-supplier-invoices.js";
|
|
9
|
+
import { executeBoundaryRows, normalizeDateOnly, sqlList } from "./service-boundary-sql.js";
|
|
10
|
+
/**
|
|
11
|
+
* Raised by the supplier-invoice (AP) service. Route handlers map `code` to HTTP.
|
|
12
|
+
*/
|
|
13
|
+
export class SupplierInvoiceServiceError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message ?? code);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = "SupplierInvoiceServiceError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const NO_FX_SNAPSHOT = {
|
|
22
|
+
baseCurrency: null,
|
|
23
|
+
fxRateSetId: null,
|
|
24
|
+
baseSubtotalCents: null,
|
|
25
|
+
baseTaxCents: null,
|
|
26
|
+
baseTotalCents: null,
|
|
27
|
+
};
|
|
28
|
+
function toIssueDateString(value) {
|
|
29
|
+
if (value instanceof Date)
|
|
30
|
+
return value.toISOString().slice(0, 10);
|
|
31
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Snapshot the operator accounting-base value of a supplier invoice using the FX
|
|
35
|
+
* rate effective on its issue date (end-to-end FX §). The total is converted via
|
|
36
|
+
* the shared {@link resolveFxMoneyBaseAmount} (persisted rate as-of the issue
|
|
37
|
+
* date, then runtime resolver); subtotal/tax are pro-rated from the resolved base
|
|
38
|
+
* total so the parts always sum to the whole. When no rate resolves, every base
|
|
39
|
+
* column stays null (lazy/forward-only) rather than guessing at the latest rate.
|
|
40
|
+
*/
|
|
41
|
+
async function snapshotSupplierInvoiceFx(db, input, runtime) {
|
|
42
|
+
// Target the operator accounting base (declared on the invoice, else the
|
|
43
|
+
// configured/default base from FX settings — "RON" by default) so AP invoices
|
|
44
|
+
// snapshot into the same base the rest of finance reports in.
|
|
45
|
+
const settings = await resolveInvoiceFxSettingsOrDefault(db, runtime);
|
|
46
|
+
const targetBaseCurrency = input.baseCurrency ?? settings.baseCurrency;
|
|
47
|
+
const fxInput = {
|
|
48
|
+
amountCents: input.totalCents,
|
|
49
|
+
currency: input.currency,
|
|
50
|
+
baseCurrency: input.baseCurrency ?? null,
|
|
51
|
+
fxRateSetId: input.fxRateSetId ?? null,
|
|
52
|
+
};
|
|
53
|
+
const resolved = await resolveFxMoneyBaseAmount(db, fxInput, {
|
|
54
|
+
...runtime,
|
|
55
|
+
...(targetBaseCurrency ? { targetBaseCurrency } : {}),
|
|
56
|
+
fallbackFxRateSetId: input.fxRateSetId ?? null,
|
|
57
|
+
date: toIssueDateString(input.issueDate) ?? null,
|
|
58
|
+
setBaseCurrencyWhenUnresolved: false,
|
|
59
|
+
});
|
|
60
|
+
const baseCurrency = resolved.baseCurrency ?? null;
|
|
61
|
+
const baseTotalCents = resolved.baseAmountCents ?? null;
|
|
62
|
+
// The check constraint requires base_currency whenever any base amount is set;
|
|
63
|
+
// a bare currency with no amounts (or a stray fxRateSetId) is just noise.
|
|
64
|
+
if (!baseCurrency || baseTotalCents == null)
|
|
65
|
+
return NO_FX_SNAPSHOT;
|
|
66
|
+
const baseSubtotalCents = input.totalCents > 0
|
|
67
|
+
? Math.round((baseTotalCents * input.subtotalCents) / input.totalCents)
|
|
68
|
+
: baseTotalCents;
|
|
69
|
+
const baseTaxCents = baseTotalCents - baseSubtotalCents;
|
|
70
|
+
return {
|
|
71
|
+
baseCurrency,
|
|
72
|
+
fxRateSetId: resolved.fxRateSetId ?? null,
|
|
73
|
+
baseSubtotalCents,
|
|
74
|
+
baseTaxCents,
|
|
75
|
+
baseTotalCents,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Totals derived from lines. `total` is the sum of line totals (which already
|
|
80
|
+
* include tax); `tax` is the sum of line tax; `subtotal = total − tax`. This is
|
|
81
|
+
* internally consistent regardless of per-line unit×qty rounding.
|
|
82
|
+
*/
|
|
83
|
+
export function recomputeTotalsFromLines(lines) {
|
|
84
|
+
let tax = 0;
|
|
85
|
+
let total = 0;
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
tax += line.taxAmountCents ?? 0;
|
|
88
|
+
total += line.totalAmountCents;
|
|
89
|
+
}
|
|
90
|
+
return { subtotalCents: total - tax, taxCents: tax, totalCents: total };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Allocation invariants (§6.1):
|
|
94
|
+
* 1. One mode per invoice — either every allocation is whole-invoice
|
|
95
|
+
* (no line id) OR every allocation is per-line. Never mixed.
|
|
96
|
+
* 2. Exactly-one-target is enforced upstream by the zod schema + DB check.
|
|
97
|
+
* 3. No over-allocation — Σ per line ≤ that line's total; for whole-invoice
|
|
98
|
+
* mode, Σ ≤ the invoice total.
|
|
99
|
+
* 4. Under-allocation is allowed (the remainder is reported as `unattributed`
|
|
100
|
+
* by the read model, not stored).
|
|
101
|
+
*/
|
|
102
|
+
export function validateAllocations(params) {
|
|
103
|
+
const { invoiceTotalCents, lines, allocations } = params;
|
|
104
|
+
if (allocations.length === 0)
|
|
105
|
+
return { ok: true };
|
|
106
|
+
const hasLineLess = allocations.some((a) => a.supplierInvoiceLineId == null);
|
|
107
|
+
const hasPerLine = allocations.some((a) => a.supplierInvoiceLineId != null);
|
|
108
|
+
if (hasLineLess && hasPerLine) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
code: "mixed_allocation_modes",
|
|
112
|
+
message: "an invoice is allocated either whole-invoice or per-line, not both — split every allocation the same way",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (hasPerLine) {
|
|
116
|
+
const lineTotals = new Map(lines.map((l) => [l.id, l.totalAmountCents]));
|
|
117
|
+
const sums = new Map();
|
|
118
|
+
for (const a of allocations) {
|
|
119
|
+
const lineId = a.supplierInvoiceLineId;
|
|
120
|
+
if (!lineTotals.has(lineId)) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
code: "unknown_allocation_line",
|
|
124
|
+
message: `allocation references unknown line ${lineId}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
sums.set(lineId, (sums.get(lineId) ?? 0) + a.amountCents);
|
|
128
|
+
}
|
|
129
|
+
for (const [lineId, sum] of sums) {
|
|
130
|
+
const total = lineTotals.get(lineId) ?? 0;
|
|
131
|
+
if (sum > total) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
code: "over_allocated",
|
|
135
|
+
message: `line ${lineId} over-allocated (${sum} > ${total})`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ok: true };
|
|
140
|
+
}
|
|
141
|
+
const sum = allocations.reduce((acc, a) => acc + a.amountCents, 0);
|
|
142
|
+
if (sum > invoiceTotalCents) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
code: "over_allocated",
|
|
146
|
+
message: `invoice over-allocated (${sum} > ${invoiceTotalCents})`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { ok: true };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Next status given paid vs total. Manual/terminal states (draft, disputed,
|
|
153
|
+
* void) are never auto-changed. `paid` only flips automatically among the
|
|
154
|
+
* settlement states.
|
|
155
|
+
*/
|
|
156
|
+
export function nextStatusForBalance(current, totalCents, paidCents) {
|
|
157
|
+
if (current === "draft" || current === "disputed" || current === "void")
|
|
158
|
+
return current;
|
|
159
|
+
if (totalCents > 0 && paidCents >= totalCents)
|
|
160
|
+
return "paid";
|
|
161
|
+
if (paidCents > 0)
|
|
162
|
+
return "partially_paid";
|
|
163
|
+
// Fully unpaid (e.g. a payment was reversed): drop back from a paid state.
|
|
164
|
+
return current === "paid" || current === "partially_paid" ? "approved" : current;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Recompute `paidCents` / `balanceDueCents` / `status` for a supplier invoice
|
|
168
|
+
* from its completed payments. Currency-aware: a payment counts in the invoice
|
|
169
|
+
* currency directly, or via its base amount when the base currency matches
|
|
170
|
+
* (mirrors the AR settlement approach). §5.4 / §10.
|
|
171
|
+
*/
|
|
172
|
+
export async function recomputeSupplierInvoiceBalance(db, supplierInvoiceId) {
|
|
173
|
+
const [invoice] = await db
|
|
174
|
+
.select()
|
|
175
|
+
.from(supplierInvoices)
|
|
176
|
+
.where(eq(supplierInvoices.id, supplierInvoiceId))
|
|
177
|
+
.limit(1);
|
|
178
|
+
if (!invoice)
|
|
179
|
+
return null;
|
|
180
|
+
const [agg] = await db
|
|
181
|
+
.select({
|
|
182
|
+
paid: sql `coalesce(sum(
|
|
183
|
+
case
|
|
184
|
+
when ${supplierPayments.currency} = ${invoice.currency} then ${supplierPayments.amountCents}
|
|
185
|
+
when ${supplierPayments.baseCurrency} = ${invoice.currency} then coalesce(${supplierPayments.baseAmountCents}, 0)
|
|
186
|
+
else 0
|
|
187
|
+
end
|
|
188
|
+
), 0)::int`,
|
|
189
|
+
})
|
|
190
|
+
.from(supplierPayments)
|
|
191
|
+
.where(and(eq(supplierPayments.supplierInvoiceId, supplierInvoiceId), eq(supplierPayments.status, "completed")));
|
|
192
|
+
const paid = agg?.paid ?? 0;
|
|
193
|
+
const [updated] = await db
|
|
194
|
+
.update(supplierInvoices)
|
|
195
|
+
.set({
|
|
196
|
+
paidCents: paid,
|
|
197
|
+
balanceDueCents: invoice.totalCents - paid,
|
|
198
|
+
status: nextStatusForBalance(invoice.status, invoice.totalCents, paid),
|
|
199
|
+
updatedAt: new Date(),
|
|
200
|
+
})
|
|
201
|
+
.where(eq(supplierInvoices.id, supplierInvoiceId))
|
|
202
|
+
.returning();
|
|
203
|
+
return updated ?? null;
|
|
204
|
+
}
|
|
205
|
+
// ---------- internal mappers ----------
|
|
206
|
+
/**
|
|
207
|
+
* Map an allocation input to a DB row. `baseRate` (= invoice base total / invoice
|
|
208
|
+
* total, snapshotted at the issue-date rate) converts each allocation's amount to
|
|
209
|
+
* the accounting base so the per-departure rollup can sum recorded base amounts
|
|
210
|
+
* without re-running FX. Null `baseRate` leaves base null (no resolvable rate).
|
|
211
|
+
*/
|
|
212
|
+
function allocationValues(supplierInvoiceId, a, baseRate = null) {
|
|
213
|
+
const baseAmountCents = baseRate != null ? Math.round(a.amountCents * baseRate) : (a.baseAmountCents ?? null);
|
|
214
|
+
return {
|
|
215
|
+
supplierInvoiceId,
|
|
216
|
+
supplierInvoiceLineId: a.supplierInvoiceLineId ?? null,
|
|
217
|
+
targetType: a.targetType,
|
|
218
|
+
departureId: a.departureId ?? null,
|
|
219
|
+
productId: a.productId ?? null,
|
|
220
|
+
bookingId: a.bookingId ?? null,
|
|
221
|
+
bookingItemId: a.bookingItemId ?? null,
|
|
222
|
+
travelerId: a.travelerId ?? null,
|
|
223
|
+
amountCents: a.amountCents,
|
|
224
|
+
baseAmountCents,
|
|
225
|
+
splitMethod: a.splitMethod ?? "manual",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Base-conversion rate snapshotted on an invoice: base total ÷ original total. */
|
|
229
|
+
function invoiceBaseRate(invoice) {
|
|
230
|
+
if (invoice.baseTotalCents == null || invoice.totalCents === 0)
|
|
231
|
+
return null;
|
|
232
|
+
return invoice.baseTotalCents / invoice.totalCents;
|
|
233
|
+
}
|
|
234
|
+
function lineValues(supplierInvoiceId, line, index) {
|
|
235
|
+
return {
|
|
236
|
+
supplierInvoiceId,
|
|
237
|
+
description: line.description,
|
|
238
|
+
serviceType: line.serviceType ?? "other",
|
|
239
|
+
costCategoryId: line.costCategoryId ?? null,
|
|
240
|
+
supplierServiceId: line.supplierServiceId ?? null,
|
|
241
|
+
quantity: line.quantity ?? 1,
|
|
242
|
+
unitAmountCents: line.unitAmountCents,
|
|
243
|
+
taxRateBps: line.taxRateBps ?? null,
|
|
244
|
+
taxAmountCents: line.taxAmountCents ?? 0,
|
|
245
|
+
totalAmountCents: line.totalAmountCents,
|
|
246
|
+
sortOrder: line.sortOrder ?? index,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async function loadSupplierInvoice(db, id) {
|
|
250
|
+
const [invoice] = await db
|
|
251
|
+
.select()
|
|
252
|
+
.from(supplierInvoices)
|
|
253
|
+
.where(eq(supplierInvoices.id, id))
|
|
254
|
+
.limit(1);
|
|
255
|
+
if (!invoice)
|
|
256
|
+
return null;
|
|
257
|
+
const [lines, allocations] = await Promise.all([
|
|
258
|
+
db
|
|
259
|
+
.select()
|
|
260
|
+
.from(supplierInvoiceLines)
|
|
261
|
+
.where(eq(supplierInvoiceLines.supplierInvoiceId, id))
|
|
262
|
+
.orderBy(asc(supplierInvoiceLines.sortOrder)),
|
|
263
|
+
db
|
|
264
|
+
.select()
|
|
265
|
+
.from(supplierCostAllocations)
|
|
266
|
+
.where(eq(supplierCostAllocations.supplierInvoiceId, id))
|
|
267
|
+
.orderBy(asc(supplierCostAllocations.createdAt)),
|
|
268
|
+
]);
|
|
269
|
+
const targetLabels = await resolveAllocationTargetLabels(db, allocations);
|
|
270
|
+
const allocationsWithLabels = allocations.map((a) => ({
|
|
271
|
+
...a,
|
|
272
|
+
targetLabel: targetLabels.get(a.departureId ?? a.productId ?? a.bookingId ?? a.travelerId ?? "") ?? null,
|
|
273
|
+
}));
|
|
274
|
+
return { ...invoice, lines, allocations: allocationsWithLabels };
|
|
275
|
+
}
|
|
276
|
+
/** Resolve friendly labels for allocation targets (departure date+product, product, booking no). */
|
|
277
|
+
async function resolveAllocationTargetLabels(db, allocations) {
|
|
278
|
+
const labels = new Map();
|
|
279
|
+
const departureIds = [
|
|
280
|
+
...new Set(allocations.map((a) => a.departureId).filter(Boolean)),
|
|
281
|
+
];
|
|
282
|
+
const productIds = [...new Set(allocations.map((a) => a.productId).filter(Boolean))];
|
|
283
|
+
const bookingIds = [...new Set(allocations.map((a) => a.bookingId).filter(Boolean))];
|
|
284
|
+
const [slotRows, productRows, bookingRows] = await Promise.all([
|
|
285
|
+
departureIds.length
|
|
286
|
+
? executeBoundaryRows(db,
|
|
287
|
+
// agent-quality: raw-sql reviewed -- owner: finance; Availability/Product are read-only allocation label sources and ids are parameter-bound.
|
|
288
|
+
sql `
|
|
289
|
+
SELECT avs.id, avs.date_local, p.name AS product_name
|
|
290
|
+
FROM availability_slots avs
|
|
291
|
+
LEFT JOIN products p ON avs.product_id = p.id
|
|
292
|
+
WHERE avs.id IN (${sqlList(departureIds)})
|
|
293
|
+
`)
|
|
294
|
+
: Promise.resolve([]),
|
|
295
|
+
productIds.length
|
|
296
|
+
? executeBoundaryRows(db,
|
|
297
|
+
// agent-quality: raw-sql reviewed -- owner: finance; Product is a read-only allocation label source and ids are parameter-bound.
|
|
298
|
+
sql `
|
|
299
|
+
SELECT id, name
|
|
300
|
+
FROM products
|
|
301
|
+
WHERE id IN (${sqlList(productIds)})
|
|
302
|
+
`)
|
|
303
|
+
: Promise.resolve([]),
|
|
304
|
+
bookingIds.length
|
|
305
|
+
? db
|
|
306
|
+
.select({ id: bookings.id, bookingNumber: bookings.bookingNumber })
|
|
307
|
+
.from(bookings)
|
|
308
|
+
.where(inArray(bookings.id, bookingIds))
|
|
309
|
+
: Promise.resolve([]),
|
|
310
|
+
]);
|
|
311
|
+
for (const s of slotRows) {
|
|
312
|
+
const dateLocal = normalizeDateOnly(s.date_local) ?? String(s.date_local);
|
|
313
|
+
labels.set(s.id, s.product_name ? `${s.product_name} · ${dateLocal}` : dateLocal);
|
|
314
|
+
}
|
|
315
|
+
for (const p of productRows)
|
|
316
|
+
labels.set(p.id, p.name);
|
|
317
|
+
for (const b of bookingRows)
|
|
318
|
+
labels.set(b.id, b.bookingNumber);
|
|
319
|
+
return labels;
|
|
320
|
+
}
|
|
321
|
+
const SORT_COLUMNS = {
|
|
322
|
+
issueDate: supplierInvoices.issueDate,
|
|
323
|
+
dueDate: supplierInvoices.dueDate,
|
|
324
|
+
totalCents: supplierInvoices.totalCents,
|
|
325
|
+
balanceDueCents: supplierInvoices.balanceDueCents,
|
|
326
|
+
status: supplierInvoices.status,
|
|
327
|
+
createdAt: supplierInvoices.createdAt,
|
|
328
|
+
};
|
|
329
|
+
export const supplierInvoicesService = {
|
|
330
|
+
async list(db, query) {
|
|
331
|
+
const conditions = [isNull(supplierInvoices.deletedAt)];
|
|
332
|
+
if (query.supplierId)
|
|
333
|
+
conditions.push(eq(supplierInvoices.supplierId, query.supplierId));
|
|
334
|
+
if (query.status)
|
|
335
|
+
conditions.push(eq(supplierInvoices.status, query.status));
|
|
336
|
+
if (query.currency)
|
|
337
|
+
conditions.push(eq(supplierInvoices.currency, query.currency));
|
|
338
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
339
|
+
if (query.dueDateFrom)
|
|
340
|
+
conditions.push(sql `${supplierInvoices.dueDate} >= ${query.dueDateFrom}`);
|
|
341
|
+
// agent-quality: raw-sql reviewed -- owner: finance; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
342
|
+
if (query.dueDateTo)
|
|
343
|
+
conditions.push(sql `${supplierInvoices.dueDate} <= ${query.dueDateTo}`);
|
|
344
|
+
if (query.search) {
|
|
345
|
+
const term = `%${query.search}%`;
|
|
346
|
+
conditions.push(or(ilike(supplierInvoices.supplierInvoiceNo, term), ilike(supplierInvoices.internalRef, term), ilike(supplierInvoices.notes, term)));
|
|
347
|
+
}
|
|
348
|
+
// Attribution filters join through the allocations table.
|
|
349
|
+
const attributedTo = (column, value) => inArray(supplierInvoices.id, db
|
|
350
|
+
.select({ id: supplierCostAllocations.supplierInvoiceId })
|
|
351
|
+
.from(supplierCostAllocations)
|
|
352
|
+
.where(eq(column, value)));
|
|
353
|
+
if (query.departureId) {
|
|
354
|
+
conditions.push(attributedTo(supplierCostAllocations.departureId, query.departureId));
|
|
355
|
+
}
|
|
356
|
+
if (query.productId) {
|
|
357
|
+
conditions.push(attributedTo(supplierCostAllocations.productId, query.productId));
|
|
358
|
+
}
|
|
359
|
+
if (query.bookingId) {
|
|
360
|
+
conditions.push(attributedTo(supplierCostAllocations.bookingId, query.bookingId));
|
|
361
|
+
}
|
|
362
|
+
const where = and(...conditions);
|
|
363
|
+
const sortColumn = SORT_COLUMNS[query.sortBy];
|
|
364
|
+
const orderBy = query.sortDir === "asc" ? asc(sortColumn) : desc(sortColumn);
|
|
365
|
+
const [rows, countResult] = await Promise.all([
|
|
366
|
+
db
|
|
367
|
+
.select()
|
|
368
|
+
.from(supplierInvoices)
|
|
369
|
+
.where(where)
|
|
370
|
+
.limit(query.limit)
|
|
371
|
+
.offset(query.offset)
|
|
372
|
+
.orderBy(orderBy),
|
|
373
|
+
db.select({ count: sql `count(*)::int` }).from(supplierInvoices).where(where),
|
|
374
|
+
]);
|
|
375
|
+
return {
|
|
376
|
+
data: rows,
|
|
377
|
+
total: countResult[0]?.count ?? 0,
|
|
378
|
+
limit: query.limit,
|
|
379
|
+
offset: query.offset,
|
|
380
|
+
};
|
|
381
|
+
},
|
|
382
|
+
async getById(db, id) {
|
|
383
|
+
return loadSupplierInvoice(db, id);
|
|
384
|
+
},
|
|
385
|
+
async create(db, input, runtime = {}) {
|
|
386
|
+
const lines = input.lines ?? [];
|
|
387
|
+
const allocations = input.allocations ?? [];
|
|
388
|
+
// Create-time allocations must be whole-invoice: new lines have no ids yet,
|
|
389
|
+
// so per-line allocation has to happen via setAllocations after create.
|
|
390
|
+
if (allocations.some((a) => a.supplierInvoiceLineId)) {
|
|
391
|
+
throw new SupplierInvoiceServiceError("allocate_lines_after_create", "per-line allocations must be set after the invoice (and its lines) exist");
|
|
392
|
+
}
|
|
393
|
+
const totals = lines.length
|
|
394
|
+
? recomputeTotalsFromLines(lines)
|
|
395
|
+
: {
|
|
396
|
+
subtotalCents: input.subtotalCents ?? 0,
|
|
397
|
+
taxCents: input.taxCents ?? 0,
|
|
398
|
+
totalCents: input.totalCents ?? 0,
|
|
399
|
+
};
|
|
400
|
+
const check = validateAllocations({
|
|
401
|
+
invoiceTotalCents: totals.totalCents,
|
|
402
|
+
lines: [],
|
|
403
|
+
allocations,
|
|
404
|
+
});
|
|
405
|
+
if (!check.ok)
|
|
406
|
+
throw new SupplierInvoiceServiceError(check.code, check.message);
|
|
407
|
+
const fx = await snapshotSupplierInvoiceFx(db, {
|
|
408
|
+
currency: input.currency,
|
|
409
|
+
subtotalCents: totals.subtotalCents,
|
|
410
|
+
taxCents: totals.taxCents,
|
|
411
|
+
totalCents: totals.totalCents,
|
|
412
|
+
baseCurrency: input.baseCurrency ?? null,
|
|
413
|
+
fxRateSetId: input.fxRateSetId ?? null,
|
|
414
|
+
issueDate: input.issueDate,
|
|
415
|
+
}, runtime);
|
|
416
|
+
const baseRate = invoiceBaseRate({
|
|
417
|
+
totalCents: totals.totalCents,
|
|
418
|
+
baseTotalCents: fx.baseTotalCents,
|
|
419
|
+
});
|
|
420
|
+
const created = await db.transaction(async (tx) => {
|
|
421
|
+
const [invoice] = await tx
|
|
422
|
+
.insert(supplierInvoices)
|
|
423
|
+
.values({
|
|
424
|
+
supplierId: input.supplierId,
|
|
425
|
+
supplierInvoiceNo: input.supplierInvoiceNo,
|
|
426
|
+
internalRef: input.internalRef ?? null,
|
|
427
|
+
status: input.status ?? "draft",
|
|
428
|
+
currency: input.currency,
|
|
429
|
+
baseCurrency: fx.baseCurrency,
|
|
430
|
+
fxRateSetId: fx.fxRateSetId,
|
|
431
|
+
subtotalCents: totals.subtotalCents,
|
|
432
|
+
taxCents: totals.taxCents,
|
|
433
|
+
totalCents: totals.totalCents,
|
|
434
|
+
baseSubtotalCents: fx.baseSubtotalCents,
|
|
435
|
+
baseTaxCents: fx.baseTaxCents,
|
|
436
|
+
baseTotalCents: fx.baseTotalCents,
|
|
437
|
+
paidCents: 0,
|
|
438
|
+
balanceDueCents: totals.totalCents,
|
|
439
|
+
taxRegimeId: input.taxRegimeId ?? null,
|
|
440
|
+
issueDate: input.issueDate,
|
|
441
|
+
dueDate: input.dueDate ?? null,
|
|
442
|
+
storageKey: input.storageKey ?? null,
|
|
443
|
+
extractionId: input.extractionId ?? null,
|
|
444
|
+
notes: input.notes ?? null,
|
|
445
|
+
})
|
|
446
|
+
.returning();
|
|
447
|
+
if (!invoice)
|
|
448
|
+
return null;
|
|
449
|
+
if (lines.length) {
|
|
450
|
+
await tx
|
|
451
|
+
.insert(supplierInvoiceLines)
|
|
452
|
+
.values(lines.map((line, index) => lineValues(invoice.id, line, index)));
|
|
453
|
+
}
|
|
454
|
+
if (allocations.length) {
|
|
455
|
+
await tx
|
|
456
|
+
.insert(supplierCostAllocations)
|
|
457
|
+
.values(allocations.map((a) => allocationValues(invoice.id, a, baseRate)));
|
|
458
|
+
}
|
|
459
|
+
if (runtime.actionLedgerContext) {
|
|
460
|
+
await appendActionLedgerMutation(tx, await buildSupplierInvoiceCreateActionLedgerInput(runtime.actionLedgerContext, { invoice }, { authorizationSource: runtime.actionLedgerAuthorizationSource }));
|
|
461
|
+
}
|
|
462
|
+
return invoice;
|
|
463
|
+
});
|
|
464
|
+
return created ? loadSupplierInvoice(db, created.id) : null;
|
|
465
|
+
},
|
|
466
|
+
async update(db, id, input, runtime = {}) {
|
|
467
|
+
const set = { updatedAt: new Date() };
|
|
468
|
+
for (const key of [
|
|
469
|
+
"supplierId",
|
|
470
|
+
"supplierInvoiceNo",
|
|
471
|
+
"internalRef",
|
|
472
|
+
"status",
|
|
473
|
+
"currency",
|
|
474
|
+
"baseCurrency",
|
|
475
|
+
"fxRateSetId",
|
|
476
|
+
"taxRegimeId",
|
|
477
|
+
"issueDate",
|
|
478
|
+
"dueDate",
|
|
479
|
+
"storageKey",
|
|
480
|
+
"extractionId",
|
|
481
|
+
"notes",
|
|
482
|
+
]) {
|
|
483
|
+
if (input[key] !== undefined)
|
|
484
|
+
set[key] = input[key];
|
|
485
|
+
}
|
|
486
|
+
// If header totals are edited directly, keep balanceDue consistent.
|
|
487
|
+
if (input.totalCents !== undefined) {
|
|
488
|
+
set.totalCents = input.totalCents;
|
|
489
|
+
if (input.subtotalCents !== undefined)
|
|
490
|
+
set.subtotalCents = input.subtotalCents;
|
|
491
|
+
if (input.taxCents !== undefined)
|
|
492
|
+
set.taxCents = input.taxCents;
|
|
493
|
+
}
|
|
494
|
+
// Re-snapshot base amounts when any FX-affecting field changes (currency,
|
|
495
|
+
// declared base/rate-set, issue date, or totals). Uses the merged row so a
|
|
496
|
+
// partial edit still resolves the correct issue-date rate.
|
|
497
|
+
const fxAffected = input.currency !== undefined ||
|
|
498
|
+
input.baseCurrency !== undefined ||
|
|
499
|
+
input.fxRateSetId !== undefined ||
|
|
500
|
+
input.issueDate !== undefined ||
|
|
501
|
+
input.totalCents !== undefined ||
|
|
502
|
+
input.subtotalCents !== undefined ||
|
|
503
|
+
input.taxCents !== undefined;
|
|
504
|
+
if (fxAffected) {
|
|
505
|
+
const [current] = await db
|
|
506
|
+
.select()
|
|
507
|
+
.from(supplierInvoices)
|
|
508
|
+
.where(eq(supplierInvoices.id, id))
|
|
509
|
+
.limit(1);
|
|
510
|
+
if (current) {
|
|
511
|
+
const totalCents = input.totalCents ?? current.totalCents;
|
|
512
|
+
const fx = await snapshotSupplierInvoiceFx(db, {
|
|
513
|
+
currency: input.currency ?? current.currency,
|
|
514
|
+
subtotalCents: input.subtotalCents ?? current.subtotalCents,
|
|
515
|
+
taxCents: input.taxCents ?? current.taxCents,
|
|
516
|
+
totalCents,
|
|
517
|
+
baseCurrency: input.baseCurrency ?? current.baseCurrency,
|
|
518
|
+
fxRateSetId: input.fxRateSetId ?? current.fxRateSetId,
|
|
519
|
+
issueDate: input.issueDate ?? current.issueDate,
|
|
520
|
+
}, runtime);
|
|
521
|
+
set.baseCurrency = fx.baseCurrency;
|
|
522
|
+
set.fxRateSetId = fx.fxRateSetId;
|
|
523
|
+
set.baseSubtotalCents = fx.baseSubtotalCents;
|
|
524
|
+
set.baseTaxCents = fx.baseTaxCents;
|
|
525
|
+
set.baseTotalCents = fx.baseTotalCents;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const runUpdate = (writer) => writer.update(supplierInvoices).set(set).where(eq(supplierInvoices.id, id)).returning();
|
|
529
|
+
if (runtime.actionLedgerContext) {
|
|
530
|
+
const row = await db.transaction(async (tx) => {
|
|
531
|
+
const [updated] = await runUpdate(tx);
|
|
532
|
+
if (updated && input.totalCents !== undefined) {
|
|
533
|
+
await tx
|
|
534
|
+
.update(supplierInvoices)
|
|
535
|
+
.set({ balanceDueCents: updated.totalCents - updated.paidCents })
|
|
536
|
+
.where(eq(supplierInvoices.id, id));
|
|
537
|
+
}
|
|
538
|
+
if (updated) {
|
|
539
|
+
await appendActionLedgerMutation(tx, buildSupplierInvoiceUpdateActionLedgerInput(runtime.actionLedgerContext, { invoice: updated, changes: input }, { authorizationSource: runtime.actionLedgerAuthorizationSource }));
|
|
540
|
+
}
|
|
541
|
+
return updated ?? null;
|
|
542
|
+
});
|
|
543
|
+
return row ? loadSupplierInvoice(db, id) : null;
|
|
544
|
+
}
|
|
545
|
+
const [updated] = await runUpdate(db);
|
|
546
|
+
if (updated && input.totalCents !== undefined) {
|
|
547
|
+
await db
|
|
548
|
+
.update(supplierInvoices)
|
|
549
|
+
.set({ balanceDueCents: updated.totalCents - updated.paidCents })
|
|
550
|
+
.where(eq(supplierInvoices.id, id));
|
|
551
|
+
}
|
|
552
|
+
return updated ? loadSupplierInvoice(db, id) : null;
|
|
553
|
+
},
|
|
554
|
+
/**
|
|
555
|
+
* Replace the invoice's lines and recompute header totals. Note: deleting a
|
|
556
|
+
* line cascades to any per-line allocations bound to it (FK on delete cascade)
|
|
557
|
+
* — re-set allocations after editing lines.
|
|
558
|
+
*/
|
|
559
|
+
async setLines(db, id, input, runtime = {}) {
|
|
560
|
+
const totals = recomputeTotalsFromLines(input.lines);
|
|
561
|
+
const updated = await db.transaction(async (tx) => {
|
|
562
|
+
const [invoice] = await tx
|
|
563
|
+
.select()
|
|
564
|
+
.from(supplierInvoices)
|
|
565
|
+
.where(eq(supplierInvoices.id, id))
|
|
566
|
+
.limit(1);
|
|
567
|
+
if (!invoice)
|
|
568
|
+
return null;
|
|
569
|
+
await tx.delete(supplierInvoiceLines).where(eq(supplierInvoiceLines.supplierInvoiceId, id));
|
|
570
|
+
if (input.lines.length) {
|
|
571
|
+
await tx
|
|
572
|
+
.insert(supplierInvoiceLines)
|
|
573
|
+
.values(input.lines.map((line, index) => lineValues(id, line, index)));
|
|
574
|
+
}
|
|
575
|
+
// Per-line allocations cascade out with their lines, but whole-invoice
|
|
576
|
+
// (line-less) allocations survive — and a shrunk line total could leave
|
|
577
|
+
// them over-allocated. Re-validate against the NEW total and reject rather
|
|
578
|
+
// than silently corrupt the P&L (mirrors setAllocations' invariant).
|
|
579
|
+
const survivingAllocations = await tx
|
|
580
|
+
.select({
|
|
581
|
+
supplierInvoiceLineId: supplierCostAllocations.supplierInvoiceLineId,
|
|
582
|
+
amountCents: supplierCostAllocations.amountCents,
|
|
583
|
+
})
|
|
584
|
+
.from(supplierCostAllocations)
|
|
585
|
+
.where(eq(supplierCostAllocations.supplierInvoiceId, id));
|
|
586
|
+
if (survivingAllocations.length) {
|
|
587
|
+
const check = validateAllocations({
|
|
588
|
+
invoiceTotalCents: totals.totalCents,
|
|
589
|
+
lines: [],
|
|
590
|
+
allocations: survivingAllocations,
|
|
591
|
+
});
|
|
592
|
+
if (!check.ok)
|
|
593
|
+
throw new SupplierInvoiceServiceError(check.code, check.message);
|
|
594
|
+
}
|
|
595
|
+
// Totals changed → re-snapshot the base value at the invoice's issue date.
|
|
596
|
+
const fx = await snapshotSupplierInvoiceFx(db, {
|
|
597
|
+
currency: invoice.currency,
|
|
598
|
+
subtotalCents: totals.subtotalCents,
|
|
599
|
+
taxCents: totals.taxCents,
|
|
600
|
+
totalCents: totals.totalCents,
|
|
601
|
+
baseCurrency: invoice.baseCurrency,
|
|
602
|
+
fxRateSetId: invoice.fxRateSetId,
|
|
603
|
+
issueDate: invoice.issueDate,
|
|
604
|
+
}, runtime);
|
|
605
|
+
const [next] = await tx
|
|
606
|
+
.update(supplierInvoices)
|
|
607
|
+
.set({
|
|
608
|
+
subtotalCents: totals.subtotalCents,
|
|
609
|
+
taxCents: totals.taxCents,
|
|
610
|
+
totalCents: totals.totalCents,
|
|
611
|
+
baseCurrency: fx.baseCurrency,
|
|
612
|
+
fxRateSetId: fx.fxRateSetId,
|
|
613
|
+
baseSubtotalCents: fx.baseSubtotalCents,
|
|
614
|
+
baseTaxCents: fx.baseTaxCents,
|
|
615
|
+
baseTotalCents: fx.baseTotalCents,
|
|
616
|
+
balanceDueCents: totals.totalCents - invoice.paidCents,
|
|
617
|
+
updatedAt: new Date(),
|
|
618
|
+
})
|
|
619
|
+
.where(eq(supplierInvoices.id, id))
|
|
620
|
+
.returning();
|
|
621
|
+
if (next && runtime.actionLedgerContext) {
|
|
622
|
+
await appendActionLedgerMutation(tx, buildSupplierInvoiceUpdateActionLedgerInput(runtime.actionLedgerContext, { invoice: next, changes: { lines: input.lines.length } }, { authorizationSource: runtime.actionLedgerAuthorizationSource }));
|
|
623
|
+
}
|
|
624
|
+
return next ?? null;
|
|
625
|
+
});
|
|
626
|
+
return updated ? loadSupplierInvoice(db, id) : null;
|
|
627
|
+
},
|
|
628
|
+
/**
|
|
629
|
+
* Replace the invoice's cost allocations after validating the §6.1 invariants
|
|
630
|
+
* against the current lines + invoice total.
|
|
631
|
+
*/
|
|
632
|
+
async setAllocations(db, id, input, runtime = {}) {
|
|
633
|
+
const result = await db.transaction(async (tx) => {
|
|
634
|
+
const [invoice] = await tx
|
|
635
|
+
.select()
|
|
636
|
+
.from(supplierInvoices)
|
|
637
|
+
.where(eq(supplierInvoices.id, id))
|
|
638
|
+
.limit(1);
|
|
639
|
+
if (!invoice)
|
|
640
|
+
return { invoice: null };
|
|
641
|
+
const lines = await tx
|
|
642
|
+
.select({
|
|
643
|
+
id: supplierInvoiceLines.id,
|
|
644
|
+
totalAmountCents: supplierInvoiceLines.totalAmountCents,
|
|
645
|
+
})
|
|
646
|
+
.from(supplierInvoiceLines)
|
|
647
|
+
.where(eq(supplierInvoiceLines.supplierInvoiceId, id));
|
|
648
|
+
const check = validateAllocations({
|
|
649
|
+
invoiceTotalCents: invoice.totalCents,
|
|
650
|
+
lines,
|
|
651
|
+
allocations: input.allocations,
|
|
652
|
+
});
|
|
653
|
+
if (!check.ok)
|
|
654
|
+
throw new SupplierInvoiceServiceError(check.code, check.message);
|
|
655
|
+
await tx
|
|
656
|
+
.delete(supplierCostAllocations)
|
|
657
|
+
.where(eq(supplierCostAllocations.supplierInvoiceId, id));
|
|
658
|
+
if (input.allocations.length) {
|
|
659
|
+
const baseRate = invoiceBaseRate(invoice);
|
|
660
|
+
await tx
|
|
661
|
+
.insert(supplierCostAllocations)
|
|
662
|
+
.values(input.allocations.map((a) => allocationValues(id, a, baseRate)));
|
|
663
|
+
}
|
|
664
|
+
if (runtime.actionLedgerContext) {
|
|
665
|
+
await appendActionLedgerMutation(tx, buildSupplierInvoiceAllocationsActionLedgerInput(runtime.actionLedgerContext, { invoice, allocationCount: input.allocations.length }, { authorizationSource: runtime.actionLedgerAuthorizationSource }));
|
|
666
|
+
}
|
|
667
|
+
return { invoice };
|
|
668
|
+
});
|
|
669
|
+
return result.invoice ? loadSupplierInvoice(db, id) : null;
|
|
670
|
+
},
|
|
671
|
+
/** Soft-delete: keeps the audit trail; excluded from list + uniqueness. */
|
|
672
|
+
async softDelete(db, id, runtime = {}) {
|
|
673
|
+
const result = await db.transaction(async (tx) => {
|
|
674
|
+
const [existing] = await tx
|
|
675
|
+
.select()
|
|
676
|
+
.from(supplierInvoices)
|
|
677
|
+
.where(eq(supplierInvoices.id, id))
|
|
678
|
+
.limit(1);
|
|
679
|
+
if (!existing)
|
|
680
|
+
return null;
|
|
681
|
+
await tx
|
|
682
|
+
.update(supplierInvoices)
|
|
683
|
+
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
|
684
|
+
.where(eq(supplierInvoices.id, id));
|
|
685
|
+
if (runtime.actionLedgerContext) {
|
|
686
|
+
await appendActionLedgerMutation(tx, buildSupplierInvoiceDeleteActionLedgerInput(runtime.actionLedgerContext, { invoice: existing }, { authorizationSource: runtime.actionLedgerAuthorizationSource }));
|
|
687
|
+
}
|
|
688
|
+
return { id: existing.id };
|
|
689
|
+
});
|
|
690
|
+
return result;
|
|
691
|
+
},
|
|
692
|
+
// ---------- attachments ----------
|
|
693
|
+
listAttachments(db, supplierInvoiceId) {
|
|
694
|
+
return db
|
|
695
|
+
.select()
|
|
696
|
+
.from(supplierInvoiceAttachments)
|
|
697
|
+
.where(eq(supplierInvoiceAttachments.supplierInvoiceId, supplierInvoiceId))
|
|
698
|
+
.orderBy(desc(supplierInvoiceAttachments.createdAt));
|
|
699
|
+
},
|
|
700
|
+
async getAttachmentById(db, attachmentId) {
|
|
701
|
+
const [row] = await db
|
|
702
|
+
.select()
|
|
703
|
+
.from(supplierInvoiceAttachments)
|
|
704
|
+
.where(eq(supplierInvoiceAttachments.id, attachmentId))
|
|
705
|
+
.limit(1);
|
|
706
|
+
return row ?? null;
|
|
707
|
+
},
|
|
708
|
+
async createAttachment(db, supplierInvoiceId, input) {
|
|
709
|
+
const [invoice] = await db
|
|
710
|
+
.select({ id: supplierInvoices.id })
|
|
711
|
+
.from(supplierInvoices)
|
|
712
|
+
.where(eq(supplierInvoices.id, supplierInvoiceId))
|
|
713
|
+
.limit(1);
|
|
714
|
+
if (!invoice)
|
|
715
|
+
return null;
|
|
716
|
+
const [row] = await db
|
|
717
|
+
.insert(supplierInvoiceAttachments)
|
|
718
|
+
.values({
|
|
719
|
+
supplierInvoiceId,
|
|
720
|
+
kind: input.kind ?? "supporting_document",
|
|
721
|
+
name: input.name,
|
|
722
|
+
mimeType: input.mimeType ?? null,
|
|
723
|
+
fileSize: input.fileSize ?? null,
|
|
724
|
+
storageKey: input.storageKey ?? null,
|
|
725
|
+
checksum: input.checksum ?? null,
|
|
726
|
+
metadata: input.metadata ?? null,
|
|
727
|
+
})
|
|
728
|
+
.returning();
|
|
729
|
+
return row ?? null;
|
|
730
|
+
},
|
|
731
|
+
async deleteAttachment(db, supplierInvoiceId, attachmentId) {
|
|
732
|
+
const [existing] = await db
|
|
733
|
+
.select({ id: supplierInvoiceAttachments.id })
|
|
734
|
+
.from(supplierInvoiceAttachments)
|
|
735
|
+
.where(and(eq(supplierInvoiceAttachments.id, attachmentId), eq(supplierInvoiceAttachments.supplierInvoiceId, supplierInvoiceId)))
|
|
736
|
+
.limit(1);
|
|
737
|
+
if (!existing)
|
|
738
|
+
return null;
|
|
739
|
+
await db
|
|
740
|
+
.delete(supplierInvoiceAttachments)
|
|
741
|
+
.where(eq(supplierInvoiceAttachments.id, attachmentId));
|
|
742
|
+
return { id: existing.id };
|
|
743
|
+
},
|
|
744
|
+
};
|