@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.
Files changed (294) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/dist/action-ledger-drift.d.ts +29 -0
  4. package/dist/action-ledger-drift.d.ts.map +1 -0
  5. package/dist/action-ledger-drift.js +166 -0
  6. package/dist/booking-tax.d.ts +124 -0
  7. package/dist/booking-tax.d.ts.map +1 -0
  8. package/dist/booking-tax.js +264 -0
  9. package/dist/checkout-routes.d.ts +1154 -0
  10. package/dist/checkout-routes.d.ts.map +1 -0
  11. package/dist/checkout-routes.js +116 -0
  12. package/dist/checkout-service-plan.d.ts +137 -0
  13. package/dist/checkout-service-plan.d.ts.map +1 -0
  14. package/dist/checkout-service-plan.js +119 -0
  15. package/dist/checkout-service.d.ts +9 -0
  16. package/dist/checkout-service.d.ts.map +1 -0
  17. package/dist/checkout-service.js +324 -0
  18. package/dist/checkout-validation.d.ts +1682 -0
  19. package/dist/checkout-validation.d.ts.map +1 -0
  20. package/dist/checkout-validation.js +228 -0
  21. package/dist/document-download.d.ts +3 -0
  22. package/dist/document-download.d.ts.map +1 -0
  23. package/dist/document-download.js +1 -0
  24. package/dist/fx-money.d.ts +17 -0
  25. package/dist/fx-money.d.ts.map +1 -0
  26. package/dist/fx-money.js +194 -0
  27. package/dist/index.d.ts +65 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +108 -0
  30. package/dist/invoice-fx.d.ts +134 -0
  31. package/dist/invoice-fx.d.ts.map +1 -0
  32. package/dist/invoice-fx.js +240 -0
  33. package/dist/invoice-number-errors.d.ts +2 -0
  34. package/dist/invoice-number-errors.d.ts.map +1 -0
  35. package/dist/invoice-number-errors.js +58 -0
  36. package/dist/markets-ref.d.ts +149 -0
  37. package/dist/markets-ref.d.ts.map +1 -0
  38. package/dist/markets-ref.js +17 -0
  39. package/dist/payment-link.d.ts +23 -0
  40. package/dist/payment-link.d.ts.map +1 -0
  41. package/dist/payment-link.js +30 -0
  42. package/dist/payment-policy.d.ts +113 -0
  43. package/dist/payment-policy.d.ts.map +1 -0
  44. package/dist/payment-policy.js +193 -0
  45. package/dist/route-runtime.d.ts +22 -0
  46. package/dist/route-runtime.d.ts.map +1 -0
  47. package/dist/route-runtime.js +18 -0
  48. package/dist/routes-action-ledger.d.ts +181 -0
  49. package/dist/routes-action-ledger.d.ts.map +1 -0
  50. package/dist/routes-action-ledger.js +142 -0
  51. package/dist/routes-booking-billing.d.ts +852 -0
  52. package/dist/routes-booking-billing.d.ts.map +1 -0
  53. package/dist/routes-booking-billing.js +223 -0
  54. package/dist/routes-booking-create.d.ts +3 -0
  55. package/dist/routes-booking-create.d.ts.map +1 -0
  56. package/dist/routes-booking-create.js +194 -0
  57. package/dist/routes-booking-reads.d.ts +46 -0
  58. package/dist/routes-booking-reads.d.ts.map +1 -0
  59. package/dist/routes-booking-reads.js +20 -0
  60. package/dist/routes-documents.d.ts +195 -0
  61. package/dist/routes-documents.d.ts.map +1 -0
  62. package/dist/routes-documents.js +93 -0
  63. package/dist/routes-invoice-core.d.ts +794 -0
  64. package/dist/routes-invoice-core.d.ts.map +1 -0
  65. package/dist/routes-invoice-core.js +238 -0
  66. package/dist/routes-invoice-documents.d.ts +401 -0
  67. package/dist/routes-invoice-documents.d.ts.map +1 -0
  68. package/dist/routes-invoice-documents.js +91 -0
  69. package/dist/routes-invoice-issue.d.ts +384 -0
  70. package/dist/routes-invoice-issue.d.ts.map +1 -0
  71. package/dist/routes-invoice-issue.js +208 -0
  72. package/dist/routes-payment-processing.d.ts +1193 -0
  73. package/dist/routes-payment-processing.d.ts.map +1 -0
  74. package/dist/routes-payment-processing.js +238 -0
  75. package/dist/routes-payments.d.ts +309 -0
  76. package/dist/routes-payments.d.ts.map +1 -0
  77. package/dist/routes-payments.js +94 -0
  78. package/dist/routes-public.d.ts +1948 -0
  79. package/dist/routes-public.d.ts.map +1 -0
  80. package/dist/routes-public.js +275 -0
  81. package/dist/routes-reference-data.d.ts +977 -0
  82. package/dist/routes-reference-data.d.ts.map +1 -0
  83. package/dist/routes-reference-data.js +191 -0
  84. package/dist/routes-reports.d.ts +344 -0
  85. package/dist/routes-reports.d.ts.map +1 -0
  86. package/dist/routes-reports.js +93 -0
  87. package/dist/routes-runtime.d.ts +71 -0
  88. package/dist/routes-runtime.d.ts.map +1 -0
  89. package/dist/routes-runtime.js +59 -0
  90. package/dist/routes-settlement.d.ts +67 -0
  91. package/dist/routes-settlement.d.ts.map +1 -0
  92. package/dist/routes-settlement.js +23 -0
  93. package/dist/routes-shared.d.ts +35 -0
  94. package/dist/routes-shared.d.ts.map +1 -0
  95. package/dist/routes-shared.js +10 -0
  96. package/dist/routes-supplier-invoices.d.ts +778 -0
  97. package/dist/routes-supplier-invoices.d.ts.map +1 -0
  98. package/dist/routes-supplier-invoices.js +159 -0
  99. package/dist/routes-vouchers.d.ts +228 -0
  100. package/dist/routes-vouchers.d.ts.map +1 -0
  101. package/dist/routes-vouchers.js +54 -0
  102. package/dist/routes.d.ts +5577 -0
  103. package/dist/routes.d.ts.map +1 -0
  104. package/dist/routes.js +44 -0
  105. package/dist/schema/booking-billing.d.ts +1006 -0
  106. package/dist/schema/booking-billing.d.ts.map +1 -0
  107. package/dist/schema/booking-billing.js +106 -0
  108. package/dist/schema/enums.d.ts +48 -0
  109. package/dist/schema/enums.d.ts.map +1 -0
  110. package/dist/schema/enums.js +237 -0
  111. package/dist/schema/invoice-documents.d.ts +1245 -0
  112. package/dist/schema/invoice-documents.d.ts.map +1 -0
  113. package/dist/schema/invoice-documents.js +140 -0
  114. package/dist/schema/payment-instruments.d.ts +418 -0
  115. package/dist/schema/payment-instruments.d.ts.map +1 -0
  116. package/dist/schema/payment-instruments.js +45 -0
  117. package/dist/schema/payment-processing.d.ts +563 -0
  118. package/dist/schema/payment-processing.d.ts.map +1 -0
  119. package/dist/schema/payment-processing.js +65 -0
  120. package/dist/schema/payment-sessions.d.ts +728 -0
  121. package/dist/schema/payment-sessions.d.ts.map +1 -0
  122. package/dist/schema/payment-sessions.js +79 -0
  123. package/dist/schema/receivables.d.ts +1474 -0
  124. package/dist/schema/receivables.d.ts.map +1 -0
  125. package/dist/schema/receivables.js +179 -0
  126. package/dist/schema/relations.d.ts +82 -0
  127. package/dist/schema/relations.d.ts.map +1 -0
  128. package/dist/schema/relations.js +144 -0
  129. package/dist/schema/supplier-invoices.d.ts +1619 -0
  130. package/dist/schema/supplier-invoices.d.ts.map +1 -0
  131. package/dist/schema/supplier-invoices.js +228 -0
  132. package/dist/schema/tax.d.ts +712 -0
  133. package/dist/schema/tax.d.ts.map +1 -0
  134. package/dist/schema/tax.js +98 -0
  135. package/dist/schema/vouchers.d.ts +444 -0
  136. package/dist/schema/vouchers.d.ts.map +1 -0
  137. package/dist/schema/vouchers.js +64 -0
  138. package/dist/schema.d.ts +12 -0
  139. package/dist/schema.d.ts.map +1 -0
  140. package/dist/schema.js +11 -0
  141. package/dist/service-accountant-shares.d.ts +106 -0
  142. package/dist/service-accountant-shares.d.ts.map +1 -0
  143. package/dist/service-accountant-shares.js +331 -0
  144. package/dist/service-action-ledger-accounting.d.ts +104 -0
  145. package/dist/service-action-ledger-accounting.d.ts.map +1 -0
  146. package/dist/service-action-ledger-accounting.js +386 -0
  147. package/dist/service-action-ledger-booking-payments.d.ts +48 -0
  148. package/dist/service-action-ledger-booking-payments.d.ts.map +1 -0
  149. package/dist/service-action-ledger-booking-payments.js +178 -0
  150. package/dist/service-action-ledger-bookings.d.ts +44 -0
  151. package/dist/service-action-ledger-bookings.d.ts.map +1 -0
  152. package/dist/service-action-ledger-bookings.js +81 -0
  153. package/dist/service-action-ledger-payment-authorizations.d.ts +48 -0
  154. package/dist/service-action-ledger-payment-authorizations.d.ts.map +1 -0
  155. package/dist/service-action-ledger-payment-authorizations.js +209 -0
  156. package/dist/service-action-ledger-payment-sessions.d.ts +83 -0
  157. package/dist/service-action-ledger-payment-sessions.d.ts.map +1 -0
  158. package/dist/service-action-ledger-payment-sessions.js +294 -0
  159. package/dist/service-action-ledger-supplier-invoices.d.ts +27 -0
  160. package/dist/service-action-ledger-supplier-invoices.d.ts.map +1 -0
  161. package/dist/service-action-ledger-supplier-invoices.js +111 -0
  162. package/dist/service-action-ledger-supplier-payments.d.ts +21 -0
  163. package/dist/service-action-ledger-supplier-payments.d.ts.map +1 -0
  164. package/dist/service-action-ledger-supplier-payments.js +97 -0
  165. package/dist/service-action-ledger.d.ts +7 -0
  166. package/dist/service-action-ledger.d.ts.map +1 -0
  167. package/dist/service-action-ledger.js +6 -0
  168. package/dist/service-aggregates.d.ts +96 -0
  169. package/dist/service-aggregates.d.ts.map +1 -0
  170. package/dist/service-aggregates.js +294 -0
  171. package/dist/service-booking-billing.d.ts +2322 -0
  172. package/dist/service-booking-billing.d.ts.map +1 -0
  173. package/dist/service-booking-billing.js +8 -0
  174. package/dist/service-booking-create.d.ts +410 -0
  175. package/dist/service-booking-create.d.ts.map +1 -0
  176. package/dist/service-booking-create.js +1256 -0
  177. package/dist/service-booking-guarantees.d.ts +725 -0
  178. package/dist/service-booking-guarantees.d.ts.map +1 -0
  179. package/dist/service-booking-guarantees.js +153 -0
  180. package/dist/service-booking-item-billing.d.ts +1062 -0
  181. package/dist/service-booking-item-billing.d.ts.map +1 -0
  182. package/dist/service-booking-item-billing.js +77 -0
  183. package/dist/service-booking-payment-schedules.d.ts +557 -0
  184. package/dist/service-booking-payment-schedules.d.ts.map +1 -0
  185. package/dist/service-booking-payment-schedules.js +372 -0
  186. package/dist/service-bookings-dual-create.d.ts +308 -0
  187. package/dist/service-bookings-dual-create.d.ts.map +1 -0
  188. package/dist/service-bookings-dual-create.js +131 -0
  189. package/dist/service-boundary-sql.d.ts +6 -0
  190. package/dist/service-boundary-sql.d.ts.map +1 -0
  191. package/dist/service-boundary-sql.js +15 -0
  192. package/dist/service-cost-categories.d.ts +26 -0
  193. package/dist/service-cost-categories.d.ts.map +1 -0
  194. package/dist/service-cost-categories.js +76 -0
  195. package/dist/service-documents.d.ts +80 -0
  196. package/dist/service-documents.d.ts.map +1 -0
  197. package/dist/service-documents.js +228 -0
  198. package/dist/service-invoice-artifacts.d.ts +246 -0
  199. package/dist/service-invoice-artifacts.d.ts.map +1 -0
  200. package/dist/service-invoice-artifacts.js +277 -0
  201. package/dist/service-invoice-core.d.ts +405 -0
  202. package/dist/service-invoice-core.d.ts.map +1 -0
  203. package/dist/service-invoice-core.js +290 -0
  204. package/dist/service-invoice-credit-notes.d.ts +973 -0
  205. package/dist/service-invoice-credit-notes.d.ts.map +1 -0
  206. package/dist/service-invoice-credit-notes.js +142 -0
  207. package/dist/service-invoice-from-booking.d.ts +41 -0
  208. package/dist/service-invoice-from-booking.d.ts.map +1 -0
  209. package/dist/service-invoice-from-booking.js +267 -0
  210. package/dist/service-invoice-line-items.d.ts +432 -0
  211. package/dist/service-invoice-line-items.d.ts.map +1 -0
  212. package/dist/service-invoice-line-items.js +102 -0
  213. package/dist/service-invoice-numbering.d.ts +227 -0
  214. package/dist/service-invoice-numbering.d.ts.map +1 -0
  215. package/dist/service-invoice-numbering.js +260 -0
  216. package/dist/service-invoice-payments.d.ts +673 -0
  217. package/dist/service-invoice-payments.d.ts.map +1 -0
  218. package/dist/service-invoice-payments.js +398 -0
  219. package/dist/service-invoices.d.ts +2501 -0
  220. package/dist/service-invoices.d.ts.map +1 -0
  221. package/dist/service-invoices.js +12 -0
  222. package/dist/service-issue.d.ts +207 -0
  223. package/dist/service-issue.d.ts.map +1 -0
  224. package/dist/service-issue.js +431 -0
  225. package/dist/service-payment-authorizations.d.ts +164 -0
  226. package/dist/service-payment-authorizations.d.ts.map +1 -0
  227. package/dist/service-payment-authorizations.js +227 -0
  228. package/dist/service-payment-instruments.d.ts +116 -0
  229. package/dist/service-payment-instruments.d.ts.map +1 -0
  230. package/dist/service-payment-instruments.js +99 -0
  231. package/dist/service-payment-processing.d.ts +676 -0
  232. package/dist/service-payment-processing.d.ts.map +1 -0
  233. package/dist/service-payment-processing.js +10 -0
  234. package/dist/service-payment-session-completion.d.ts +48 -0
  235. package/dist/service-payment-session-completion.d.ts.map +1 -0
  236. package/dist/service-payment-session-completion.js +238 -0
  237. package/dist/service-payment-sessions.d.ts +361 -0
  238. package/dist/service-payment-sessions.d.ts.map +1 -0
  239. package/dist/service-payment-sessions.js +280 -0
  240. package/dist/service-profitability.d.ts +114 -0
  241. package/dist/service-profitability.d.ts.map +1 -0
  242. package/dist/service-profitability.js +794 -0
  243. package/dist/service-public.d.ts +553 -0
  244. package/dist/service-public.d.ts.map +1 -0
  245. package/dist/service-public.js +583 -0
  246. package/dist/service-reference-data.d.ts +272 -0
  247. package/dist/service-reference-data.d.ts.map +1 -0
  248. package/dist/service-reference-data.js +280 -0
  249. package/dist/service-rendition-wait.d.ts +38 -0
  250. package/dist/service-rendition-wait.d.ts.map +1 -0
  251. package/dist/service-rendition-wait.js +67 -0
  252. package/dist/service-reports.d.ts +37 -0
  253. package/dist/service-reports.d.ts.map +1 -0
  254. package/dist/service-reports.js +62 -0
  255. package/dist/service-settlement.d.ts +46 -0
  256. package/dist/service-settlement.d.ts.map +1 -0
  257. package/dist/service-settlement.js +185 -0
  258. package/dist/service-shared.d.ts +541 -0
  259. package/dist/service-shared.d.ts.map +1 -0
  260. package/dist/service-shared.js +764 -0
  261. package/dist/service-supplier-invoices.d.ts +871 -0
  262. package/dist/service-supplier-invoices.d.ts.map +1 -0
  263. package/dist/service-supplier-invoices.js +744 -0
  264. package/dist/service-supplier-payments.d.ts +69 -0
  265. package/dist/service-supplier-payments.d.ts.map +1 -0
  266. package/dist/service-supplier-payments.js +136 -0
  267. package/dist/service-vouchers-migration.d.ts +44 -0
  268. package/dist/service-vouchers-migration.d.ts.map +1 -0
  269. package/dist/service-vouchers-migration.js +148 -0
  270. package/dist/service-vouchers.d.ts +157 -0
  271. package/dist/service-vouchers.d.ts.map +1 -0
  272. package/dist/service-vouchers.js +191 -0
  273. package/dist/service.d.ts +6490 -0
  274. package/dist/service.d.ts.map +1 -0
  275. package/dist/service.js +29 -0
  276. package/dist/validation-billing.d.ts +2 -0
  277. package/dist/validation-billing.d.ts.map +1 -0
  278. package/dist/validation-billing.js +1 -0
  279. package/dist/validation-payments.d.ts +2 -0
  280. package/dist/validation-payments.d.ts.map +1 -0
  281. package/dist/validation-payments.js +1 -0
  282. package/dist/validation-public.d.ts +2 -0
  283. package/dist/validation-public.d.ts.map +1 -0
  284. package/dist/validation-public.js +1 -0
  285. package/dist/validation-shared.d.ts +2 -0
  286. package/dist/validation-shared.d.ts.map +1 -0
  287. package/dist/validation-shared.js +1 -0
  288. package/dist/validation-vouchers.d.ts +2 -0
  289. package/dist/validation-vouchers.d.ts.map +1 -0
  290. package/dist/validation-vouchers.js +1 -0
  291. package/dist/validation.d.ts +2 -0
  292. package/dist/validation.d.ts.map +1 -0
  293. package/dist/validation.js +1 -0
  294. 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
+ };