@happyvertical/smrt-commerce 0.57.2 → 0.57.4

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 (162) hide show
  1. package/AGENTS.md +17 -0
  2. package/README.md +130 -0
  3. package/agents/accounting-snapshot-mapper.md +11 -0
  4. package/agents/contract-billing-schedules.md +67 -0
  5. package/agents/contract-money.md +81 -0
  6. package/agents/customer-funds.md +96 -0
  7. package/agents/historical-sales.md +132 -0
  8. package/agents/money-calculator.md +36 -0
  9. package/agents/money-model.md +6 -3
  10. package/agents/observed-purchase-prices.md +37 -0
  11. package/agents/payment-allocations.md +59 -0
  12. package/agents/received-sales-contract-money.md +38 -0
  13. package/dist/accounting-snapshot.d.ts +8 -1
  14. package/dist/accounting-snapshot.d.ts.map +1 -1
  15. package/dist/allocation-integrity.d.ts.map +1 -1
  16. package/dist/billing/period-close.d.ts.map +1 -1
  17. package/dist/chunks/{ContractLineItemCollection-BZcrlrrY.js → ContractLineItemCollection-J4xtRtzE.js} +6 -6
  18. package/dist/chunks/{ContractLineItemCollection-BZcrlrrY.js.map → ContractLineItemCollection-J4xtRtzE.js.map} +1 -1
  19. package/dist/chunks/DocumentTaxCollection-C42x0YLW.js +4385 -0
  20. package/dist/chunks/DocumentTaxCollection-C42x0YLW.js.map +1 -0
  21. package/dist/chunks/HistoricalCustomerFundsCollections-B5x8gTW3.js +170 -0
  22. package/dist/chunks/HistoricalCustomerFundsCollections-B5x8gTW3.js.map +1 -0
  23. package/dist/chunks/{InvoiceCollection-cinzAqt6.js → InvoiceCollection-BIcge9Wl.js} +131 -18
  24. package/dist/chunks/InvoiceCollection-BIcge9Wl.js.map +1 -0
  25. package/dist/chunks/{TaxCodeCollection-DaBOPG9c.js → TaxCodeCollection-RWdVaheF.js} +3 -4
  26. package/dist/chunks/TaxCodeCollection-RWdVaheF.js.map +1 -0
  27. package/dist/chunks/{TaxComponentCollection-C4zxnyVu.js → TaxComponentCollection-Dt2jeWiC.js} +3 -3
  28. package/dist/chunks/{TaxComponentCollection-C4zxnyVu.js.map → TaxComponentCollection-Dt2jeWiC.js.map} +1 -1
  29. package/dist/chunks/{allocation-integrity-WmWyXhtG.js → allocation-integrity-DJ7-LY1j.js} +7 -7
  30. package/dist/chunks/{allocation-integrity-WmWyXhtG.js.map → allocation-integrity-DJ7-LY1j.js.map} +1 -1
  31. package/dist/chunks/{contract-money-EuToOghh.js → contract-money-DSRTMoXm.js} +45 -12
  32. package/dist/chunks/contract-money-DSRTMoXm.js.map +1 -0
  33. package/dist/chunks/customer-funds-internal-BQzoJXK5.js +24 -0
  34. package/dist/chunks/customer-funds-internal-BQzoJXK5.js.map +1 -0
  35. package/dist/chunks/{detect-CY9ufaFb.js → detect-DAE-_NAk.js} +5 -2
  36. package/dist/chunks/{detect-CY9ufaFb.js.map → detect-DAE-_NAk.js.map} +1 -1
  37. package/dist/chunks/historical-commerce-internal-C9ButfuH.js +18 -0
  38. package/dist/chunks/historical-commerce-internal-C9ButfuH.js.map +1 -0
  39. package/dist/chunks/{invoice-posting-C2i5dOVZ.js → invoice-posting-BeTPw3h9.js} +5 -5
  40. package/dist/chunks/invoice-posting-BeTPw3h9.js.map +1 -0
  41. package/dist/chunks/retention-accounting-Brdj9x-V.js +2323 -0
  42. package/dist/chunks/retention-accounting-Brdj9x-V.js.map +1 -0
  43. package/dist/chunks/retention-accounting-internal-CBQRvd74.js +17 -0
  44. package/dist/chunks/retention-accounting-internal-CBQRvd74.js.map +1 -0
  45. package/dist/chunks/{tenant-money-barrier-6TlqUeVj.js → tenant-money-barrier-DoF6lR8h.js} +5 -5
  46. package/dist/chunks/{tenant-money-barrier-6TlqUeVj.js.map → tenant-money-barrier-DoF6lR8h.js.map} +1 -1
  47. package/dist/chunks/types-Bbm0Xflc.js.map +1 -1
  48. package/dist/collections/DocumentTaxCollection.d.ts +4 -1
  49. package/dist/collections/DocumentTaxCollection.d.ts.map +1 -1
  50. package/dist/collections/HistoricalCustomerFundsCollections.d.ts +10 -0
  51. package/dist/collections/HistoricalCustomerFundsCollections.d.ts.map +1 -0
  52. package/dist/collections/HistoricalSalesLineCollection.d.ts +6 -0
  53. package/dist/collections/HistoricalSalesLineCollection.d.ts.map +1 -0
  54. package/dist/collections/PaymentAllocationCollection.d.ts +2 -6
  55. package/dist/collections/PaymentAllocationCollection.d.ts.map +1 -1
  56. package/dist/collections/PurchasePriceObservationCollection.d.ts +6 -0
  57. package/dist/collections/PurchasePriceObservationCollection.d.ts.map +1 -0
  58. package/dist/collections/index.d.ts +1 -0
  59. package/dist/collections/index.d.ts.map +1 -1
  60. package/dist/contract-billing-schedule.d.ts +83 -0
  61. package/dist/contract-billing-schedule.d.ts.map +1 -0
  62. package/dist/contract-money.d.ts +2 -0
  63. package/dist/contract-money.d.ts.map +1 -1
  64. package/dist/customer-funds-internal.d.ts +6 -0
  65. package/dist/customer-funds-internal.d.ts.map +1 -0
  66. package/dist/customer-funds.d.ts +108 -0
  67. package/dist/customer-funds.d.ts.map +1 -0
  68. package/dist/document-money.d.ts +11 -0
  69. package/dist/document-money.d.ts.map +1 -1
  70. package/dist/historical-commerce-internal.d.ts +3 -0
  71. package/dist/historical-commerce-internal.d.ts.map +1 -0
  72. package/dist/historical-customer-funds-types.d.ts +113 -0
  73. package/dist/historical-customer-funds-types.d.ts.map +1 -0
  74. package/dist/historical-customer-funds.d.ts +23 -0
  75. package/dist/historical-customer-funds.d.ts.map +1 -0
  76. package/dist/historical-funds-internal.d.ts +5 -0
  77. package/dist/historical-funds-internal.d.ts.map +1 -0
  78. package/dist/historical-sales-money.d.ts +22 -0
  79. package/dist/historical-sales-money.d.ts.map +1 -0
  80. package/dist/historical-sales-reports.d.ts +31 -0
  81. package/dist/historical-sales-reports.d.ts.map +1 -0
  82. package/dist/historical-sales-types.d.ts +106 -0
  83. package/dist/historical-sales-types.d.ts.map +1 -0
  84. package/dist/historical-sales-validation.d.ts +12 -0
  85. package/dist/historical-sales-validation.d.ts.map +1 -0
  86. package/dist/index.d.ts +21 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +1685 -1640
  89. package/dist/index.js.map +1 -1
  90. package/dist/invoice-accounting-sync.d.ts +0 -1
  91. package/dist/invoice-accounting-sync.d.ts.map +1 -1
  92. package/dist/invoice-mcp.d.ts.map +1 -1
  93. package/dist/invoice-posting.d.ts.map +1 -1
  94. package/dist/invoice-retention.d.ts +16 -0
  95. package/dist/invoice-retention.d.ts.map +1 -0
  96. package/dist/invoices.d.ts +2 -0
  97. package/dist/invoices.d.ts.map +1 -1
  98. package/dist/invoices.js +2 -2
  99. package/dist/manifest.json +7563 -4419
  100. package/dist/models/Contract.d.ts +14 -0
  101. package/dist/models/Contract.d.ts.map +1 -1
  102. package/dist/models/DocumentTax.d.ts +2 -0
  103. package/dist/models/DocumentTax.d.ts.map +1 -1
  104. package/dist/models/HistoricalCustomerFunds.d.ts +36 -0
  105. package/dist/models/HistoricalCustomerFunds.d.ts.map +1 -0
  106. package/dist/models/HistoricalCustomerFundsAllocation.d.ts +32 -0
  107. package/dist/models/HistoricalCustomerFundsAllocation.d.ts.map +1 -0
  108. package/dist/models/HistoricalSalesLine.d.ts +29 -0
  109. package/dist/models/HistoricalSalesLine.d.ts.map +1 -0
  110. package/dist/models/Invoice.d.ts +10 -1
  111. package/dist/models/Invoice.d.ts.map +1 -1
  112. package/dist/models/Payment.d.ts +7 -1
  113. package/dist/models/Payment.d.ts.map +1 -1
  114. package/dist/models/PaymentAllocation.d.ts +3 -1
  115. package/dist/models/PaymentAllocation.d.ts.map +1 -1
  116. package/dist/models/PaymentIntent.d.ts.map +1 -1
  117. package/dist/models/Payout.d.ts.map +1 -1
  118. package/dist/models/PurchasePriceObservation.d.ts +15 -0
  119. package/dist/models/PurchasePriceObservation.d.ts.map +1 -0
  120. package/dist/models/TaxCode.d.ts +1 -1
  121. package/dist/models/TaxCode.d.ts.map +1 -1
  122. package/dist/models/index.d.ts +1 -0
  123. package/dist/models/index.d.ts.map +1 -1
  124. package/dist/money.d.ts +5 -1
  125. package/dist/money.d.ts.map +1 -1
  126. package/dist/money.js +62 -15
  127. package/dist/money.js.map +1 -1
  128. package/dist/observed-purchase-price-internal.d.ts +3 -0
  129. package/dist/observed-purchase-price-internal.d.ts.map +1 -0
  130. package/dist/observed-purchase-price.d.ts +71 -0
  131. package/dist/observed-purchase-price.d.ts.map +1 -0
  132. package/dist/received-purchase-money.d.ts +87 -0
  133. package/dist/received-purchase-money.d.ts.map +1 -0
  134. package/dist/recipes.d.ts +4 -1
  135. package/dist/recipes.d.ts.map +1 -1
  136. package/dist/retention-accounting-internal.d.ts +3 -0
  137. package/dist/retention-accounting-internal.d.ts.map +1 -0
  138. package/dist/retention-accounting.d.ts +164 -0
  139. package/dist/retention-accounting.d.ts.map +1 -0
  140. package/dist/retention-money.d.ts +43 -0
  141. package/dist/retention-money.d.ts.map +1 -0
  142. package/dist/smrt-knowledge.json +1866 -113
  143. package/dist/tax/cookbook.d.ts.map +1 -1
  144. package/dist/tax-summary.d.ts +1 -0
  145. package/dist/tax-summary.d.ts.map +1 -1
  146. package/dist/types/index.d.ts +20 -1
  147. package/dist/types/index.d.ts.map +1 -1
  148. package/package.json +16 -16
  149. package/dist/chunks/CustomerCollection-BIPH2ZUx.js +0 -99
  150. package/dist/chunks/CustomerCollection-BIPH2ZUx.js.map +0 -1
  151. package/dist/chunks/DocumentTaxCollection-DDzlywMW.js +0 -1666
  152. package/dist/chunks/DocumentTaxCollection-DDzlywMW.js.map +0 -1
  153. package/dist/chunks/InvoiceCollection-cinzAqt6.js.map +0 -1
  154. package/dist/chunks/TaxCodeCollection-DaBOPG9c.js.map +0 -1
  155. package/dist/chunks/VendorCollection-D6ZcUYy8.js +0 -100
  156. package/dist/chunks/VendorCollection-D6ZcUYy8.js.map +0 -1
  157. package/dist/chunks/calculations-C9daQLsh.js +0 -82
  158. package/dist/chunks/calculations-C9daQLsh.js.map +0 -1
  159. package/dist/chunks/contract-money-EuToOghh.js.map +0 -1
  160. package/dist/chunks/document-money-DV5t4vlK.js +0 -1186
  161. package/dist/chunks/document-money-DV5t4vlK.js.map +0 -1
  162. package/dist/chunks/invoice-posting-C2i5dOVZ.js.map +0 -1
@@ -0,0 +1,2323 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
+ import { Y as InvoiceLineItemCollection, t as DocumentTaxCollection, x as lockAndReloadInvoice } from "./DocumentTaxCollection-C42x0YLW.js";
3
+ import { d as PaymentStatus, s as InvoiceStatus, u as PaymentMethod } from "./types-Bbm0Xflc.js";
4
+ import { o as minorToMajorUnits } from "./units-8ElbjVZK.js";
5
+ import { n as withRetentionAccountingAuthority, t as hasRetentionAccountingAuthority } from "./retention-accounting-internal-CBQRvd74.js";
6
+ import { i as claimAllocationParent, n as allocationTotal, r as assertAllocationParent, t as allocationOwner } from "./allocation-integrity-DJ7-LY1j.js";
7
+ import { n as customerFundsState, t as assertCustomerFundsWrite } from "./customer-funds-internal-BQzoJXK5.js";
8
+ import { MoneyValidationError, assertIntegerMinorUnits, calculateMoneyDocument } from "../money.js";
9
+ import { t as HistoricalCustomerFundsAllocationCollection } from "./HistoricalCustomerFundsCollections-B5x8gTW3.js";
10
+ import { n as calculateAvailableRefund } from "./credit-allocation-yspz0Lab.js";
11
+ import { _ as withDocumentMoneyTransaction, m as isMoneyTransaction, p as DOCUMENT_MONEY_WRITE } from "./detect-DAE-_NAk.js";
12
+ import { c as validateRetentionEvidence, o as calculateInvoicePayable, s as parseInvoiceRetention, t as InvoiceCollection } from "./InvoiceCollection-BIcge9Wl.js";
13
+ import { t as lockTenantMoneyRuntime } from "./tenant-money-barrier-DoF6lR8h.js";
14
+ import { SmrtCollection, SmrtObject, crossPackageRef, field, foreignKey, isPostgresDatabase, smrt } from "@happyvertical/smrt-core";
15
+ import { TenantScoped, getTenantId, queryGlobal, queryWithGlobals, tenantId } from "@happyvertical/smrt-tenancy";
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ import { prepareQuickBooksCreditMemoRequest, prepareQuickBooksInvoiceRequest, prepareQuickBooksReceivableRequest, requireCreditMemoOperations } from "@happyvertical/accounting";
18
+ //#region src/models/Payment.ts
19
+ var __defProp$1 = Object.defineProperty;
20
+ var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
21
+ var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, {
22
+ enumerable: true,
23
+ configurable: true,
24
+ writable: true,
25
+ value
26
+ }) : obj[key] = value;
27
+ var __decorateClass$1 = (decorators, target, key, kind) => {
28
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
29
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
30
+ if (kind && result) __defProp$1(target, key, result);
31
+ return result;
32
+ };
33
+ var __publicField$1 = (obj, key, value) => __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
34
+ var PAYMENT_STATUS_TRANSITIONS = {
35
+ [PaymentStatus.PENDING]: [
36
+ PaymentStatus.COMPLETED,
37
+ PaymentStatus.FAILED,
38
+ PaymentStatus.CANCELLED
39
+ ],
40
+ [PaymentStatus.COMPLETED]: [PaymentStatus.REFUNDED],
41
+ [PaymentStatus.FAILED]: [],
42
+ [PaymentStatus.REFUNDED]: [],
43
+ [PaymentStatus.CANCELLED]: []
44
+ };
45
+ var loadedPaymentStatus = /* @__PURE__ */ new WeakMap();
46
+ var settlementInProgress = /* @__PURE__ */ new WeakSet();
47
+ var recordingInProgress = /* @__PURE__ */ new WeakSet();
48
+ var Payment = class extends SmrtObject {
49
+ /** Incoming settlement or outgoing customer refund. Amount stays positive. */
50
+ kind = "payment";
51
+ customerFundsJson = "";
52
+ retentionAccountingBindingJson = "";
53
+ tenantId = null;
54
+ contractId = null;
55
+ customerId = null;
56
+ amount = 0;
57
+ currency = "USD";
58
+ method = PaymentMethod.BANK_TRANSFER;
59
+ status = PaymentStatus.PENDING;
60
+ /**
61
+ * External transaction ID (from payment processor)
62
+ */
63
+ transactionId = "";
64
+ reference = "";
65
+ journalId = "";
66
+ paidAt = null;
67
+ notes = "";
68
+ /**
69
+ * External ID in accounting provider (e.g., QBO payment ID)
70
+ */
71
+ externalId = "";
72
+ /**
73
+ * Accounting provider name ('quickbooks' | 'stripe' | 'paypal' | etc.)
74
+ */
75
+ externalProvider = "";
76
+ /**
77
+ * When payment was last synced to provider
78
+ */
79
+ syncedAt = null;
80
+ /**
81
+ * Stable identifier of the `PaymentBackend` adapter that served this
82
+ * payment — e.g. `base-usdc`, `solana-usdc`, `btc`, `stripe`, `paypal`.
83
+ *
84
+ * Distinct from {@link externalProvider}: `backendId` names the SMRT
85
+ * payment-rail adapter, while `externalProvider` names a downstream
86
+ * accounting destination (QuickBooks, Stripe-the-accounting-source).
87
+ * The same Stripe payment will have `backendId: 'stripe'` AND
88
+ * `externalProvider: 'stripe'`; a crypto payment synced to QBO will
89
+ * have `backendId: 'base-usdc'` and `externalProvider: 'quickbooks'`.
90
+ */
91
+ backendId = "";
92
+ /**
93
+ * Chain transaction hash or backend aggregator's reference id. For
94
+ * on-chain payments this is the tx hash (`0x...` on EVM, base58 sig on
95
+ * Solana, txid on Bitcoin); for fiat-rail backends it's the gateway's
96
+ * own reference. Kept distinct from {@link transactionId} so consumers
97
+ * that already populate `transactionId` with a provider-internal id
98
+ * don't have to overload it.
99
+ */
100
+ backendTxRef = "";
101
+ /**
102
+ * The amount the backend actually moved, in the native currency's own
103
+ * **integer minor units** — satoshis for BTC, cents for a fiat rail. For
104
+ * stablecoin rails this typically equals `amount`; for volatile-currency
105
+ * rails it's the satoshi/wei figure the chain recorded, independent of any
106
+ * USD valuation.
107
+ *
108
+ * Fresh PostgreSQL and DuckDB INTEGER columns materialize as BIGINT. SMRT
109
+ * rejects values outside JavaScript's safe-integer range during hydration;
110
+ * existing PostgreSQL `int4` columns require the explicit widening in #2424.
111
+ */
112
+ nativeAmount = 0;
113
+ /**
114
+ * Code identifying the native currency `nativeAmount` is denominated
115
+ * in. Mirrors the `backendId` namespacing convention — `USDC-base`,
116
+ * `BTC`, `ETH`, `USD-stripe`. Empty string means "use {@link currency}"
117
+ * (i.e. the payment was already quoted and settled in the same
118
+ * currency).
119
+ */
120
+ nativeCurrency = "";
121
+ /**
122
+ * USD valuation of the payment at the moment the price was quoted to
123
+ * the buyer (typically the moment a `PaymentIntent` was issued), in
124
+ * **integer minor units** (US cents). A default of `0` means no USD-quote
125
+ * snapshot was taken (the payment was already USD-denominated or the
126
+ * backend doesn't require drift accounting).
127
+ *
128
+ * This is money describing the same funds as {@link Payment.amount}, so it
129
+ * converts with it — a dollars-here / cents-there split would make
130
+ * {@link Payment.usdDrift} meaningless (#2401).
131
+ */
132
+ usdAtQuote = 0;
133
+ /**
134
+ * USD valuation of the payment at the moment it was confirmed on the
135
+ * backend (chain confirmation, gateway settlement, etc.), in integer minor
136
+ * units (US cents). The delta between {@link usdAtQuote} and
137
+ * `usdAtConfirmation` is the USD drift the operator absorbs (positive or
138
+ * negative) when accepting payment in a volatile native currency (#2401).
139
+ */
140
+ usdAtConfirmation = 0;
141
+ constructor(options = {}) {
142
+ super(options);
143
+ if (options.kind !== void 0) this.kind = options.kind;
144
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
145
+ if (options.contractId !== void 0) this.contractId = options.contractId;
146
+ if (options.customerId !== void 0) this.customerId = options.customerId;
147
+ if (options.amount !== void 0) this.amount = options.amount;
148
+ if (options.currency !== void 0) this.currency = options.currency;
149
+ if (options.method !== void 0) this.method = options.method;
150
+ if (options.status !== void 0) this.status = options.status;
151
+ if (options.transactionId !== void 0) this.transactionId = options.transactionId;
152
+ if (options.reference !== void 0) this.reference = options.reference;
153
+ if (options.customerFundsJson !== void 0) this.customerFundsJson = options.customerFundsJson;
154
+ if (options.journalId !== void 0) this.journalId = options.journalId;
155
+ if (options.paidAt !== void 0) this.paidAt = options.paidAt;
156
+ if (options.notes !== void 0) this.notes = options.notes;
157
+ if (options.externalId !== void 0) this.externalId = options.externalId;
158
+ if (options.externalProvider !== void 0) this.externalProvider = options.externalProvider;
159
+ if (options.syncedAt !== void 0) this.syncedAt = options.syncedAt;
160
+ if (options.backendId !== void 0) this.backendId = options.backendId;
161
+ if (options.backendTxRef !== void 0) this.backendTxRef = options.backendTxRef;
162
+ if (options.nativeAmount !== void 0) this.nativeAmount = options.nativeAmount;
163
+ if (options.nativeCurrency !== void 0) this.nativeCurrency = options.nativeCurrency;
164
+ if (options.usdAtQuote !== void 0) this.usdAtQuote = options.usdAtQuote;
165
+ if (options.usdAtConfirmation !== void 0) this.usdAtConfirmation = options.usdAtConfirmation;
166
+ }
167
+ /**
168
+ * Capture the persisted status the row was loaded with, so the save-time
169
+ * transition guard can reject illegal status flips made via raw field
170
+ * assignment (mass-assignment on the generated update route, a stale caller,
171
+ * etc.). Freshly-constructed (not-yet-saved) payments have no prior status.
172
+ */
173
+ async initialize() {
174
+ await super.initialize();
175
+ if (await this.isSaved()) loadedPaymentStatus.set(this, this.status);
176
+ return this;
177
+ }
178
+ /**
179
+ * Save-time state-machine guard (S5 audit #1390).
180
+ *
181
+ * `status` is mass-assignable on the generated update/create routes, and a
182
+ * COMPLETED Payment is treated as settlement proof downstream — e.g.
183
+ * {@link PaymentIntent}'s PAID verification trusts a COMPLETED Payment row.
184
+ * A forged `status: 'completed'` (with arbitrary amounts and no journal)
185
+ * would therefore satisfy that check without any money having moved.
186
+ *
187
+ * This guard enforces two things:
188
+ * - **Transitions must be legal** per {@link PAYMENT_STATUS_TRANSITIONS}.
189
+ * - **Reaching COMPLETED requires the verified settlement path — on
190
+ * creation AND on update.** Only `recordPayment()` (which posts a
191
+ * balanced journal and links `journalId`) may write a Payment into
192
+ * COMPLETED; it announces itself via {@link settlementInProgress}. A raw
193
+ * `status: 'completed'` is rejected whether the row is brand-new (a
194
+ * GENESIS `create({ status: 'completed' })`) or already persisted — that
195
+ * is the exact path a forged COMPLETED would take to satisfy
196
+ * PaymentIntent's PAID verification without any money having moved (codex
197
+ * HIGH#1, #1390 round 5). There is deliberately NO import/fixture
198
+ * carve-out: a COMPLETED Payment is settlement proof downstream, so it is
199
+ * only ever reachable through `recordPayment()`, on every surface
200
+ * (REST/MCP/CLI/direct). Fixtures/migrations that need a completed payment
201
+ * must drive it through `recordPayment()` (or start non-COMPLETED).
202
+ */
203
+ async save() {
204
+ return this.withTransaction(async () => {
205
+ if (this.kind !== "payment" && this.kind !== "refund") throw new Error("Payment kind must be payment or refund");
206
+ await claimAllocationParent(this, "payment");
207
+ assertIntegerMinorUnits("Payment", this.id || "<new>", [
208
+ ["amount", this.amount],
209
+ ["nativeAmount", this.nativeAmount],
210
+ ["usdAtQuote", this.usdAtQuote],
211
+ ["usdAtConfirmation", this.usdAtConfirmation]
212
+ ]);
213
+ const priorRow = await this.loadPersistedRow();
214
+ if (this.customerFundsJson || priorRow?.customer_funds_json) assertCustomerFundsWrite(this);
215
+ const retainedBinding = this.retentionAccountingBindingJson || String(priorRow?.retention_accounting_binding_json ?? "");
216
+ if (retainedBinding && !hasRetentionAccountingAuthority(this.tenantId, JSON.parse(retainedBinding).invoiceId)) throw new Error("Provider-recorded refund is immutable; use an explicit reversal workflow");
217
+ const prior = priorRow && priorRow.status != null ? priorRow.status : loadedPaymentStatus.get(this);
218
+ this.assertStatusTransition(prior);
219
+ if (this.status === PaymentStatus.COMPLETED && prior !== PaymentStatus.COMPLETED && !settlementInProgress.has(this)) throw new Error(`Payment ${this.id || "<new>"}: cannot set status to COMPLETED via raw assignment \u2014 a Payment is only completed through recordPayment(), which posts a balanced settlement journal. Use recordPayment() instead of setting status directly.`);
220
+ if (priorRow && (priorRow.status === PaymentStatus.COMPLETED || priorRow.status === PaymentStatus.REFUNDED)) this.assertSettledAmountsUnchanged(priorRow);
221
+ try {
222
+ await assertAllocationParent(this, "payment");
223
+ const result = await super.save();
224
+ loadedPaymentStatus.set(this, this.status);
225
+ return result;
226
+ } finally {
227
+ settlementInProgress.delete(this);
228
+ }
229
+ });
230
+ }
231
+ /** Managed receipts and refunds retain immutable cash provenance. */
232
+ async delete(options = {}) {
233
+ const persisted = await this.loadPersistedRow();
234
+ if (this.retentionAccountingBindingJson || persisted?.retention_accounting_binding_json) throw new Error("Provider-recorded refund history cannot be deleted");
235
+ if (this.customerFundsJson || persisted?.customer_funds_json) throw new Error("Customer funds cash history cannot be deleted");
236
+ return super.delete(options);
237
+ }
238
+ /**
239
+ * Reject an illegal status flip. Compares the about-to-be-written status
240
+ * against the status the row was loaded with. No-op transitions (status
241
+ * unchanged) and brand-new rows (no prior) are always allowed — the
242
+ * COMPLETED-specific settlement requirement is enforced separately in
243
+ * {@link save}.
244
+ */
245
+ assertStatusTransition(prior) {
246
+ if (prior === void 0) return;
247
+ if (prior === this.status) return;
248
+ if (!(PAYMENT_STATUS_TRANSITIONS[prior] ?? []).includes(this.status)) throw new Error(`Payment ${this.id}: illegal status transition '${prior}' \u2192 '${this.status}'. Use the guarded helpers (recordPayment / markFailed / cancel).`);
249
+ }
250
+ /**
251
+ * Load the AUTHORITATIVE persisted row (S5 audit #1390 round 4, codex HIGH#1;
252
+ * extended round 6 to carry the full row, not just `status`). The WeakMap is
253
+ * only populated when {@link initialize} loaded the row from the DB; it is
254
+ * empty for an instance built via
255
+ * `collection.create({ id: <existing>, _skipLoad: true })` — the upsert path
256
+ * that lets a caller write onto an existing row without hydrating it. Trusting
257
+ * an empty WeakMap there would treat the write as a brand-new row and skip the
258
+ * transition guard entirely (a poisonable prior-state).
259
+ *
260
+ * So when this instance carries an `id`, read the persisted row straight from
261
+ * the database and use it as the prior — a create-onto-existing is an update.
262
+ * Returns `undefined` when no row exists (truly new), so callers fall back to
263
+ * the WeakMap (which is also empty then). Settled-amount columns are
264
+ * snake_case (`native_amount`, etc.); the `status` column is single-word.
265
+ */
266
+ async loadPersistedRow() {
267
+ if (!this.id) return void 0;
268
+ try {
269
+ return await this.db.get(this.tableName, { id: this.id }) ?? void 0;
270
+ } catch {
271
+ return;
272
+ }
273
+ }
274
+ /**
275
+ * Reject any change to the settled monetary fields of an already-COMPLETED
276
+ * Payment (S5 audit #1390 round 6 — codex ROOT INSIGHT). Mirrors the
277
+ * PaymentIntent PAID backing-field freeze. A COMPLETED Payment's economic
278
+ * identity is settled: the `amount` was posted into a balanced journal and is
279
+ * the figure PaymentIntent's PAID verification reconciles against, so it (and
280
+ * the native/USD valuation fields that describe the same money) must not
281
+ * drift. The only legal status move out of COMPLETED is REFUNDED, which
282
+ * reverses the funds without rewriting how much they were. Compared against
283
+ * the AUTHORITATIVE persisted row so the freeze holds on every surface, even
284
+ * for an un-hydrated create-onto-existing upsert.
285
+ */
286
+ assertSettledAmountsUnchanged(priorRow) {
287
+ const frozen = [
288
+ [
289
+ "journalId",
290
+ String(priorRow.journal_id ?? ""),
291
+ this.journalId ?? ""
292
+ ],
293
+ [
294
+ "paidAt",
295
+ priorRow.paid_at == null ? "" : new Date(priorRow.paid_at).getTime(),
296
+ this.paidAt == null ? "" : new Date(this.paidAt).getTime()
297
+ ],
298
+ [
299
+ "kind",
300
+ String(priorRow.kind ?? "payment"),
301
+ this.kind
302
+ ],
303
+ [
304
+ "amount",
305
+ Number(priorRow.amount ?? 0),
306
+ Number(this.amount ?? 0)
307
+ ],
308
+ [
309
+ "currency",
310
+ String(priorRow.currency ?? ""),
311
+ String(this.currency ?? "")
312
+ ],
313
+ [
314
+ "nativeAmount",
315
+ Number(priorRow.native_amount ?? 0),
316
+ Number(this.nativeAmount ?? 0)
317
+ ],
318
+ [
319
+ "nativeCurrency",
320
+ String(priorRow.native_currency ?? ""),
321
+ String(this.nativeCurrency ?? "")
322
+ ],
323
+ [
324
+ "usdAtQuote",
325
+ Number(priorRow.usd_at_quote ?? 0),
326
+ Number(this.usdAtQuote ?? 0)
327
+ ],
328
+ [
329
+ "usdAtConfirmation",
330
+ Number(priorRow.usd_at_confirmation ?? 0),
331
+ Number(this.usdAtConfirmation ?? 0)
332
+ ]
333
+ ];
334
+ for (const [field2, prior, next] of frozen) if (prior !== next) throw new Error(`Payment ${this.id}: '${field2}' is frozen once the payment is COMPLETED (was '${prior}', got '${next}'). A settled payment's monetary fields cannot be mutated \u2014 issue a refund instead.`);
335
+ }
336
+ /**
337
+ * USD drift between quote time and confirmation time — what the
338
+ * operator gained (positive) or lost (negative) by accepting a
339
+ * volatile-currency payment. Returns `0` when either side of the
340
+ * comparison is missing or zero, so callers don't have to special-case
341
+ * fiat-rail / stablecoin payments.
342
+ */
343
+ usdDrift() {
344
+ if (!this.usdAtQuote || !this.usdAtConfirmation) return 0;
345
+ return this.usdAtConfirmation - this.usdAtQuote;
346
+ }
347
+ /**
348
+ * Check if payment is pending
349
+ */
350
+ isPending() {
351
+ return this.status === PaymentStatus.PENDING;
352
+ }
353
+ /**
354
+ * Check if payment is completed
355
+ */
356
+ isCompleted() {
357
+ return this.status === PaymentStatus.COMPLETED;
358
+ }
359
+ /**
360
+ * Check if payment is refunded
361
+ */
362
+ isRefunded() {
363
+ return this.status === PaymentStatus.REFUNDED;
364
+ }
365
+ /**
366
+ * Record the payment and create a balanced journal entry.
367
+ *
368
+ * Creates a journal entry in smrt-ledgers:
369
+ * - Debit: Cash/Bank account (assets increase)
370
+ * - Credit: Accounts Receivable (receivables decrease)
371
+ *
372
+ * Both sides carry `this.amount`, so the journal posted from commerce is
373
+ * denominated in **integer minor units** like the rest of this package
374
+ * (#2401). smrt-ledgers is unit-agnostic — its `BALANCE_EPSILON` only checks
375
+ * that debits equal credits — and integer entries balance exactly, so this is
376
+ * strictly tighter than the major-unit journals commerce used to post.
377
+ *
378
+ * @param options - Ledger and account configuration
379
+ * @returns The created journal
380
+ *
381
+ * @example
382
+ * ```typescript
383
+ * const journal = await payment.recordPayment({
384
+ * ledgerId: ledger.id,
385
+ * receivablesAccountId: arAccount.id,
386
+ * cashAccountId: bankAccount.id
387
+ * });
388
+ * console.log(`Created journal: ${journal.number}`);
389
+ * ```
390
+ */
391
+ async recordPayment(options) {
392
+ if (recordingInProgress.has(this)) throw new Error("Payment recording is already in progress");
393
+ if (this.status === PaymentStatus.COMPLETED) throw new Error("Payment already recorded");
394
+ const previous = {
395
+ status: this.status,
396
+ journalId: this.journalId,
397
+ paidAt: this.paidAt,
398
+ loadedStatus: loadedPaymentStatus.get(this)
399
+ };
400
+ recordingInProgress.add(this);
401
+ try {
402
+ await this.withTransaction(async () => {
403
+ if (!this.isPersisted) await this.save();
404
+ if (!this.updated_at) throw new Error("Payment revision is missing; reload before recording");
405
+ await this.claimRevision(this.updated_at);
406
+ const persisted = await this.db.get(this.tableName, { id: this.id });
407
+ if (!persisted) throw new Error("Claimed payment could not be loaded");
408
+ if (persisted.status === PaymentStatus.COMPLETED) throw new Error("Payment already recorded");
409
+ await this.postPaymentInTransaction(options);
410
+ });
411
+ } catch (error) {
412
+ this.status = previous.status;
413
+ this.journalId = previous.journalId;
414
+ this.paidAt = previous.paidAt;
415
+ if (previous.loadedStatus === void 0) loadedPaymentStatus.delete(this);
416
+ else loadedPaymentStatus.set(this, previous.loadedStatus);
417
+ throw error;
418
+ } finally {
419
+ recordingInProgress.delete(this);
420
+ settlementInProgress.delete(this);
421
+ }
422
+ const { JournalCollection } = await import("@happyvertical/smrt-ledgers");
423
+ const journal = await (await JournalCollection.create({
424
+ ...this.options,
425
+ db: this.db
426
+ })).get({ id: this.journalId });
427
+ if (!journal) throw new Error("Recorded payment journal could not be loaded");
428
+ return journal;
429
+ }
430
+ /** Settle an outgoing refund through the same guarded journal transaction. */
431
+ async recordRefund(options) {
432
+ if (this.kind !== "refund") throw new Error("recordRefund requires Payment.kind=refund");
433
+ return this.recordPayment(options);
434
+ }
435
+ /** Post and complete only inside recordPayment's claimed transaction. */
436
+ async postPaymentInTransaction(options) {
437
+ if (this.status === PaymentStatus.COMPLETED) throw new Error("Payment already recorded");
438
+ if (this.amount <= 0) throw new Error("Payment amount must be positive");
439
+ const funds = customerFundsState(this.customerFundsJson);
440
+ const explicitDate = options.effectiveOn;
441
+ if (explicitDate !== void 0 && (typeof explicitDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(explicitDate) || !Number.isFinite(Date.parse(explicitDate)) || new Date(explicitDate).toISOString().slice(0, 10) !== explicitDate)) throw new Error("Settlement effectiveOn must be a calendar date");
442
+ if (funds) {
443
+ if (explicitDate !== void 0 && explicitDate !== funds.effectiveOn) throw new Error("Settlement date differs from reviewed customer funds");
444
+ assertCustomerFundsWrite(this);
445
+ if (options.cashAccountId !== funds.policy.cashAccountId || options.receivablesAccountId !== funds.policy.receivablesAccountId) throw new Error("Customer funds settlement accounts differ from reviewed policy");
446
+ }
447
+ const creditAccountId = funds?.policy.mode === "deposit-liability" ? funds.policy.liabilityAccountId : options.receivablesAccountId;
448
+ const effectiveOn = funds?.effectiveOn ?? explicitDate;
449
+ const effectiveDate = effectiveOn ? /* @__PURE__ */ new Date(`${effectiveOn}T12:00:00Z`) : /* @__PURE__ */ new Date();
450
+ const { JournalCollection } = await import("@happyvertical/smrt-ledgers");
451
+ const journal = await (await JournalCollection.create(this.options)).create({
452
+ date: effectiveDate,
453
+ description: this.kind === "refund" ? `Customer refund for contract ${this.contractId}` : `Payment received for contract ${this.contractId}`,
454
+ sourceModule: "smrt-commerce",
455
+ sourceRef: this.id
456
+ });
457
+ await journal.save();
458
+ await journal.addEntry({
459
+ accountId: options.cashAccountId,
460
+ currency: this.currency,
461
+ debit: this.kind === "payment" ? this.amount : 0,
462
+ credit: this.kind === "refund" ? this.amount : 0,
463
+ memo: `${this.kind === "refund" ? "Refund" : "Payment"} ${this.reference || this.id}`
464
+ });
465
+ await journal.addEntry({
466
+ accountId: creditAccountId,
467
+ currency: this.currency,
468
+ debit: this.kind === "refund" ? this.amount : 0,
469
+ credit: this.kind === "payment" ? this.amount : 0,
470
+ memo: `${this.kind === "refund" ? "Refund" : "Payment"} ${this.reference || this.id}`
471
+ });
472
+ await journal.post();
473
+ if (!journal.id) throw new Error("Payment.recordPayment: journal was posted but has no id");
474
+ this.journalId = journal.id;
475
+ this.status = PaymentStatus.COMPLETED;
476
+ this.paidAt = effectiveDate;
477
+ settlementInProgress.add(this);
478
+ await this.save();
479
+ return journal;
480
+ }
481
+ /**
482
+ * Get the linked journal entry (if recorded)
483
+ */
484
+ async getJournal() {
485
+ if (!this.journalId) return null;
486
+ try {
487
+ const { JournalCollection } = await import("@happyvertical/smrt-ledgers");
488
+ return await (await JournalCollection.create(this.options)).get({ id: this.journalId });
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+ /**
494
+ * Mark payment as failed
495
+ */
496
+ markFailed(reason) {
497
+ this.status = PaymentStatus.FAILED;
498
+ if (reason) this.notes = `${this.notes ? `${this.notes}
499
+ ` : ""}Failed: ${reason}`;
500
+ }
501
+ /**
502
+ * Cancel the payment
503
+ */
504
+ cancel() {
505
+ if (this.status === PaymentStatus.COMPLETED) throw new Error("Cannot cancel a completed payment. Use refund instead.");
506
+ this.status = PaymentStatus.CANCELLED;
507
+ }
508
+ };
509
+ __publicField$1(Payment, "__smrtPackage__", "@happyvertical/smrt-commerce");
510
+ __decorateClass$1([field({ type: "text" })], Payment.prototype, "customerFundsJson", 2);
511
+ __decorateClass$1([field({ sensitive: true })], Payment.prototype, "retentionAccountingBindingJson", 2);
512
+ __decorateClass$1([tenantId({ nullable: true })], Payment.prototype, "tenantId", 2);
513
+ __decorateClass$1([field({ description: "The order or agreement this payment is for, if any." }), foreignKey("SalesContract", { validateSubtype: true })], Payment.prototype, "contractId", 2);
514
+ __decorateClass$1([field({ description: "The customer who paid." }), foreignKey("Customer")], Payment.prototype, "customerId", 2);
515
+ __decorateClass$1([field({
516
+ description: "How much was paid.",
517
+ ui: { format: "money" }
518
+ })], Payment.prototype, "amount", 2);
519
+ __decorateClass$1([field({
520
+ description: "The currency of the payment.",
521
+ ui: { widget: "currency" }
522
+ })], Payment.prototype, "currency", 2);
523
+ __decorateClass$1([field({ description: "How it was paid: cash, check, card, bank transfer or other." })], Payment.prototype, "method", 2);
524
+ __decorateClass$1([field({ description: "Whether the payment is pending, completed, failed or refunded." })], Payment.prototype, "status", 2);
525
+ __decorateClass$1([field({ description: "A reference note, such as a check number." })], Payment.prototype, "reference", 2);
526
+ __decorateClass$1([crossPackageRef("@happyvertical/smrt-ledgers:Journal")], Payment.prototype, "journalId", 2);
527
+ __decorateClass$1([field({ description: "When the money was received." })], Payment.prototype, "paidAt", 2);
528
+ __decorateClass$1([field({
529
+ description: "Anything worth remembering about this payment.",
530
+ ui: { widget: "textarea" }
531
+ })], Payment.prototype, "notes", 2);
532
+ Payment = __decorateClass$1([TenantScoped({ mode: "optional" }), smrt({
533
+ api: {
534
+ include: [
535
+ "list",
536
+ "get",
537
+ "create",
538
+ "update"
539
+ ],
540
+ writable: [
541
+ "kind",
542
+ "contractId",
543
+ "customerId",
544
+ "amount",
545
+ "currency",
546
+ "method",
547
+ "transactionId",
548
+ "reference",
549
+ "notes",
550
+ "externalId",
551
+ "externalProvider",
552
+ "backendId",
553
+ "backendTxRef",
554
+ "nativeAmount",
555
+ "nativeCurrency",
556
+ "usdAtQuote",
557
+ "usdAtConfirmation"
558
+ ]
559
+ },
560
+ mcp: { include: ["list", "get"] },
561
+ cli: { skipApiCheck: true }
562
+ })], Payment);
563
+ //#endregion
564
+ //#region src/collections/PaymentCollection.ts
565
+ var PaymentCollection_exports = /* @__PURE__ */ __exportAll({ PaymentCollection: () => PaymentCollection });
566
+ var PaymentCollection = class extends SmrtCollection {
567
+ static _itemClass = Payment;
568
+ /**
569
+ * Find payments by contract
570
+ *
571
+ * @param contractId - Contract ID
572
+ * @returns Array of payments
573
+ */
574
+ async findByContract(contractId) {
575
+ return await this.list({
576
+ where: { contractId },
577
+ orderBy: "created_at DESC"
578
+ });
579
+ }
580
+ /**
581
+ * Find payments by customer
582
+ *
583
+ * @param customerId - Customer ID
584
+ * @returns Array of payments
585
+ */
586
+ async findByCustomer(customerId) {
587
+ return await this.list({
588
+ where: { customerId },
589
+ orderBy: "created_at DESC"
590
+ });
591
+ }
592
+ /**
593
+ * Find payments by status
594
+ *
595
+ * @param status - Payment status
596
+ * @returns Array of payments
597
+ */
598
+ async findByStatus(status) {
599
+ return await this.list({
600
+ where: { status },
601
+ orderBy: "created_at DESC"
602
+ });
603
+ }
604
+ /**
605
+ * Find payments by method
606
+ *
607
+ * @param method - Payment method
608
+ * @returns Array of payments
609
+ */
610
+ async findByMethod(method) {
611
+ return await this.list({
612
+ where: { method },
613
+ orderBy: "created_at DESC"
614
+ });
615
+ }
616
+ /**
617
+ * Find all pending payments
618
+ *
619
+ * @returns Array of pending payments
620
+ */
621
+ async findPending() {
622
+ return await this.findByStatus(PaymentStatus.PENDING);
623
+ }
624
+ /**
625
+ * Find all completed payments
626
+ *
627
+ * @returns Array of completed payments
628
+ */
629
+ async findCompleted() {
630
+ return await this.findByStatus(PaymentStatus.COMPLETED);
631
+ }
632
+ /**
633
+ * Find payment by transaction ID
634
+ *
635
+ * @param transactionId - External transaction ID
636
+ * @returns Payment or null
637
+ */
638
+ async findByTransactionId(transactionId) {
639
+ return (await this.list({
640
+ where: { transactionId },
641
+ limit: 1
642
+ }))[0] || null;
643
+ }
644
+ /**
645
+ * Find payments in date range
646
+ *
647
+ * @param startDate - Start date
648
+ * @param endDate - End date
649
+ * @returns Array of payments
650
+ */
651
+ async findByDateRange(startDate, endDate) {
652
+ return await this.list({
653
+ where: {
654
+ "paidAt >=": startDate.toISOString(),
655
+ "paidAt <=": endDate.toISOString()
656
+ },
657
+ orderBy: "paidAt DESC"
658
+ });
659
+ }
660
+ /**
661
+ * Calculate total payments for a contract
662
+ *
663
+ * @param contractId - Contract ID
664
+ * @returns Total amount paid
665
+ */
666
+ async getTotalForContract(contractId) {
667
+ return (await this.list({ where: {
668
+ contractId,
669
+ status: PaymentStatus.COMPLETED
670
+ } })).reduce((sum, payment) => sum + payment.amount, 0);
671
+ }
672
+ /**
673
+ * Find payments linked to a ledger journal
674
+ *
675
+ * @param journalId - Journal ID from smrt-ledgers
676
+ * @returns Payment or null
677
+ */
678
+ async findByJournal(journalId) {
679
+ return (await this.list({
680
+ where: { journalId },
681
+ limit: 1
682
+ }))[0] || null;
683
+ }
684
+ /**
685
+ * Find all payments belonging to a specific tenant
686
+ *
687
+ * @param tenantId - Tenant ID
688
+ * @returns Array of payments for the tenant
689
+ */
690
+ async findByTenant(tenantId) {
691
+ return this.list({ where: { tenantId } });
692
+ }
693
+ /**
694
+ * Find all global payments (not associated with any tenant).
695
+ *
696
+ * Routes through the shared tenant-global helper so it does not throw under
697
+ * an active tenant context (an explicit `tenant_id IS NULL` filter would be
698
+ * flagged as an isolation violation). (#1600)
699
+ *
700
+ * @returns Array of global payments
701
+ */
702
+ async findGlobal() {
703
+ return queryGlobal(this);
704
+ }
705
+ /**
706
+ * Find payments for a tenant including global payments.
707
+ *
708
+ * Fails closed if an active tenant context requests a different tenant's
709
+ * rows; the admin/system path keeps the cross-tenant capability. (#1600)
710
+ *
711
+ * @param tenantId - Tenant ID
712
+ * @returns Array of tenant-specific and global payments
713
+ */
714
+ async findWithGlobals(tenantId) {
715
+ return queryWithGlobals(this, tenantId, "Payment.findWithGlobals");
716
+ }
717
+ };
718
+ //#endregion
719
+ //#region src/historical-funds-internal.ts
720
+ async function exists(db) {
721
+ return isPostgresDatabase(db) ? (await db.query("SELECT to_regclass(?) AS relation", ["historical_customer_funds_allocations"])).rows[0]?.relation != null : db.tableExists("historical_customer_funds_allocations");
722
+ }
723
+ async function historicalInvoiceAllocationTotal(db, invoiceId, effect) {
724
+ if (effect === "refund") return historicalCreditRefundCommittedTotal(db, invoiceId);
725
+ if (!await exists(db)) return historicalCreditRefundCommittedTotal(db, invoiceId);
726
+ const tenantId = getTenantId();
727
+ if (!tenantId) {
728
+ if ((await db.query("SELECT id FROM historical_customer_funds_allocations WHERE invoice_id=? LIMIT 1", invoiceId)).rows.length) throw new Error("Historical allocation capacity requires the explicit active tenant");
729
+ return historicalCreditRefundCommittedTotal(db, invoiceId);
730
+ }
731
+ const rows = await (await HistoricalCustomerFundsAllocationCollection.create({ db })).list({
732
+ where: {
733
+ tenantId,
734
+ invoiceId,
735
+ effect: "payment"
736
+ },
737
+ limit: 100001
738
+ });
739
+ if (rows.length > 1e5) throw new Error("Historical allocation capacity exceeds the bounded source scope");
740
+ const total = rows.filter((row) => row.effect === "payment").reduce((sum, row) => sum + BigInt(row.amountMinor), 0n);
741
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Historical allocation capacity is unsafe");
742
+ const combined = total + BigInt(effect === "payment" ? 0 : await historicalCreditRefundCommittedTotal(db, invoiceId));
743
+ if (combined > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Historical combined capacity is unsafe");
744
+ return Number(combined);
745
+ }
746
+ async function historicalCreditRefundCommittedTotal(db, creditNoteId) {
747
+ if (!(isPostgresDatabase(db) ? (await db.query("SELECT to_regclass(?) AS relation", ["historical_customer_funds"])).rows[0]?.relation != null : await db.tableExists("historical_customer_funds"))) return 0;
748
+ const tenantId = getTenantId();
749
+ if (!tenantId) {
750
+ if ((await db.query("SELECT id FROM historical_customer_funds WHERE source_credit_note_id=? LIMIT 1", creditNoteId)).rows.length) throw new Error("Historical refund capacity requires the explicit active tenant");
751
+ return 0;
752
+ }
753
+ const { HistoricalCustomerFundsCollection } = await import("./HistoricalCustomerFundsCollections-B5x8gTW3.js").then((n) => n.r);
754
+ const rows = await (await HistoricalCustomerFundsCollection.create({ db })).list({
755
+ where: {
756
+ tenantId,
757
+ sourceCreditNoteId: creditNoteId,
758
+ kind: "refund"
759
+ },
760
+ limit: 100001
761
+ });
762
+ if (rows.length > 1e5) throw new Error("Historical credit refund source scope exceeds its bounded limit");
763
+ const total = rows.reduce((sum, row) => sum + BigInt(row.amountMinor), 0n);
764
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Historical refund source total is unsafe");
765
+ return Number(total);
766
+ }
767
+ //#endregion
768
+ //#region src/models/PaymentAllocation.ts
769
+ var __defProp = Object.defineProperty;
770
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
771
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
772
+ enumerable: true,
773
+ configurable: true,
774
+ writable: true,
775
+ value
776
+ }) : obj[key] = value;
777
+ var __decorateClass = (decorators, target, key, kind) => {
778
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
779
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
780
+ if (kind && result) __defProp(target, key, result);
781
+ return result;
782
+ };
783
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
784
+ var PaymentAllocation = class extends SmrtObject {
785
+ tenantId = null;
786
+ paymentId = "";
787
+ invoiceId = "";
788
+ amount = 0;
789
+ allocatedAt = /* @__PURE__ */ new Date();
790
+ /**
791
+ * User/agent who made the allocation
792
+ */
793
+ allocatedBy = "";
794
+ notes = "";
795
+ constructor(options = {}) {
796
+ super(options);
797
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
798
+ if (options.paymentId !== void 0) this.paymentId = options.paymentId;
799
+ if (options.invoiceId !== void 0) this.invoiceId = options.invoiceId;
800
+ if (options.amount !== void 0) this.amount = options.amount;
801
+ if (options.allocatedAt !== void 0) this.allocatedAt = options.allocatedAt;
802
+ if (options.allocatedBy !== void 0) this.allocatedBy = options.allocatedBy;
803
+ if (options.notes !== void 0) this.notes = options.notes;
804
+ }
805
+ /** Save and validate against both claimed parent rows in one transaction. */
806
+ async save() {
807
+ assertIntegerMinorUnits("PaymentAllocation", this.id ?? "<new>", [["amount", this.amount]]);
808
+ if (this.amount <= 0) throw new Error("PaymentAllocation amount must be a positive number");
809
+ if (!this.paymentId || !this.invoiceId) throw new Error("PaymentAllocation requires payment and invoice");
810
+ return this.withTransaction(async () => {
811
+ const { PaymentCollection } = await Promise.resolve().then(() => PaymentCollection_exports);
812
+ const { InvoiceCollection } = await import("./InvoiceCollection-BIcge9Wl.js").then((n) => n.n);
813
+ const { PaymentAllocationCollection } = await Promise.resolve().then(() => PaymentAllocationCollection_exports);
814
+ const payments = await PaymentCollection.create({ db: this.db });
815
+ const invoices = await InvoiceCollection.create({ db: this.db });
816
+ const allocations = await PaymentAllocationCollection.create({ db: this.db });
817
+ const prior = this.id ? await allocations.get({ id: this.id }, { cache: false }) : null;
818
+ if (prior && (prior.paymentId !== this.paymentId || prior.invoiceId !== this.invoiceId || prior.tenantId !== allocationOwner(this.tenantId))) throw new Error("PaymentAllocation parent identity is immutable");
819
+ const payment = await payments.get({ id: this.paymentId }, { cache: false });
820
+ if (!payment) throw new Error("PaymentAllocation referenced Payment does not exist");
821
+ if (payment.retentionAccountingBindingJson) throw new Error("Provider-recorded refund allocation is immutable");
822
+ if (customerFundsState(payment.customerFundsJson)) assertCustomerFundsWrite(this);
823
+ if (!payment.updated_at) throw new Error("Allocation parent has no persisted revision");
824
+ await payment.claimRevision(payment.updated_at);
825
+ const invoice = await invoices.get({ id: this.invoiceId }, { cache: false });
826
+ if (!invoice) throw new Error("PaymentAllocation referenced Invoice does not exist");
827
+ if (!invoice.updated_at) throw new Error("Allocation parent has no persisted revision");
828
+ await invoice.claimRevision(invoice.updated_at);
829
+ const owner = allocationOwner(this.tenantId);
830
+ if (payment.tenantId !== owner || invoice.tenantId !== owner || (payment.customerId || null) !== (invoice.customerId || null) || payment.currency !== invoice.currency) throw new Error("PaymentAllocation requires matching tenant, customer and currency");
831
+ if (payment.kind === "refund" && invoice.kind !== "credit_note" || payment.kind === "payment" && invoice.kind !== "invoice") throw new Error("PaymentAllocation requires payments on invoices and refunds on credit notes");
832
+ const paymentTotal = allocationTotal(await allocations.findByPayment(this.paymentId), this.id) + BigInt(this.amount);
833
+ const invoiceTotal = allocationTotal(await allocations.findByInvoice(this.invoiceId), this.id) + BigInt(this.amount) + BigInt(await historicalInvoiceAllocationTotal(this.db, this.invoiceId));
834
+ if (paymentTotal > BigInt(payment.amount)) throw new Error("PaymentAllocation would over-apply payment");
835
+ const invoiceCap = invoice.kind === "invoice" ? invoice.getPayableFacts().allocationCapMinor : invoice.totalAmount;
836
+ if (invoiceTotal > BigInt(Math.max(0, invoiceCap))) throw new Error("PaymentAllocation would over-pay invoice");
837
+ if (payment.kind === "refund") {
838
+ if (payment.status !== "completed") throw new Error("A refund must be settled before it is allocated");
839
+ if (!invoice.creditedInvoiceId) throw new Error("Refund credit note has no credited invoice");
840
+ const source = await invoices.get({ id: invoice.creditedInvoiceId }, { cache: false });
841
+ if (!source || source.tenantId !== owner) throw new Error("Refund source invoice does not exist in its tenant");
842
+ if (!source.updated_at) throw new Error("Refund source invoice has no persisted revision");
843
+ await source.claimRevision(source.updated_at);
844
+ const creditRows = await invoices.list({
845
+ where: {
846
+ tenantId: owner,
847
+ creditedInvoiceId: source.id
848
+ },
849
+ limit: 100001
850
+ });
851
+ if (creditRows.length > 1e5) throw new Error("Refund credit scope exceeds its bounded source limit");
852
+ const credits = creditRows.filter((credit) => credit.kind === "credit_note" && credit.status !== "draft" && credit.status !== "cancelled");
853
+ const issuedCredits = credits.reduce((sum, credit) => sum + credit.totalAmount, 0);
854
+ let settledRefunds = 0;
855
+ for (const credit of credits) {
856
+ if (!credit.id) continue;
857
+ settledRefunds += await historicalCreditRefundCommittedTotal(this.db, credit.id);
858
+ for (const allocation of await allocations.findByInvoice(credit.id)) {
859
+ if (allocation.id === this.id) continue;
860
+ const refund = await payments.get({ id: allocation.paymentId }, { cache: false });
861
+ if (refund?.kind === "refund" && refund.status === "completed") settledRefunds += allocation.amount;
862
+ }
863
+ }
864
+ const availableRefund = calculateAvailableRefund({
865
+ sourceTotalMinor: source.totalAmount,
866
+ sourcePaidMinor: source.amountPaid,
867
+ issuedCreditMinor: issuedCredits,
868
+ settledRefundMinor: settledRefunds
869
+ });
870
+ if (this.amount > availableRefund) throw new Error(`Refund allocation exceeds available unapplied credit (${availableRefund})`);
871
+ }
872
+ return await super.save();
873
+ });
874
+ }
875
+ /** Managed application reversal must also post its inverse journal and retain evidence. */
876
+ async delete(options = {}) {
877
+ return this.withTransaction(async () => {
878
+ const persisted = this.id ? await this.db.get(this.tableName, { id: this.id }) : null;
879
+ const { PaymentCollection } = await Promise.resolve().then(() => PaymentCollection_exports);
880
+ const payment = await (await PaymentCollection.create({ db: this.db })).get({ id: String(persisted?.payment_id ?? this.paymentId) }, { cache: false });
881
+ if (payment) {
882
+ if (!payment.updated_at) throw new Error("Allocation parent has no persisted revision");
883
+ await payment.claimRevision(payment.updated_at);
884
+ }
885
+ if (payment?.retentionAccountingBindingJson) throw new Error("Provider-recorded refund allocation cannot be deleted");
886
+ if (payment?.customerFundsJson) assertCustomerFundsWrite(this);
887
+ return super.delete(options);
888
+ });
889
+ }
890
+ };
891
+ __publicField(PaymentAllocation, "__smrtPackage__", "@happyvertical/smrt-commerce");
892
+ __decorateClass([tenantId({ nullable: true })], PaymentAllocation.prototype, "tenantId", 2);
893
+ __decorateClass([field({ description: "The payment being applied." }), foreignKey("Payment")], PaymentAllocation.prototype, "paymentId", 2);
894
+ __decorateClass([field({ description: "The invoice it settles." }), foreignKey("Invoice")], PaymentAllocation.prototype, "invoiceId", 2);
895
+ __decorateClass([field({
896
+ description: "How much of the payment goes to this invoice.",
897
+ ui: { format: "money" }
898
+ })], PaymentAllocation.prototype, "amount", 2);
899
+ __decorateClass([field({ description: "When it was applied." })], PaymentAllocation.prototype, "allocatedAt", 2);
900
+ __decorateClass([field({ description: "Anything worth remembering about this allocation." })], PaymentAllocation.prototype, "notes", 2);
901
+ PaymentAllocation = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
902
+ api: { include: ["list", "get"] },
903
+ mcp: { include: ["list", "get"] },
904
+ cli: { include: ["list", "get"] }
905
+ })], PaymentAllocation);
906
+ //#endregion
907
+ //#region src/collections/PaymentAllocationCollection.ts
908
+ var PaymentAllocationCollection_exports = /* @__PURE__ */ __exportAll({ PaymentAllocationCollection: () => PaymentAllocationCollection });
909
+ var PaymentAllocationCollection = class PaymentAllocationCollection extends SmrtCollection {
910
+ static _itemClass = PaymentAllocation;
911
+ /**
912
+ * Apply a payment to several invoices atomically. Callers supply stable UUID
913
+ * allocation ids: exact replays reuse rows; reusing an id with other money
914
+ * or parents fails. Actor authorization remains the application's job.
915
+ */
916
+ async allocatePayment(input) {
917
+ if (!input.allocations.length || new Set(input.allocations.map((item) => item.id)).size !== input.allocations.length) throw new Error("Allocation batch requires distinct stable allocation ids");
918
+ const { PaymentCollection } = await Promise.resolve().then(() => PaymentCollection_exports);
919
+ const { InvoiceCollection } = await import("./InvoiceCollection-BIcge9Wl.js").then((n) => n.n);
920
+ const { InvoiceStatus } = await import("./types-Bbm0Xflc.js").then((n) => n.m);
921
+ const payment = await (await PaymentCollection.create({ db: this.db })).get({ id: input.paymentId }, { cache: false });
922
+ if (!payment) throw new Error("Payment does not exist");
923
+ const committed = await payment.withTransaction(async () => {
924
+ if (!payment.updated_at) throw new Error("Allocation parent has no persisted revision");
925
+ await payment.claimRevision(payment.updated_at);
926
+ const allocations = await PaymentAllocationCollection.create({ db: payment.db });
927
+ const invoices = await InvoiceCollection.create({ db: payment.db });
928
+ const result = [];
929
+ for (const item of [...input.allocations].sort((a, b) => a.invoiceId.localeCompare(b.invoiceId) || a.id.localeCompare(b.id))) {
930
+ if (!item.id) throw new Error("Allocation requires a stable id");
931
+ const existing = await allocations.get({ id: item.id }, { cache: false });
932
+ if (existing) {
933
+ if (existing.paymentId !== input.paymentId || existing.invoiceId !== item.invoiceId || existing.amount !== item.amount || existing.tenantId !== payment.tenantId) throw new Error("Allocation replay does not match existing allocation");
934
+ result.push(existing);
935
+ } else result.push(await allocations.create({
936
+ ...item,
937
+ paymentId: input.paymentId,
938
+ tenantId: payment.tenantId,
939
+ allocatedBy: input.allocatedBy ?? "",
940
+ _insertOnly: true
941
+ }));
942
+ }
943
+ for (const invoiceId of [...new Set(input.allocations.map((item) => item.invoiceId))].sort()) {
944
+ const invoice = await invoices.get({ id: invoiceId }, { cache: false });
945
+ if (!invoice) throw new Error("Invoice does not exist");
946
+ if (invoice.status !== InvoiceStatus.WRITTEN_OFF) invoice.updatePaymentStatus(await allocations.getTotalAllocatedToInvoice(invoiceId));
947
+ await invoice.save();
948
+ }
949
+ return result;
950
+ });
951
+ return Promise.all(committed.map(async ({ id }) => {
952
+ const allocation = await this.get({ id: String(id) }, { cache: false });
953
+ if (!allocation) throw new Error("Committed allocation no longer exists");
954
+ return allocation;
955
+ }));
956
+ }
957
+ /** Remove one allocation atomically and rederive its invoice/credit status. */
958
+ async removeAllocation(id) {
959
+ const allocation = await this.get({ id }, { cache: false });
960
+ if (!allocation) return;
961
+ const { PaymentCollection } = await Promise.resolve().then(() => PaymentCollection_exports);
962
+ const { InvoiceCollection } = await import("./InvoiceCollection-BIcge9Wl.js").then((n) => n.n);
963
+ const payment = await (await PaymentCollection.create({ db: this.db })).get({ id: allocation.paymentId }, { cache: false });
964
+ if (!payment) throw new Error("Allocation payment does not exist");
965
+ await payment.withTransaction(async () => {
966
+ const rows = await PaymentAllocationCollection.create({ db: payment.db });
967
+ const current = await rows.get({ id }, { cache: false });
968
+ if (!current) return;
969
+ const invoice = await (await InvoiceCollection.create({ db: payment.db })).get({ id: current.invoiceId }, { cache: false });
970
+ if (!invoice) throw new Error("Allocation invoice does not exist");
971
+ await current.delete();
972
+ invoice.updatePaymentStatus(await rows.getTotalAllocatedToInvoice(current.invoiceId));
973
+ await invoice.save();
974
+ });
975
+ }
976
+ /**
977
+ * Find allocations by payment
978
+ *
979
+ * @param paymentId - Payment ID
980
+ * @returns Array of allocations
981
+ */
982
+ async allFor(field, id) {
983
+ const rows = [];
984
+ for (let offset = 0;; offset += 1e3) {
985
+ const page = await this.list({
986
+ where: { [field]: id },
987
+ orderBy: ["allocatedAt DESC", "id ASC"],
988
+ offset,
989
+ limit: 1e3
990
+ });
991
+ rows.push(...page);
992
+ if (page.length < 1e3) return rows;
993
+ }
994
+ }
995
+ async findByPayment(paymentId) {
996
+ return this.allFor("paymentId", paymentId);
997
+ }
998
+ /** Every committed row participates in payment and historical combined capacity. */
999
+ async findByInvoice(invoiceId) {
1000
+ return this.allFor("invoiceId", invoiceId);
1001
+ }
1002
+ /**
1003
+ * Get total amount allocated to an invoice
1004
+ *
1005
+ * @param invoiceId - Invoice ID
1006
+ * @returns Total allocated amount
1007
+ */
1008
+ async getTotalAllocatedToInvoice(invoiceId) {
1009
+ const total = (await this.findByInvoice(invoiceId)).reduce((sum, alloc) => sum + BigInt(alloc.amount), BigInt(await historicalInvoiceAllocationTotal(this.db, invoiceId)));
1010
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Combined invoice allocation total is unsafe");
1011
+ return Number(total);
1012
+ }
1013
+ /**
1014
+ * Get total amount allocated from a payment
1015
+ *
1016
+ * @param paymentId - Payment ID
1017
+ * @returns Total allocated amount
1018
+ */
1019
+ async getTotalAllocatedFromPayment(paymentId) {
1020
+ return (await this.findByPayment(paymentId)).reduce((sum, alloc) => sum + alloc.amount, 0);
1021
+ }
1022
+ /**
1023
+ * Get remaining unallocated amount from a payment.
1024
+ *
1025
+ * @param paymentId - Payment ID
1026
+ * @param paymentAmount - Total payment amount
1027
+ * @returns Unallocated amount
1028
+ * @throws Error if payment is over-allocated (indicates data integrity issue)
1029
+ */
1030
+ async getUnallocatedFromPayment(paymentId, paymentAmount) {
1031
+ const allocated = await this.getTotalAllocatedFromPayment(paymentId);
1032
+ const { PaymentCollection } = await Promise.resolve().then(() => PaymentCollection_exports);
1033
+ const payment = await (await PaymentCollection.create({ db: this.db })).get({ id: paymentId }, { cache: false });
1034
+ const managed = customerFundsState(payment?.customerFundsJson);
1035
+ if (managed && (managed.kind !== "receipt" || payment?.amount !== paymentAmount)) throw new Error("Customer funds require their authoritative receipt amount");
1036
+ const refunded = managed?.operations.filter((op) => op.kind === "refund").reduce((sum, op) => sum + BigInt(op.amountMinor), 0n) ?? 0n;
1037
+ const remaining = Number(BigInt(paymentAmount) - BigInt(allocated) - refunded);
1038
+ if (remaining < 0) throw new Error(`Over-allocated payment detected for paymentId=${paymentId}: allocated amount (${allocated}) exceeds payment amount (${paymentAmount})`);
1039
+ return remaining;
1040
+ }
1041
+ /**
1042
+ * Find allocation by payment and invoice
1043
+ *
1044
+ * @param paymentId - Payment ID
1045
+ * @param invoiceId - Invoice ID
1046
+ * @returns Allocation or null
1047
+ */
1048
+ async findByPaymentAndInvoice(paymentId, invoiceId) {
1049
+ return (await this.list({
1050
+ where: {
1051
+ paymentId,
1052
+ invoiceId
1053
+ },
1054
+ limit: 1
1055
+ }))[0] || null;
1056
+ }
1057
+ /**
1058
+ * Find allocations by allocator
1059
+ *
1060
+ * @param allocatedBy - User/agent ID who made the allocation
1061
+ * @returns Array of allocations
1062
+ */
1063
+ async findByAllocator(allocatedBy) {
1064
+ return await this.list({
1065
+ where: { allocatedBy },
1066
+ orderBy: "allocatedAt DESC"
1067
+ });
1068
+ }
1069
+ /**
1070
+ * Find allocations in date range
1071
+ *
1072
+ * @param startDate - Start date
1073
+ * @param endDate - End date
1074
+ * @returns Array of allocations
1075
+ */
1076
+ async findByDateRange(startDate, endDate) {
1077
+ return await this.list({
1078
+ where: {
1079
+ "allocatedAt >=": startDate.toISOString(),
1080
+ "allocatedAt <=": endDate.toISOString()
1081
+ },
1082
+ orderBy: "allocatedAt DESC"
1083
+ });
1084
+ }
1085
+ /**
1086
+ * Find all payment allocations belonging to a specific tenant
1087
+ *
1088
+ * @param tenantId - Tenant ID
1089
+ * @returns Array of payment allocations for the tenant
1090
+ */
1091
+ async findByTenant(tenantId) {
1092
+ return this.list({ where: { tenantId } });
1093
+ }
1094
+ /**
1095
+ * Find all global payment allocations (not associated with any tenant).
1096
+ *
1097
+ * Routes through the shared tenant-global helper so it does not throw under
1098
+ * an active tenant context (an explicit `tenant_id IS NULL` filter would be
1099
+ * flagged as an isolation violation). (#1600)
1100
+ *
1101
+ * @returns Array of global payment allocations
1102
+ */
1103
+ async findGlobal() {
1104
+ return queryGlobal(this);
1105
+ }
1106
+ /**
1107
+ * Find payment allocations for a tenant including global allocations.
1108
+ *
1109
+ * Fails closed if an active tenant context requests a different tenant's
1110
+ * rows; the admin/system path keeps the cross-tenant capability. (#1600)
1111
+ *
1112
+ * @param tenantId - Tenant ID
1113
+ * @returns Array of tenant-specific and global payment allocations
1114
+ */
1115
+ async findWithGlobals(tenantId) {
1116
+ return queryWithGlobals(this, tenantId, "PaymentAllocation.findWithGlobals");
1117
+ }
1118
+ };
1119
+ //#endregion
1120
+ //#region src/accounting-snapshot.ts
1121
+ function requireText(value, label) {
1122
+ if (!value || value.trim() !== value) throw new Error(`${label} must be a nonempty trimmed string`);
1123
+ return value;
1124
+ }
1125
+ function requireSafeMinor(value, label) {
1126
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${label} must be a nonnegative safe integer minor amount`);
1127
+ return value;
1128
+ }
1129
+ function requirePositiveQuantity(quantity, lineId) {
1130
+ if (!Number.isFinite(quantity) || quantity <= 0) throw new Error(`Line '${lineId}' quantity must be a positive finite number`);
1131
+ return quantity;
1132
+ }
1133
+ function percentToFraction(percent, componentId) {
1134
+ if (!/^(?:0|[1-9]\d{0,2})(?:\.\d{1,6})?$/.test(percent)) throw new Error(`Tax component '${componentId}' percent must be exact decimal text from 0 through 100`);
1135
+ const [whole, fraction = ""] = percent.split(".");
1136
+ const scale = 10n ** BigInt(fraction.length);
1137
+ const numerator = BigInt(whole) * scale + BigInt(fraction || "0");
1138
+ const denominator = 100n * scale;
1139
+ if (numerator > denominator) throw new Error(`Tax component '${componentId}' percent exceeds 100`);
1140
+ if (numerator === 0n) return "0";
1141
+ if (numerator === denominator) return "1";
1142
+ return `0.${numerator.toString().padStart(denominator.toString().length - 1, "0")}`.replace(/0+$/, "");
1143
+ }
1144
+ function globalTaxCalculation(lineAmountType) {
1145
+ switch (lineAmountType) {
1146
+ case "exclusive": return "TaxExcluded";
1147
+ case "inclusive": return "TaxInclusive";
1148
+ case "none": return "NotApplicable";
1149
+ }
1150
+ }
1151
+ function mapLine(line, currency, lineAmountType, mapping) {
1152
+ requireText(line.id, "Line ID");
1153
+ const lineMapping = mapping.lines[line.id];
1154
+ if (!lineMapping) throw new Error(`Missing explicit QuickBooks mapping for line '${line.id}'`);
1155
+ requireText(lineMapping.itemRef, `QuickBooks item reference for line '${line.id}'`);
1156
+ requireText(lineMapping.taxCodeRef, `QuickBooks tax-code reference for line '${line.id}'`);
1157
+ const amountMinor = requireSafeMinor(line.amountMinor, `Line '${line.id}' amount`);
1158
+ const taxMinor = requireSafeMinor(line.taxMinor, `Line '${line.id}' tax`);
1159
+ const componentIds = /* @__PURE__ */ new Set();
1160
+ let componentTotalMinor = 0;
1161
+ const taxComponents = line.taxComponents.map((component) => {
1162
+ if (component.compound !== void 0 && typeof component.compound !== "boolean") throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen component compound flag must be boolean");
1163
+ if (component.compound && !lineMapping.taxComponents) throw new MoneyValidationError("ACCOUNTING_SYNC_UNSUPPORTED", "Compound accounting sync requires caller-verified ordered realm tax-code mapping");
1164
+ if (component.compound && component.taxableMinor === void 0) throw new MoneyValidationError("ACCOUNTING_SYNC_UNSUPPORTED", "Compound accounting sync requires the frozen component taxable basis");
1165
+ const basisMinor = component.taxableMinor === void 0 ? void 0 : requireSafeMinor(component.taxableMinor, `Line '${line.id}' component taxable basis`);
1166
+ if (basisMinor !== void 0 && basisMinor !== amountMinor + (component.compound ? componentTotalMinor : 0)) throw new MoneyValidationError("ACCOUNTING_SYNC_UNSUPPORTED", "Frozen component taxable basis does not match ordered source facts");
1167
+ requireText(component.taxComponentId, `Line '${line.id}' tax component ID`);
1168
+ if (componentIds.has(component.taxComponentId)) throw new Error(`Line '${line.id}' repeats tax component '${component.taxComponentId}'`);
1169
+ componentIds.add(component.taxComponentId);
1170
+ const taxAmountMinor = requireSafeMinor(component.taxMinor, `Line '${line.id}' component '${component.taxComponentId}' tax`);
1171
+ componentTotalMinor += taxAmountMinor;
1172
+ const componentMapping = mapping.components[component.taxComponentId];
1173
+ if (!componentMapping) throw new Error(`Missing explicit QuickBooks tax-rate reference for component '${component.taxComponentId}'`);
1174
+ requireText(componentMapping.quickbooksTaxRateRef, `QuickBooks tax-rate reference for component '${component.taxComponentId}'`);
1175
+ return {
1176
+ taxCode: component.taxComponentId,
1177
+ rate: percentToFraction(component.percent, component.taxComponentId),
1178
+ quickbooksTaxRateRef: componentMapping.quickbooksTaxRateRef,
1179
+ taxAmount: minorToMajorUnits(taxAmountMinor, currency),
1180
+ ...component.compound ? { compound: true } : {},
1181
+ ...basisMinor !== void 0 ? { taxableAmount: minorToMajorUnits(basisMinor, currency) } : {}
1182
+ };
1183
+ });
1184
+ if (lineMapping.taxComponents && (lineMapping.taxComponents.length !== taxComponents.length || lineMapping.taxComponents.some((part, index) => part.taxRateRef !== taxComponents[index].quickbooksTaxRateRef || typeof part.compound !== "boolean" || part.compound !== (taxComponents[index].compound ?? false)))) throw new MoneyValidationError("ACCOUNTING_SYNC_UNSUPPORTED", "Ordered realm tax-code mapping does not match frozen components");
1185
+ if (componentTotalMinor !== taxMinor) throw new Error(`Line '${line.id}' frozen component tax ${componentTotalMinor} does not equal line tax ${taxMinor}`);
1186
+ if (taxMinor > 0 && taxComponents.length === 0) throw new Error(`Line '${line.id}' tax requires frozen tax components`);
1187
+ const discountMinor = requireSafeMinor(line.discountMinor, `Line '${line.id}' discount`);
1188
+ const unitPriceMinor = requireSafeMinor(line.unitPriceMinor, `Line '${line.id}' unit price`);
1189
+ const result = {
1190
+ description: requireText(line.description, `Line '${line.id}' description`),
1191
+ sku: line.sku || void 0,
1192
+ quantity: requirePositiveQuantity(line.quantity, line.id),
1193
+ unitPrice: minorToMajorUnits(unitPriceMinor, currency),
1194
+ ...discountMinor === 0 ? {} : { discount: minorToMajorUnits(discountMinor, currency) },
1195
+ amount: minorToMajorUnits(amountMinor, currency),
1196
+ taxAmount: minorToMajorUnits(taxMinor, currency),
1197
+ taxComponents,
1198
+ quickbooksMapping: {
1199
+ itemRef: lineMapping.itemRef,
1200
+ taxCodeRef: lineMapping.taxCodeRef,
1201
+ ...lineMapping.taxComponents ? { taxComponents: lineMapping.taxComponents.map((part) => ({ ...part })) } : {}
1202
+ }
1203
+ };
1204
+ if (lineAmountType === "inclusive") return {
1205
+ ...result,
1206
+ taxInclusiveAmount: minorToMajorUnits(amountMinor + taxMinor, currency)
1207
+ };
1208
+ return result;
1209
+ }
1210
+ function mapFrozenAccountingSnapshot(snapshot, mapping) {
1211
+ const currency = requireText(snapshot.currency, "Currency");
1212
+ requireText(snapshot.id, "Document ID");
1213
+ requireText(snapshot.number, "Document number");
1214
+ requireText(snapshot.customerId, "Customer ID");
1215
+ if (!(snapshot.issueDate instanceof Date) || Number.isNaN(snapshot.issueDate.valueOf())) throw new Error("Issue date must be a valid Date");
1216
+ if (!Array.isArray(snapshot.lineItems) || snapshot.lineItems.length === 0) throw new Error("Frozen document requires at least one line item");
1217
+ const subtotalMinor = requireSafeMinor(snapshot.subtotalMinor, "Subtotal");
1218
+ const taxAmountMinor = requireSafeMinor(snapshot.taxAmountMinor, "Tax amount");
1219
+ const providerTaxMinor = requireSafeMinor(snapshot.providerTaxMinor, "Provider tax");
1220
+ const totalAmountMinor = requireSafeMinor(snapshot.totalAmountMinor, "Total");
1221
+ if (providerTaxMinor !== 0) throw new Error("Frozen provider tax cannot be mapped to QuickBooks without a stored component snapshot and explicit tax-rate reference");
1222
+ if (subtotalMinor + taxAmountMinor !== totalAmountMinor) throw new Error("Frozen subtotal plus tax must equal frozen total");
1223
+ const lineItems = snapshot.lineItems.map((line) => mapLine(line, currency, snapshot.lineAmountType, mapping));
1224
+ const linesSubtotalMinor = snapshot.lineItems.reduce((sum, line) => sum + line.amountMinor, 0);
1225
+ const linesTaxMinor = snapshot.lineItems.reduce((sum, line) => sum + line.taxMinor, 0);
1226
+ if (linesSubtotalMinor !== subtotalMinor) throw new Error("Frozen line net amounts do not equal frozen subtotal");
1227
+ if (linesTaxMinor !== taxAmountMinor) throw new Error("Frozen line tax amounts do not equal frozen tax total");
1228
+ const base = {
1229
+ id: snapshot.id,
1230
+ externalId: snapshot.externalId,
1231
+ customerId: snapshot.customerId,
1232
+ customerExternalId: snapshot.customerExternalId,
1233
+ issueDate: snapshot.issueDate,
1234
+ currency,
1235
+ subtotal: minorToMajorUnits(subtotalMinor, currency),
1236
+ taxAmount: minorToMajorUnits(taxAmountMinor, currency),
1237
+ totalAmount: minorToMajorUnits(totalAmountMinor, currency),
1238
+ lineItems,
1239
+ quickbooksMapping: { globalTaxCalculation: globalTaxCalculation(snapshot.lineAmountType) },
1240
+ reference: snapshot.reference,
1241
+ memo: snapshot.memo
1242
+ };
1243
+ if (snapshot.kind === "invoice") {
1244
+ if (!(snapshot.dueDate instanceof Date) || Number.isNaN(snapshot.dueDate.valueOf())) throw new Error("Invoice due date must be a valid Date");
1245
+ return {
1246
+ ...base,
1247
+ invoiceNumber: snapshot.number,
1248
+ dueDate: snapshot.dueDate
1249
+ };
1250
+ }
1251
+ const creditedInvoiceExternalId = requireText(snapshot.creditedInvoiceExternalId, "Credited invoice external ID");
1252
+ if (subtotalMinor === 0 || totalAmountMinor === 0) throw new Error("Credit memo amounts must be positive");
1253
+ return {
1254
+ ...base,
1255
+ creditMemoNumber: snapshot.number,
1256
+ creditedInvoiceExternalId
1257
+ };
1258
+ }
1259
+ //#endregion
1260
+ //#region src/invoice-accounting-sync.ts
1261
+ var invoice_accounting_sync_exports = /* @__PURE__ */ __exportAll({
1262
+ loadFrozenInvoiceAccountingSnapshot: () => loadFrozenInvoiceAccountingSnapshot,
1263
+ readFrozenAccountingSyncState: () => readFrozenAccountingSyncState,
1264
+ reconcileFrozenInvoiceAccountingSync: () => reconcileFrozenInvoiceAccountingSync,
1265
+ syncFrozenInvoiceToAccounting: () => syncFrozenInvoiceToAccounting,
1266
+ toFrozenInvoiceAccountingInput: () => toFrozenInvoiceAccountingInput
1267
+ });
1268
+ function requiredText(value, label) {
1269
+ if (!value || value.trim() !== value) throw new MoneyValidationError("INVALID_MONEY_INPUT", `${label} must be a nonempty trimmed string`);
1270
+ return value;
1271
+ }
1272
+ function assertPrincipalTenant(tenantId) {
1273
+ requiredText(tenantId, "Accounting tenant");
1274
+ if (getTenantId() !== tenantId) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Accounting sync requires its active principal tenant");
1275
+ }
1276
+ function unsupported(message) {
1277
+ throw new MoneyValidationError("ACCOUNTING_SYNC_UNSUPPORTED", message);
1278
+ }
1279
+ function parseSyncState(value) {
1280
+ if (!value) return null;
1281
+ try {
1282
+ const parsed = JSON.parse(value);
1283
+ if (!parsed || typeof parsed !== "object") throw new Error("not object");
1284
+ const state = parsed;
1285
+ const identity = state.identity;
1286
+ if (state.version !== 1 || ![
1287
+ "in_flight",
1288
+ "unknown",
1289
+ "rejected",
1290
+ "synced"
1291
+ ].includes(String(state.status)) || !identity || identity.provider !== "quickbooks" || typeof identity.realmId !== "string" || identity.environment !== "sandbox" && identity.environment !== "production" || typeof identity.invoiceId !== "string" || typeof identity.moneySnapshotId !== "string" || typeof identity.requestId !== "string" || typeof identity.payloadHash !== "string" || identity.documentKind !== void 0 && identity.documentKind !== "invoice" && identity.documentKind !== "creditMemo" || identity.creditedInvoiceExternalId !== void 0 && typeof identity.creditedInvoiceExternalId !== "string" || typeof state.attemptId !== "string" || typeof state.startedAt !== "string") throw new Error("invalid shape");
1292
+ return {
1293
+ ...state,
1294
+ identity: {
1295
+ ...identity,
1296
+ documentKind: identity.documentKind ?? "invoice"
1297
+ }
1298
+ };
1299
+ } catch {
1300
+ throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Stored accounting sync state is malformed and requires reconciliation");
1301
+ }
1302
+ }
1303
+ function sameIdentity(left, right) {
1304
+ return left.provider === right.provider && left.documentKind === right.documentKind && left.realmId === right.realmId && left.environment === right.environment && left.invoiceId === right.invoiceId && left.moneySnapshotId === right.moneySnapshotId && left.requestId === right.requestId && left.payloadHash === right.payloadHash && left.creditedInvoiceExternalId === right.creditedInvoiceExternalId;
1305
+ }
1306
+ function sameSyncScope(identity, snapshot, mapping) {
1307
+ return identity.provider === "quickbooks" && identity.documentKind === snapshot.document.kind && identity.realmId === mapping.realmId && identity.environment === mapping.environment && identity.invoiceId === snapshot.document.id && identity.moneySnapshotId === snapshot.moneySnapshotId;
1308
+ }
1309
+ function sum(values) {
1310
+ let result = 0n;
1311
+ for (const value of values) {
1312
+ if (!Number.isSafeInteger(value)) throw new MoneyValidationError("UNSAFE_MONEY", "Frozen accounting facts require safe integer minor units");
1313
+ result += BigInt(value);
1314
+ }
1315
+ if (result > BigInt(Number.MAX_SAFE_INTEGER) || result < BigInt(Number.MIN_SAFE_INTEGER)) throw new MoneyValidationError("UNSAFE_MONEY", "Frozen accounting totals exceed safe integer minor units");
1316
+ return Number(result);
1317
+ }
1318
+ function componentTotals(lines) {
1319
+ const totals = /* @__PURE__ */ new Map();
1320
+ for (const line of lines) for (const component of line.taxComponents) totals.set(component.taxComponentId, sum([totals.get(component.taxComponentId) ?? 0, component.taxMinor]));
1321
+ return totals;
1322
+ }
1323
+ async function loadSnapshot(db, tenantId, invoice) {
1324
+ if (invoice.moneyAuthority === "historical") throw new MoneyValidationError("ACCOUNTING_SYNC_UNSUPPORTED", "Historical sales source money requires an explicit reviewed provider representation; no quantities or tax breakdown will be invented");
1325
+ if (invoice.tenantId !== tenantId) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Accounting invoice cannot cross tenant boundaries");
1326
+ if (invoice.status === InvoiceStatus.DRAFT || !invoice.moneySnapshotId) throw new MoneyValidationError("FROZEN_DOCUMENT", "Accounting sync requires an issued frozen invoice");
1327
+ if ((invoice.getPayableFacts().retainedRemainingMinor > 0 || invoice.retentionAccountingStateJson) && !hasRetentionAccountingAuthority(invoice.tenantId, invoice.id)) unsupported("Unreleased retention requires a provider payable representation");
1328
+ if (invoice.status === InvoiceStatus.CANCELLED) unsupported("Cancelled documents cannot be created by accounting sync");
1329
+ const lines = await (await InvoiceLineItemCollection.create({ db })).findByInvoice(requiredText(invoice.id ?? "", "Accounting invoice ID"));
1330
+ if (lines.length === 0) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen accounting invoice requires at least one line");
1331
+ for (const line of lines) if (line.tenantId !== tenantId || line.moneySnapshotId !== invoice.moneySnapshotId) throw new MoneyValidationError("FROZEN_DOCUMENT", "Accounting line snapshot does not match its frozen invoice");
1332
+ const taxes = await (await DocumentTaxCollection.create({ db })).findByDocument(tenantId, invoice.kind, invoice.id ?? "");
1333
+ const lineTax = sum(lines.map((line) => line.taxAmount));
1334
+ const storedTax = sum(taxes.map((row) => row.taxMinor));
1335
+ if (lineTax + invoice.providerTaxAmount !== invoice.taxAmount || storedTax !== lineTax) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen line, DocumentTax, and invoice tax totals do not reconcile");
1336
+ const lineComponents = componentTotals(lines);
1337
+ const storedComponents = /* @__PURE__ */ new Map();
1338
+ for (const row of taxes) {
1339
+ if (!row.taxComponentId) continue;
1340
+ storedComponents.set(row.taxComponentId, sum([storedComponents.get(row.taxComponentId) ?? 0, row.taxMinor]));
1341
+ }
1342
+ if ([.../* @__PURE__ */ new Set([...lineComponents.keys(), ...storedComponents.keys()])].some((id) => (lineComponents.get(id) ?? 0) !== (storedComponents.get(id) ?? 0))) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen line components do not reconcile to DocumentTax");
1343
+ let creditedInvoiceExternalId;
1344
+ let creditedInvoiceScope;
1345
+ if (invoice.kind === "credit_note") {
1346
+ if (!invoice.creditedInvoiceId) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen credit note requires its credited invoice");
1347
+ const source = await (await InvoiceCollection.create({ db })).get({ id: invoice.creditedInvoiceId }, { cache: false });
1348
+ if (!source || source.tenantId !== tenantId || !source.externalId) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen credit note requires the source invoice external ID");
1349
+ if (source.retentionAccountingStateJson && !hasRetentionAccountingAuthority(source.tenantId, source.id)) unsupported("Provider-retained credit requires the retained-first accounting adjustment command");
1350
+ const sourceState = parseSyncState(source.accountingSyncStateJson);
1351
+ if (sourceState?.status !== "synced" || sourceState.externalId !== source.externalId) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Frozen credit note requires a reconciled source invoice accounting identity");
1352
+ creditedInvoiceExternalId = source.externalId;
1353
+ creditedInvoiceScope = {
1354
+ provider: sourceState.identity.provider,
1355
+ realmId: sourceState.identity.realmId,
1356
+ environment: sourceState.identity.environment
1357
+ };
1358
+ }
1359
+ return {
1360
+ moneySnapshotId: invoice.moneySnapshotId,
1361
+ creditedInvoiceScope,
1362
+ document: {
1363
+ kind: invoice.kind === "invoice" ? "invoice" : "creditMemo",
1364
+ id: invoice.id ?? "",
1365
+ number: invoice.invoiceNumber,
1366
+ customerId: invoice.customerId,
1367
+ customerExternalId: invoice.customerExternalId || void 0,
1368
+ issueDate: invoice.issueDate,
1369
+ dueDate: invoice.dueDate ?? void 0,
1370
+ currency: invoice.currency,
1371
+ lineAmountType: invoice.lineAmountType,
1372
+ taxRoundingScope: invoice.taxRoundingScope,
1373
+ subtotalMinor: invoice.subtotal,
1374
+ taxAmountMinor: invoice.taxAmount,
1375
+ providerTaxMinor: invoice.providerTaxAmount,
1376
+ totalAmountMinor: invoice.totalAmount,
1377
+ lineItems: lines.map((line) => ({
1378
+ id: requiredText(line.id ?? "", "Accounting line ID"),
1379
+ description: line.description,
1380
+ sku: line.sku || void 0,
1381
+ quantity: line.quantity,
1382
+ unitPriceMinor: line.unitPrice,
1383
+ discountMinor: line.discount,
1384
+ amountMinor: invoice.lineAmountType === "inclusive" ? line.amount - line.taxAmount : line.amount,
1385
+ taxMinor: line.taxAmount,
1386
+ taxComponents: structuredClone(line.taxComponents)
1387
+ })),
1388
+ creditedInvoiceExternalId,
1389
+ externalId: invoice.externalId || void 0,
1390
+ reference: invoice.reference || void 0,
1391
+ memo: invoice.customerNotes || void 0
1392
+ }
1393
+ };
1394
+ }
1395
+ function validateMapping(mapping) {
1396
+ requiredText(mapping.realmId, "QuickBooks realm ID");
1397
+ if (mapping.environment !== "sandbox" && mapping.environment !== "production") throw new MoneyValidationError("INVALID_MONEY_INPUT", "QuickBooks environment must be sandbox or production");
1398
+ if (!mapping.lines || typeof mapping.lines !== "object") throw new MoneyValidationError("INVALID_MONEY_INPUT", "QuickBooks line mapping is required");
1399
+ if (!mapping.components || typeof mapping.components !== "object") throw new MoneyValidationError("INVALID_MONEY_INPUT", "QuickBooks component mapping is required");
1400
+ }
1401
+ function toSdkInput(snapshot, mapping) {
1402
+ validateMapping(mapping);
1403
+ if (snapshot.document.kind === "creditMemo" && (snapshot.creditedInvoiceScope?.provider !== "quickbooks" || snapshot.creditedInvoiceScope.realmId !== mapping.realmId || snapshot.creditedInvoiceScope.environment !== mapping.environment)) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Credit note and credited invoice must use the same accounting provider, realm, and environment");
1404
+ if (snapshot.document.lineItems.some((line) => line.quantity < 0 || line.amountMinor < 0 || line.unitPriceMinor < 0)) unsupported("Signed-line accounting sync requires a public accounting SDK signed-line representation");
1405
+ return mapFrozenAccountingSnapshot(snapshot.document, mapping);
1406
+ }
1407
+ async function loadFrozenInvoiceAccountingSnapshot(input) {
1408
+ assertPrincipalTenant(input.tenantId);
1409
+ return withDocumentMoneyTransaction(input.db, async (tx) => {
1410
+ await lockTenantMoneyRuntime(tx, input.tenantId);
1411
+ const { invoice } = await lockAndReloadInvoice(tx, input.invoiceId, input.tenantId);
1412
+ return loadSnapshot(tx, input.tenantId, invoice);
1413
+ });
1414
+ }
1415
+ async function toFrozenInvoiceAccountingInput(input) {
1416
+ return toSdkInput(await loadFrozenInvoiceAccountingSnapshot(input), input.mapping);
1417
+ }
1418
+ function prepareRequest(snapshot, mapping, sdkInput) {
1419
+ const identity = {
1420
+ requestId: requestIdFor(snapshot, mapping),
1421
+ realmId: mapping.realmId,
1422
+ environment: mapping.environment
1423
+ };
1424
+ if (snapshot.document.kind === "invoice") {
1425
+ if (!("invoiceNumber" in sdkInput)) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen invoice mapping produced the wrong accounting document kind");
1426
+ const request2 = prepareQuickBooksInvoiceRequest(sdkInput, identity);
1427
+ return {
1428
+ request: request2,
1429
+ accountingInput: {
1430
+ ...sdkInput,
1431
+ idempotencyKey: request2.requestId,
1432
+ quickbooksRequest: request2
1433
+ }
1434
+ };
1435
+ }
1436
+ if (!("creditMemoNumber" in sdkInput)) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Frozen credit-note mapping produced the wrong accounting document kind");
1437
+ const request = prepareQuickBooksCreditMemoRequest(sdkInput, identity);
1438
+ return {
1439
+ request,
1440
+ accountingInput: {
1441
+ ...sdkInput,
1442
+ quickbooksCreditMemoRequest: request
1443
+ }
1444
+ };
1445
+ }
1446
+ function requestIdFor(snapshot, mapping) {
1447
+ return `smrt_${createHash("sha256").update(JSON.stringify([
1448
+ "quickbooks",
1449
+ mapping.realmId,
1450
+ mapping.environment,
1451
+ snapshot.document.id,
1452
+ snapshot.moneySnapshotId
1453
+ ])).digest("hex").slice(0, 40)}`;
1454
+ }
1455
+ async function prepareSync(input) {
1456
+ if (input.provider.type !== "quickbooks") unsupported(`Frozen accounting sync does not support provider '${input.provider.type}'`);
1457
+ return withDocumentMoneyTransaction(input.db, async (tx) => {
1458
+ await lockTenantMoneyRuntime(tx, input.tenantId);
1459
+ const { invoice } = await lockAndReloadInvoice(tx, input.invoiceId, input.tenantId);
1460
+ const snapshot = await loadSnapshot(tx, input.tenantId, invoice);
1461
+ const sdkInput = toSdkInput(snapshot, input.mapping);
1462
+ const prior = parseSyncState(invoice.accountingSyncStateJson);
1463
+ if (prior?.status === "synced" && sameSyncScope(prior.identity, snapshot, input.mapping)) {
1464
+ if (!prior.externalId || !prior.syncedAt) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Completed accounting sync state is incomplete");
1465
+ return {
1466
+ kind: "unchanged",
1467
+ result: {
1468
+ status: "unchanged",
1469
+ externalId: prior.externalId,
1470
+ syncedAt: new Date(prior.syncedAt),
1471
+ moneySnapshotId: prior.identity.moneySnapshotId
1472
+ }
1473
+ };
1474
+ }
1475
+ if (prior?.status === "in_flight") throw new MoneyValidationError("ACCOUNTING_SYNC_IN_PROGRESS", "Another accounting sync owns this invoice");
1476
+ if (prior?.status === "unknown") throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "The prior accounting write outcome is unknown and must be reconciled");
1477
+ if (prior?.status === "synced" || invoice.externalId) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Invoice accounting identity changed across provider, realm, environment, or snapshot");
1478
+ if (snapshot.document.kind === "creditMemo") try {
1479
+ requireCreditMemoOperations(input.provider);
1480
+ } catch (error) {
1481
+ unsupported(error instanceof Error ? error.message : String(error));
1482
+ }
1483
+ const { accountingInput, request } = prepareRequest(snapshot, input.mapping, sdkInput);
1484
+ const identity = {
1485
+ provider: "quickbooks",
1486
+ documentKind: snapshot.document.kind,
1487
+ realmId: request.realmId,
1488
+ environment: request.environment,
1489
+ invoiceId: snapshot.document.id,
1490
+ moneySnapshotId: snapshot.moneySnapshotId,
1491
+ requestId: request.requestId,
1492
+ payloadHash: request.payloadHash,
1493
+ creditedInvoiceExternalId: "creditedInvoiceExternalId" in request ? request.creditedInvoiceExternalId : void 0
1494
+ };
1495
+ if (prior && !sameIdentity(prior.identity, identity)) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Invoice accounting identity changed across provider, realm, environment, or snapshot");
1496
+ const attemptId = randomUUID();
1497
+ invoice.accountingSyncStateJson = JSON.stringify({
1498
+ version: 1,
1499
+ status: "in_flight",
1500
+ identity,
1501
+ attemptId,
1502
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1503
+ });
1504
+ await invoice.saveDerived(DOCUMENT_MONEY_WRITE);
1505
+ return {
1506
+ kind: "push",
1507
+ documentKind: snapshot.document.kind,
1508
+ accountingInput,
1509
+ identity,
1510
+ attemptId
1511
+ };
1512
+ });
1513
+ }
1514
+ async function recordFailure(input, prepared, error) {
1515
+ const rejected = !!error && typeof error === "object" && error.code === "QBO_WRITE_ERROR" && error.outcome === "rejected";
1516
+ await withDocumentMoneyTransaction(input.db, async (tx) => {
1517
+ await lockTenantMoneyRuntime(tx, input.tenantId);
1518
+ const { invoice } = await lockAndReloadInvoice(tx, input.invoiceId, input.tenantId);
1519
+ const state = parseSyncState(invoice.accountingSyncStateJson);
1520
+ if (!state || state.attemptId !== prepared.attemptId || !sameIdentity(state.identity, prepared.identity)) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Accounting sync ownership changed after provider failure");
1521
+ invoice.accountingSyncStateJson = JSON.stringify({
1522
+ ...state,
1523
+ status: rejected ? "rejected" : "unknown"
1524
+ });
1525
+ await invoice.saveDerived(DOCUMENT_MONEY_WRITE);
1526
+ });
1527
+ }
1528
+ async function recordSuccess(input, prepared, result) {
1529
+ requiredText(result.externalId, "Accounting external ID");
1530
+ if (!(result.syncedAt instanceof Date) || Number.isNaN(result.syncedAt.valueOf())) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Accounting provider returned an invalid sync timestamp");
1531
+ return withDocumentMoneyTransaction(input.db, async (tx) => {
1532
+ await lockTenantMoneyRuntime(tx, input.tenantId);
1533
+ const { invoice } = await lockAndReloadInvoice(tx, input.invoiceId, input.tenantId);
1534
+ const state = parseSyncState(invoice.accountingSyncStateJson);
1535
+ if (state?.status !== "in_flight" || state.attemptId !== prepared.attemptId || !sameIdentity(state.identity, prepared.identity) || invoice.moneySnapshotId !== prepared.identity.moneySnapshotId) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Accounting sync ownership or frozen snapshot changed after provider write");
1536
+ const syncedAt = result.syncedAt.toISOString();
1537
+ invoice.externalId = result.externalId;
1538
+ invoice.externalProvider = prepared.identity.provider;
1539
+ invoice.syncedAt = result.syncedAt;
1540
+ invoice.accountingSyncStateJson = JSON.stringify({
1541
+ ...state,
1542
+ status: "synced",
1543
+ externalId: result.externalId,
1544
+ syncedAt
1545
+ });
1546
+ await invoice.saveDerived(DOCUMENT_MONEY_WRITE);
1547
+ return {
1548
+ status: "synced",
1549
+ externalId: result.externalId,
1550
+ syncedAt: result.syncedAt,
1551
+ moneySnapshotId: prepared.identity.moneySnapshotId
1552
+ };
1553
+ });
1554
+ }
1555
+ async function syncFrozenInvoiceToAccounting(input) {
1556
+ assertPrincipalTenant(input.tenantId);
1557
+ const prepared = await prepareSync(input);
1558
+ if (prepared.kind === "unchanged") return prepared.result;
1559
+ let result;
1560
+ try {
1561
+ if (prepared.documentKind === "invoice") {
1562
+ if (!("invoiceNumber" in prepared.accountingInput)) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Prepared invoice has the wrong accounting document kind");
1563
+ result = await input.provider.invoices.push(prepared.accountingInput);
1564
+ } else {
1565
+ if (!("creditMemoNumber" in prepared.accountingInput)) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Prepared credit note has the wrong accounting document kind");
1566
+ result = await requireCreditMemoOperations(input.provider).push(prepared.accountingInput);
1567
+ }
1568
+ } catch (error) {
1569
+ await recordFailure(input, prepared, error);
1570
+ throw error;
1571
+ }
1572
+ return recordSuccess(input, prepared, result);
1573
+ }
1574
+ async function reconcileFrozenInvoiceAccountingSync(input) {
1575
+ assertPrincipalTenant(input.tenantId);
1576
+ requiredText(input.requestId, "Accounting request ID");
1577
+ requiredText(input.attemptId, "Accounting attempt ID");
1578
+ return withDocumentMoneyTransaction(input.db, async (tx) => {
1579
+ await lockTenantMoneyRuntime(tx, input.tenantId);
1580
+ const { invoice } = await lockAndReloadInvoice(tx, input.invoiceId, input.tenantId);
1581
+ const snapshot = await loadSnapshot(tx, input.tenantId, invoice);
1582
+ const sdkInput = toSdkInput(snapshot, input.mapping);
1583
+ const { request } = prepareRequest(snapshot, input.mapping, sdkInput);
1584
+ const identity = {
1585
+ provider: "quickbooks",
1586
+ documentKind: snapshot.document.kind,
1587
+ realmId: request.realmId,
1588
+ environment: request.environment,
1589
+ invoiceId: snapshot.document.id,
1590
+ moneySnapshotId: snapshot.moneySnapshotId,
1591
+ requestId: request.requestId,
1592
+ payloadHash: request.payloadHash,
1593
+ creditedInvoiceExternalId: "creditedInvoiceExternalId" in request ? request.creditedInvoiceExternalId : void 0
1594
+ };
1595
+ const state = parseSyncState(invoice.accountingSyncStateJson);
1596
+ if (!state || state.status !== "unknown" && state.status !== "in_flight" || state.identity.requestId !== input.requestId || state.attemptId !== input.attemptId || !sameIdentity(state.identity, identity)) throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", "Accounting reconciliation does not match the unresolved frozen request");
1597
+ if (state.status === "in_flight" && input.confirmInterruptedOwnerStopped !== true) throw new MoneyValidationError("ACCOUNTING_SYNC_IN_PROGRESS", "Accounting reconciliation cannot release a live sync owner");
1598
+ if (input.outcome.status === "absent") {
1599
+ invoice.accountingSyncStateJson = JSON.stringify({
1600
+ ...state,
1601
+ status: "rejected"
1602
+ });
1603
+ await invoice.saveDerived(DOCUMENT_MONEY_WRITE);
1604
+ return {
1605
+ status: "ready",
1606
+ moneySnapshotId: identity.moneySnapshotId,
1607
+ requestId: identity.requestId
1608
+ };
1609
+ }
1610
+ const externalId = requiredText(input.outcome.externalId, "Accounting external ID");
1611
+ if (!(input.outcome.syncedAt instanceof Date) || Number.isNaN(input.outcome.syncedAt.valueOf())) throw new MoneyValidationError("INVALID_MONEY_INPUT", "Accounting reconciliation requires a valid sync timestamp");
1612
+ const syncedAt = input.outcome.syncedAt.toISOString();
1613
+ invoice.externalId = externalId;
1614
+ invoice.externalProvider = identity.provider;
1615
+ invoice.syncedAt = input.outcome.syncedAt;
1616
+ invoice.accountingSyncStateJson = JSON.stringify({
1617
+ ...state,
1618
+ status: "synced",
1619
+ externalId,
1620
+ syncedAt
1621
+ });
1622
+ await invoice.saveDerived(DOCUMENT_MONEY_WRITE);
1623
+ return {
1624
+ status: "synced",
1625
+ externalId,
1626
+ syncedAt: input.outcome.syncedAt,
1627
+ moneySnapshotId: identity.moneySnapshotId
1628
+ };
1629
+ });
1630
+ }
1631
+ function readFrozenAccountingSyncState(invoice) {
1632
+ const state = parseSyncState(invoice.accountingSyncStateJson);
1633
+ if (!state) return null;
1634
+ return {
1635
+ status: state.status,
1636
+ provider: state.identity.provider,
1637
+ realmId: state.identity.realmId,
1638
+ environment: state.identity.environment,
1639
+ documentKind: state.identity.documentKind,
1640
+ moneySnapshotId: state.identity.moneySnapshotId,
1641
+ requestId: state.identity.requestId,
1642
+ attemptId: state.attemptId,
1643
+ startedAt: state.startedAt,
1644
+ request: {
1645
+ requestId: state.identity.requestId,
1646
+ realmId: state.identity.realmId,
1647
+ environment: state.identity.environment,
1648
+ payloadHash: state.identity.payloadHash,
1649
+ ...state.identity.documentKind === "creditMemo" ? { creditedInvoiceExternalId: requiredText(state.identity.creditedInvoiceExternalId ?? "", "Credited invoice external ID") } : {}
1650
+ }
1651
+ };
1652
+ }
1653
+ //#endregion
1654
+ //#region src/svelte/invoices/calculations.ts
1655
+ function numberDecimal(value, precision) {
1656
+ if (!Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) throw new RangeError("Invoice rate or quantity is outside the supported range.");
1657
+ const [mantissa, exponent = "0"] = value.toString().split("e");
1658
+ const [whole, fraction = ""] = mantissa.split(".");
1659
+ const places = fraction.length - Number(exponent);
1660
+ if (places > precision) throw new RangeError("Invoice rate or quantity exceeds the supported decimal precision.");
1661
+ const coefficient = BigInt(whole + fraction);
1662
+ return places < 0 ? {
1663
+ coefficient: coefficient * 10n ** BigInt(-places),
1664
+ scale: 1n
1665
+ } : {
1666
+ coefficient,
1667
+ scale: 10n ** BigInt(places)
1668
+ };
1669
+ }
1670
+ function decimalText(value) {
1671
+ if (value.scale === 1n) return value.coefficient.toString();
1672
+ const places = value.scale.toString().length - 1;
1673
+ const digits = value.coefficient.toString().padStart(places + 1, "0");
1674
+ const split = digits.length - places;
1675
+ return `${digits.slice(0, split)}.${digits.slice(split)}`.replace(/0+$/, "").replace(/\.$/, "");
1676
+ }
1677
+ function rateTaxCode(percent) {
1678
+ const normalized = percent.replace(/^0+(?=\d)/, "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "") || "0";
1679
+ return normalized === "0" ? {
1680
+ id: "rate-zero",
1681
+ kind: "zero-rated",
1682
+ components: []
1683
+ } : {
1684
+ id: `rate-${normalized}`,
1685
+ kind: "taxable",
1686
+ components: [{
1687
+ taxComponentId: `rate-${normalized}`,
1688
+ percent: normalized,
1689
+ compound: false
1690
+ }]
1691
+ };
1692
+ }
1693
+ function calculateInvoiceMinorLine(input) {
1694
+ for (const amount of [input.unitPrice, input.discount]) if (!Number.isSafeInteger(amount)) throw new RangeError("Invoice money must be safe integer minor units.");
1695
+ if (input.taxRate > 1) throw new RangeError("Invoice tax rate is outside the supported range.");
1696
+ numberDecimal(input.quantity, 6);
1697
+ const tax = numberDecimal(input.taxRate, 8);
1698
+ const percent = decimalText({
1699
+ coefficient: tax.coefficient * 100n,
1700
+ scale: tax.scale
1701
+ });
1702
+ let result;
1703
+ try {
1704
+ result = calculateMoneyDocument({
1705
+ currency: "USD",
1706
+ lineAmountType: "exclusive",
1707
+ roundingScope: "line",
1708
+ lines: [{
1709
+ id: "line",
1710
+ quantity: input.quantity,
1711
+ unitPrice: input.unitPrice,
1712
+ discount: input.discount === 0 ? { kind: "none" } : {
1713
+ kind: "amount",
1714
+ minor: input.discount
1715
+ },
1716
+ taxCode: rateTaxCode(percent)
1717
+ }]
1718
+ }).lines[0];
1719
+ } catch (cause) {
1720
+ if (cause instanceof MoneyValidationError) throw new RangeError(cause.message, { cause });
1721
+ throw cause;
1722
+ }
1723
+ return {
1724
+ grossMinor: result.grossMinor,
1725
+ discountMinor: result.discountMinor,
1726
+ subtotalMinor: result.netMinor,
1727
+ taxMinor: result.taxMinor,
1728
+ totalMinor: result.netMinor + result.taxMinor
1729
+ };
1730
+ }
1731
+ //#endregion
1732
+ //#region src/retention-accounting.ts
1733
+ var hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
1734
+ function reject(message) {
1735
+ throw new MoneyValidationError("ACCOUNTING_SYNC_RECONCILIATION_REQUIRED", message);
1736
+ }
1737
+ function text(value, label) {
1738
+ if (typeof value !== "string" || !value || value.trim() !== value) reject(`${label} is required`);
1739
+ }
1740
+ function date(value, label) {
1741
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value) reject(`${label} is invalid`);
1742
+ }
1743
+ function principal(input) {
1744
+ if (!input.tenantId || getTenantId() !== input.tenantId) reject("Retention accounting requires its active tenant");
1745
+ if (isMoneyTransaction(input.db)) reject("Provider writes cannot run inside a caller-owned money transaction");
1746
+ if (input.provider.type !== "quickbooks" || !input.provider.receivableTransfers) reject("Provider lacks the reviewed receivable transfer capability");
1747
+ }
1748
+ function readRetainedInvoiceAccountingState(invoice) {
1749
+ if (!invoice.retentionAccountingStateJson) return null;
1750
+ let state;
1751
+ try {
1752
+ state = JSON.parse(invoice.retentionAccountingStateJson);
1753
+ } catch {
1754
+ return reject("Malformed retained accounting state");
1755
+ }
1756
+ if (state.version !== 1 || state.invoiceId !== invoice.id || state.moneySnapshotId !== invoice.moneySnapshotId || state.currency !== invoice.currency || state.grossMinor !== invoice.totalAmount || state.mapping.customerRef !== invoice.customerExternalId || !Array.isArray(state.steps) || !Array.isArray(state.releases) || !Array.isArray(state.credits) || !Array.isArray(state.refunds)) reject("Retention provider source identity changed");
1757
+ const retention = parseInvoiceRetention(invoice.retentionStateJson);
1758
+ if (!retention) reject("Provider retention agreement is missing");
1759
+ const { releases: _releases, ...agreement } = retention;
1760
+ if (state.retentionAgreementHash !== hash(agreement)) reject("Provider retention agreement changed");
1761
+ return state;
1762
+ }
1763
+ async function loadRetainedInvoiceAccountingSnapshot(input) {
1764
+ if (!input.tenantId || getTenantId() !== input.tenantId) reject("Retained accounting review requires its active tenant");
1765
+ return withDocumentMoneyTransaction(input.db, async (tx) => {
1766
+ await lockTenantMoneyRuntime(tx, input.tenantId);
1767
+ const { invoice } = await lockAndReloadInvoice(tx, input.invoiceId, input.tenantId);
1768
+ const retention = parseInvoiceRetention(invoice.retentionStateJson);
1769
+ if (!retention || retention.retainedMinor <= 0 || invoice.kind !== "invoice" || invoice.moneyAuthority === "historical") reject("Retained accounting review requires a calculated retained invoice");
1770
+ readRetainedInvoiceAccountingState(invoice);
1771
+ return withRetentionAccountingAuthority(input.tenantId, input.invoiceId, () => loadFrozenInvoiceAccountingSnapshot({
1772
+ ...input,
1773
+ db: tx
1774
+ }));
1775
+ });
1776
+ }
1777
+ async function toRetainedInvoiceAccountingInput(input) {
1778
+ return mapFrozenAccountingSnapshot((await loadRetainedInvoiceAccountingSnapshot(input)).document, input.mapping);
1779
+ }
1780
+ async function reviewRetainedCredit(input, read) {
1781
+ if (!input.tenantId || getTenantId() !== input.tenantId) reject("Retained credit review requires its active tenant");
1782
+ return withDocumentMoneyTransaction(input.db, async (tx) => {
1783
+ const original = await loadRetainedInvoiceAccountingSnapshot({
1784
+ ...input,
1785
+ db: tx
1786
+ });
1787
+ const { invoice: credit } = await lockAndReloadInvoice(tx, input.creditInvoiceId, input.tenantId);
1788
+ if (credit.kind !== "credit_note" || credit.creditedInvoiceId !== input.invoiceId || credit.customerId !== original.document.customerId || credit.currency !== original.document.currency || credit.moneyAuthority === "historical") reject("Retained credit review requires its exact calculated source lineage");
1789
+ return withRetentionAccountingAuthority(input.tenantId, input.invoiceId, () => read({
1790
+ db: tx,
1791
+ tenantId: input.tenantId,
1792
+ invoiceId: input.creditInvoiceId
1793
+ }));
1794
+ });
1795
+ }
1796
+ function loadRetainedCreditAccountingSnapshot(input) {
1797
+ return reviewRetainedCredit(input, loadFrozenInvoiceAccountingSnapshot);
1798
+ }
1799
+ function toRetainedCreditAccountingInput(input) {
1800
+ return reviewRetainedCredit(input, async (scope) => {
1801
+ return await toFrozenInvoiceAccountingInput({
1802
+ ...scope,
1803
+ mapping: input.mapping
1804
+ });
1805
+ });
1806
+ }
1807
+ async function locked(input, operation) {
1808
+ return withDocumentMoneyTransaction(input.db, async (tx) => {
1809
+ await lockTenantMoneyRuntime(tx, input.tenantId);
1810
+ const { invoice } = await lockAndReloadInvoice(tx, input.invoiceId, input.tenantId);
1811
+ return operation(invoice, readRetainedInvoiceAccountingState(invoice));
1812
+ });
1813
+ }
1814
+ async function save(invoice, state) {
1815
+ invoice.retentionAccountingStateJson = JSON.stringify(state);
1816
+ await invoice.saveDerived(DOCUMENT_MONEY_WRITE);
1817
+ }
1818
+ function refreshStatus(state) {
1819
+ state.status = state.steps.every((step) => step.status === "confirmed") && state.releases.every((event) => event.committed) && state.credits.every((event) => event.committed) && state.refunds.every((event) => event.committed) ? "ready" : "pending";
1820
+ }
1821
+ function requireReady(state) {
1822
+ if (!state || state.status !== "ready" || state.steps.some((s) => s.status !== "confirmed")) reject("Retention provider bundle requires reconciliation before a new operation");
1823
+ return state;
1824
+ }
1825
+ function requestId(state, key) {
1826
+ return `smrt_ret_${hash([
1827
+ state.invoiceId,
1828
+ state.moneySnapshotId,
1829
+ state.mapping.realmId,
1830
+ state.mapping.environment,
1831
+ key
1832
+ ]).slice(0, 40)}`;
1833
+ }
1834
+ function source(invoice, state) {
1835
+ if (!invoice.externalId || invoice.externalProvider !== "quickbooks") reject("Retention needs a reconciled provider invoice");
1836
+ return {
1837
+ type: "Invoice",
1838
+ id: invoice.externalId,
1839
+ customerRef: state.mapping.customerRef,
1840
+ evidenceRef: state.moneySnapshotId
1841
+ };
1842
+ }
1843
+ async function executeStep(input, key, make) {
1844
+ const prepared = await locked(input, async (invoice, state) => {
1845
+ if (!state) reject("Retention provider contract is missing");
1846
+ const expected = make(invoice, state), prior = state.steps.find((s) => s.key === key);
1847
+ if (prior && prior.request.payloadHash !== expected.payloadHash) reject("Retention operation identity reused with changed facts");
1848
+ if (prior?.status === "confirmed") {
1849
+ if (!prior.result) reject("Confirmed retention step has no readback");
1850
+ return { complete: prior.result };
1851
+ }
1852
+ if (prior && prior.status !== "rejected") reject("Retention operation has an unresolved remote owner");
1853
+ if (state.steps.some((s) => s.key !== key && s.status !== "confirmed")) reject("Another retention operation requires reconciliation");
1854
+ const step2 = {
1855
+ key,
1856
+ status: "in_flight",
1857
+ attemptId: randomUUID(),
1858
+ request: expected,
1859
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1860
+ priorAttempts: prior ? [...prior.priorAttempts ?? [], {
1861
+ attemptId: prior.attemptId,
1862
+ status: prior.status,
1863
+ startedAt: prior.startedAt,
1864
+ reconciliation: prior.reconciliation
1865
+ }] : []
1866
+ };
1867
+ if (prior) state.steps[state.steps.indexOf(prior)] = step2;
1868
+ else state.steps.push(step2);
1869
+ await save(invoice, state);
1870
+ return { step: step2 };
1871
+ });
1872
+ if ("complete" in prepared && prepared.complete) return prepared.complete;
1873
+ if (!prepared.step) reject("Prepared retention step is missing");
1874
+ const step = prepared.step;
1875
+ let result;
1876
+ try {
1877
+ result = await input.provider.receivableTransfers.execute(step.request);
1878
+ } catch (error) {
1879
+ await locked(input, async (invoice, state) => {
1880
+ const current = state?.steps.find((s) => s.key === key);
1881
+ if (!state || current?.attemptId !== step.attemptId || current.status !== "in_flight") reject("Retention step ownership changed after remote failure");
1882
+ current.status = error?.outcome === "rejected" ? "rejected" : "unknown";
1883
+ await save(invoice, state);
1884
+ });
1885
+ throw error;
1886
+ }
1887
+ return locked(input, async (invoice, state) => {
1888
+ const current = state?.steps.find((s) => s.key === key);
1889
+ if (!state || current?.attemptId !== step.attemptId || current.status !== "in_flight" || result.payloadHash !== step.request.payloadHash) reject("Retention step ownership or readback changed after write");
1890
+ current.status = "confirmed";
1891
+ current.result = structuredClone(result);
1892
+ await save(invoice, state);
1893
+ return result;
1894
+ });
1895
+ }
1896
+ async function syncRetainedInvoiceToAccounting(input) {
1897
+ principal(input);
1898
+ text(input.requestId, "Retention request");
1899
+ text(input.evidenceRef, "Retention evidence");
1900
+ date(input.effectiveOn, "Retention effective date");
1901
+ await input.provider.receivableTransfers.verifyMapping(input.mapping);
1902
+ await locked(input, async (invoice, state) => {
1903
+ if (input.mapping.realmId !== input.invoiceMapping.realmId || input.mapping.environment !== input.invoiceMapping.environment || input.mapping.currency !== invoice.currency || input.mapping.customerRef !== invoice.customerExternalId) reject("Retention mapping differs from frozen invoice provider authority");
1904
+ if (state) {
1905
+ if (hash([
1906
+ state.requestId,
1907
+ state.evidenceRef,
1908
+ state.effectiveOn,
1909
+ state.mapping,
1910
+ state.invoiceMapping
1911
+ ]) !== hash([
1912
+ input.requestId,
1913
+ input.evidenceRef,
1914
+ input.effectiveOn,
1915
+ input.mapping,
1916
+ input.invoiceMapping
1917
+ ])) reject("Retention provider contract is immutable");
1918
+ return;
1919
+ }
1920
+ if ("moneyAuthority" in invoice && invoice.moneyAuthority === "historical") reject("Historical source-owned invoices require an explicit reconciled provider authority contract; original journals cannot be duplicated");
1921
+ const retention = parseInvoiceRetention(invoice.retentionStateJson);
1922
+ if (!retention || retention.retainedMinor <= 0 || retention.releases.length || invoice.creditedAmount || invoice.amountPaid || !invoice.moneySnapshotId || invoice.kind !== "invoice" || [
1923
+ InvoiceStatus.DRAFT,
1924
+ InvoiceStatus.CANCELLED,
1925
+ InvoiceStatus.WRITTEN_OFF
1926
+ ].includes(invoice.status) || invoice.externalId || invoice.externalProvider || invoice.accountingSyncStateJson || invoice.collectionMethod || invoice.providerTaxAmount) reject("Initial provider retention requires an issued, uncredited, unpaid, unreleased host-owned retained invoice");
1927
+ const { releases: _releases, ...agreement } = retention;
1928
+ state = {
1929
+ version: 1,
1930
+ invoiceId: input.invoiceId,
1931
+ moneySnapshotId: invoice.moneySnapshotId,
1932
+ requestId: input.requestId,
1933
+ evidenceRef: input.evidenceRef,
1934
+ effectiveOn: input.effectiveOn,
1935
+ currency: invoice.currency,
1936
+ grossMinor: invoice.totalAmount,
1937
+ retainedMinor: retention.retainedMinor,
1938
+ retentionAgreementHash: hash(agreement),
1939
+ mapping: structuredClone(input.mapping),
1940
+ invoiceMapping: structuredClone(input.invoiceMapping),
1941
+ status: "pending",
1942
+ steps: [],
1943
+ releases: [],
1944
+ credits: [],
1945
+ refunds: []
1946
+ };
1947
+ await save(invoice, state);
1948
+ });
1949
+ await withRetentionAccountingAuthority(input.tenantId, input.invoiceId, () => syncFrozenInvoiceToAccounting({
1950
+ ...input,
1951
+ mapping: input.invoiceMapping
1952
+ }));
1953
+ const withheld = await executeStep(input, "withhold", (invoice, state) => prepareQuickBooksReceivableRequest({
1954
+ kind: "withhold",
1955
+ mapping: state.mapping,
1956
+ source: source(invoice, state),
1957
+ requestId: requestId(state, "withhold"),
1958
+ effectiveOn: state.effectiveOn,
1959
+ evidenceRef: state.evidenceRef,
1960
+ amountMinor: state.retainedMinor
1961
+ }));
1962
+ await executeStep(input, "withhold-application", (invoice, state) => prepareQuickBooksReceivableRequest({
1963
+ kind: "application",
1964
+ mapping: state.mapping,
1965
+ source: source(invoice, state),
1966
+ requestId: requestId(state, "withhold-application"),
1967
+ effectiveOn: state.effectiveOn,
1968
+ evidenceRef: state.evidenceRef,
1969
+ amountMinor: state.retainedMinor,
1970
+ debit: {
1971
+ type: "Invoice",
1972
+ id: invoice.externalId
1973
+ },
1974
+ credit: {
1975
+ type: "JournalEntry",
1976
+ id: withheld.externalId
1977
+ }
1978
+ }));
1979
+ return locked(input, async (invoice, state) => {
1980
+ if (!state) reject("Missing retained provider state");
1981
+ if (state.steps.some((step) => step.status !== "confirmed") || state.releases.some((release) => !release.committed)) reject("Another retention operation is unfinished");
1982
+ refreshStatus(state);
1983
+ await save(invoice, state);
1984
+ return state;
1985
+ });
1986
+ }
1987
+ async function releaseRetainedInvoiceToAccounting(input) {
1988
+ principal(input);
1989
+ validateRetentionEvidence(input);
1990
+ date(input.effectiveOn, "Release effective date");
1991
+ date(input.dueOn, "Release due date");
1992
+ if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor <= 0) reject("Release amount or due date is invalid");
1993
+ const key = `release:${input.requestId}`;
1994
+ await locked(input, async (invoice, state) => {
1995
+ if (!state) reject("Retention provider contract is missing");
1996
+ const prior = state.releases.find((r) => r.requestId === input.requestId);
1997
+ const release = {
1998
+ requestId: input.requestId,
1999
+ policyVersion: input.policyVersion,
2000
+ evidenceRef: input.evidenceRef,
2001
+ actorId: input.actorId,
2002
+ reason: input.reason,
2003
+ amountMinor: input.amountMinor,
2004
+ effectiveOn: input.effectiveOn,
2005
+ dueOn: input.dueOn,
2006
+ stepKey: key,
2007
+ committed: false
2008
+ };
2009
+ if (prior) {
2010
+ if (hash({
2011
+ ...prior,
2012
+ committed: false
2013
+ }) !== hash(release)) reject("Release request identity reused with changed facts");
2014
+ return;
2015
+ }
2016
+ requireReady(state);
2017
+ if (!invoice.updated_at || new Date(invoice.updated_at).toISOString() !== new Date(input.expectedUpdatedAt).toISOString()) reject("Stale retention release revision");
2018
+ if (invoice.creditedAmount !== state.credits.filter((c) => c.committed).reduce((sum, c) => sum + c.grossMinor, 0)) reject("Provider retention credit adjustment must reconcile before release");
2019
+ if (input.amountMinor > calculateInvoicePayable(invoice).retainedRemainingMinor) reject("Release exceeds retained capacity");
2020
+ state.releases.push(release);
2021
+ state.status = "pending";
2022
+ await save(invoice, state);
2023
+ });
2024
+ await executeStep(input, key, (invoice, state) => prepareQuickBooksReceivableRequest({
2025
+ kind: "release",
2026
+ mapping: state.mapping,
2027
+ source: source(invoice, state),
2028
+ requestId: requestId(state, key),
2029
+ effectiveOn: input.effectiveOn,
2030
+ evidenceRef: input.evidenceRef,
2031
+ amountMinor: input.amountMinor
2032
+ }));
2033
+ return locked(input, async (invoice, state) => {
2034
+ if (!state) reject("Retention provider contract is missing");
2035
+ const release = state.releases.find((r) => r.requestId === input.requestId);
2036
+ if (!release) reject("Release reservation disappeared");
2037
+ if (!release.committed) {
2038
+ if (invoice.creditedAmount !== state.credits.filter((c) => c.committed).reduce((sum, c) => sum + c.grossMinor, 0) || input.amountMinor > calculateInvoicePayable(invoice).retainedRemainingMinor) reject("Source capacity changed after provider release; reconciliation required");
2039
+ const retention = parseInvoiceRetention(invoice.retentionStateJson);
2040
+ retention.releases.push({
2041
+ requestId: release.requestId,
2042
+ policyVersion: release.policyVersion,
2043
+ evidenceRef: release.evidenceRef,
2044
+ actorId: release.actorId,
2045
+ reason: release.reason,
2046
+ amountMinor: release.amountMinor,
2047
+ effectiveOn: release.effectiveOn,
2048
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
2049
+ });
2050
+ invoice.retentionStateJson = JSON.stringify(retention);
2051
+ release.committed = true;
2052
+ }
2053
+ refreshStatus(state);
2054
+ await save(invoice, state);
2055
+ return state;
2056
+ });
2057
+ }
2058
+ async function reconcileRetainedAccountingStep(input) {
2059
+ principal(input);
2060
+ const step = await locked(input, async (_invoice, state) => {
2061
+ const found = state?.steps.find((s) => s.key === input.stepKey);
2062
+ if (!found || found.request.requestId !== input.requestId || found.attemptId !== input.attemptId || !["in_flight", "unknown"].includes(found.status)) reject("Observed retention reconciliation owner differs");
2063
+ if (found.status === "in_flight" && !input.confirmInterruptedOwnerStopped) reject("Interrupted retention owner must be proven stopped");
2064
+ return structuredClone(found);
2065
+ });
2066
+ const result = input.outcome.status === "found" ? await input.provider.receivableTransfers.read(step.request, input.outcome.externalId) : null;
2067
+ if (input.outcome.status === "absent") text(input.outcome.evidenceRef, "Authoritative provider absence evidence");
2068
+ return locked(input, async (invoice, state) => {
2069
+ const current = state?.steps.find((s) => s.key === input.stepKey);
2070
+ if (!state || current?.attemptId !== step.attemptId || current.status !== step.status) reject("Retention reconciliation owner changed");
2071
+ current.reconciliation = {
2072
+ outcome: input.outcome.status,
2073
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
2074
+ interruptedOwnerStopped: input.confirmInterruptedOwnerStopped === true,
2075
+ ...input.outcome.status === "found" ? { externalId: input.outcome.externalId } : { evidenceRef: input.outcome.evidenceRef }
2076
+ };
2077
+ if (result) {
2078
+ if (result.payloadHash !== step.request.payloadHash) reject("Retention reconciliation readback differs");
2079
+ current.result = result;
2080
+ current.status = "confirmed";
2081
+ } else current.status = "rejected";
2082
+ await save(invoice, state);
2083
+ return state;
2084
+ });
2085
+ }
2086
+ async function reconcileRetainedBaseInvoice(input) {
2087
+ principal(input);
2088
+ await locked(input, async (_invoice, state) => {
2089
+ if (!state || hash(state.invoiceMapping) !== hash(input.mapping)) reject("Retention base mapping differs");
2090
+ });
2091
+ return withRetentionAccountingAuthority(input.tenantId, input.invoiceId, () => reconcileFrozenInvoiceAccountingSync(input));
2092
+ }
2093
+ async function syncRetainedCreditToAccounting(input) {
2094
+ principal(input);
2095
+ text(input.requestId, "Credit adjustment request");
2096
+ text(input.evidenceRef, "Credit adjustment evidence");
2097
+ date(input.effectiveOn, "Credit adjustment date");
2098
+ const reserved = await locked(input, async (invoice, state) => {
2099
+ if (!state) reject("Retention provider contract is missing");
2100
+ const credit = await (await InvoiceCollection.create({ db: invoice.db })).get({ id: input.creditInvoiceId }, { cache: false });
2101
+ if (!credit || credit.tenantId !== input.tenantId || credit.creditedInvoiceId !== invoice.id || credit.kind !== "credit_note" || !credit.moneySnapshotId || [InvoiceStatus.DRAFT, InvoiceStatus.CANCELLED].includes(credit.status) || credit.currency !== state.currency || credit.customerExternalId !== state.mapping.customerRef) reject("Credit adjustment requires its issued same-customer source credit");
2102
+ if (input.creditMapping.realmId !== state.mapping.realmId || input.creditMapping.environment !== state.mapping.environment) reject("Credit adjustment provider scope differs");
2103
+ const prior = state.credits.find((c) => c.creditId === input.creditInvoiceId || c.requestId === input.requestId);
2104
+ if (prior) {
2105
+ if (prior.creditId !== input.creditInvoiceId || prior.requestId !== input.requestId || prior.moneySnapshotId !== credit.moneySnapshotId || prior.grossMinor !== credit.totalAmount || prior.evidenceRef !== input.evidenceRef || prior.effectiveOn !== input.effectiveOn || hash(prior.mapping) !== hash(input.creditMapping)) reject("Credit adjustment identity reused with changed facts");
2106
+ return structuredClone(prior);
2107
+ }
2108
+ requireReady(state);
2109
+ if (state.credits.reduce((sum, c) => sum + c.grossMinor, 0) + credit.totalAmount > invoice.creditedAmount) reject("Provider credit exceeds issued source credit capacity");
2110
+ const remainingRetained = state.retainedMinor - state.releases.filter((r) => r.committed).reduce((sum, r) => sum + r.amountMinor, 0) - state.credits.reduce((sum, c) => sum + c.retainedMinor, 0);
2111
+ const retainedMinor = Math.min(remainingRetained, credit.totalAmount);
2112
+ if (retainedMinor < 0 || retainedMinor > credit.creditedAmount) reject("Retained-first credit exceeds its approved source allocation");
2113
+ const next = {
2114
+ creditId: input.creditInvoiceId,
2115
+ moneySnapshotId: credit.moneySnapshotId,
2116
+ requestId: input.requestId,
2117
+ evidenceRef: input.evidenceRef,
2118
+ effectiveOn: input.effectiveOn,
2119
+ grossMinor: credit.totalAmount,
2120
+ retainedMinor,
2121
+ appliedMinor: credit.creditedAmount - retainedMinor,
2122
+ mapping: structuredClone(input.creditMapping),
2123
+ committed: false
2124
+ };
2125
+ state.credits.push(next);
2126
+ state.status = "pending";
2127
+ await save(invoice, state);
2128
+ return next;
2129
+ });
2130
+ const creditResult = await withRetentionAccountingAuthority(input.tenantId, input.invoiceId, () => syncFrozenInvoiceToAccounting({
2131
+ db: input.db,
2132
+ tenantId: input.tenantId,
2133
+ invoiceId: input.creditInvoiceId,
2134
+ provider: input.provider,
2135
+ mapping: input.creditMapping
2136
+ }));
2137
+ const prefix = `credit:${input.requestId}`;
2138
+ const creditSource = (state) => ({
2139
+ type: "CreditMemo",
2140
+ id: creditResult.externalId,
2141
+ customerRef: state.mapping.customerRef,
2142
+ evidenceRef: reserved.moneySnapshotId
2143
+ });
2144
+ if (reserved.retainedMinor > 0) {
2145
+ const cancelled = await executeStep(input, `${prefix}:retention`, (_invoice, state) => prepareQuickBooksReceivableRequest({
2146
+ kind: "release",
2147
+ mapping: state.mapping,
2148
+ source: creditSource(state),
2149
+ requestId: requestId(state, `${prefix}:retention`),
2150
+ effectiveOn: reserved.effectiveOn,
2151
+ evidenceRef: reserved.evidenceRef,
2152
+ amountMinor: reserved.retainedMinor
2153
+ }));
2154
+ await executeStep(input, `${prefix}:retention-application`, (_invoice, state) => prepareQuickBooksReceivableRequest({
2155
+ kind: "application",
2156
+ mapping: state.mapping,
2157
+ source: creditSource(state),
2158
+ requestId: requestId(state, `${prefix}:retention-application`),
2159
+ effectiveOn: reserved.effectiveOn,
2160
+ evidenceRef: reserved.evidenceRef,
2161
+ amountMinor: reserved.retainedMinor,
2162
+ debit: {
2163
+ type: "JournalEntry",
2164
+ id: cancelled.externalId
2165
+ },
2166
+ credit: {
2167
+ type: "CreditMemo",
2168
+ id: creditResult.externalId
2169
+ }
2170
+ }));
2171
+ }
2172
+ if (reserved.appliedMinor > 0) await executeStep(input, `${prefix}:due-application`, (invoice, state) => prepareQuickBooksReceivableRequest({
2173
+ kind: "application",
2174
+ mapping: state.mapping,
2175
+ source: creditSource(state),
2176
+ requestId: requestId(state, `${prefix}:due-application`),
2177
+ effectiveOn: reserved.effectiveOn,
2178
+ evidenceRef: reserved.evidenceRef,
2179
+ amountMinor: reserved.appliedMinor,
2180
+ debit: {
2181
+ type: "Invoice",
2182
+ id: invoice.externalId
2183
+ },
2184
+ credit: {
2185
+ type: "CreditMemo",
2186
+ id: creditResult.externalId
2187
+ }
2188
+ }));
2189
+ return locked(input, async (invoice, state) => {
2190
+ if (!state) reject("Retention provider state is missing");
2191
+ const credit = state.credits.find((c) => c.creditId === input.creditInvoiceId);
2192
+ if (!credit) reject("Credit reservation disappeared");
2193
+ credit.committed = true;
2194
+ refreshStatus(state);
2195
+ await save(invoice, state);
2196
+ return state;
2197
+ });
2198
+ }
2199
+ async function syncRetainedRefundToAccounting(input) {
2200
+ principal(input);
2201
+ text(input.requestId, "Refund recording request");
2202
+ text(input.evidenceRef, "Refund settlement evidence");
2203
+ date(input.effectiveOn, "Refund effective date");
2204
+ const reservation = await locked(input, async (invoice, state) => {
2205
+ if (!state) reject("Retention provider contract is missing");
2206
+ const credit = state.credits.find((c) => c.creditId === input.creditInvoiceId && c.committed);
2207
+ if (!credit) reject("Refund requires its reconciled provider credit");
2208
+ const payment = await (await PaymentCollection.create({ db: invoice.db })).get({ id: input.paymentId }, { cache: false });
2209
+ const creditInvoice = await (await InvoiceCollection.create({ db: invoice.db })).get({ id: input.creditInvoiceId }, { cache: false });
2210
+ if (!payment || !creditInvoice || payment.tenantId !== input.tenantId || payment.customerId !== invoice.customerId || payment.currency !== state.currency || payment.kind !== "refund" || payment.status !== "completed" || !payment.journalId || !payment.paidAt || !creditInvoice.externalId || creditInvoice.moneySnapshotId !== credit.moneySnapshotId) reject("Provider refund requires its completed same-customer local cash journal and source credit");
2211
+ const allocations = await (await PaymentAllocationCollection.create({ db: invoice.db })).findByPayment(input.paymentId);
2212
+ if (allocations.length !== 1 || allocations[0].invoiceId !== input.creditInvoiceId || allocations[0].amount !== payment.amount) reject("Refund recording requires one exact full source-credit allocation");
2213
+ const fingerprint = hash([
2214
+ payment.id,
2215
+ payment.customerId,
2216
+ payment.currency,
2217
+ payment.amount,
2218
+ payment.journalId,
2219
+ new Date(payment.paidAt).toISOString(),
2220
+ allocations[0].id,
2221
+ allocations[0].amount,
2222
+ credit.moneySnapshotId
2223
+ ]);
2224
+ const next = {
2225
+ paymentId: input.paymentId,
2226
+ creditId: input.creditInvoiceId,
2227
+ requestId: input.requestId,
2228
+ evidenceRef: input.evidenceRef,
2229
+ effectiveOn: input.effectiveOn,
2230
+ amountMinor: payment.amount,
2231
+ paymentFingerprint: fingerprint,
2232
+ committed: false
2233
+ };
2234
+ const prior = state.refunds.find((r) => r.paymentId === input.paymentId || r.requestId === input.requestId);
2235
+ if (prior) {
2236
+ if (hash({
2237
+ ...prior,
2238
+ committed: false
2239
+ }) !== hash(next)) reject("Refund recording identity reused with changed facts");
2240
+ return {
2241
+ ...prior,
2242
+ externalCreditId: creditInvoice.externalId
2243
+ };
2244
+ }
2245
+ requireReady(state);
2246
+ if (!state.mapping.cashAccountRef) reject("Refund provider bank account must be explicitly reviewed");
2247
+ if (state.refunds.filter((r) => r.creditId === input.creditInvoiceId).reduce((sum, r) => sum + r.amountMinor, 0) + payment.amount > credit.grossMinor - credit.retainedMinor - credit.appliedMinor) reject("Refund exceeds unapplied provider credit capacity");
2248
+ if (payment.retentionAccountingBindingJson) reject("Refund is already bound to a provider operation");
2249
+ payment.retentionAccountingBindingJson = JSON.stringify({
2250
+ invoiceId: input.invoiceId,
2251
+ tenantId: input.tenantId,
2252
+ requestId: input.requestId,
2253
+ paymentFingerprint: fingerprint
2254
+ });
2255
+ await withRetentionAccountingAuthority(input.tenantId, input.invoiceId, () => payment.save());
2256
+ state.refunds.push(next);
2257
+ state.status = "pending";
2258
+ await save(invoice, state);
2259
+ return {
2260
+ ...next,
2261
+ externalCreditId: creditInvoice.externalId
2262
+ };
2263
+ });
2264
+ const prefix = `refund:${input.requestId}`;
2265
+ const creditSource = (state) => ({
2266
+ type: "CreditMemo",
2267
+ id: reservation.externalCreditId,
2268
+ customerRef: state.mapping.customerRef,
2269
+ evidenceRef: reservation.paymentFingerprint
2270
+ });
2271
+ const journal = await executeStep(input, prefix, (_invoice, state) => prepareQuickBooksReceivableRequest({
2272
+ kind: "cash-refund",
2273
+ mapping: state.mapping,
2274
+ source: creditSource(state),
2275
+ requestId: requestId(state, prefix),
2276
+ effectiveOn: reservation.effectiveOn,
2277
+ evidenceRef: reservation.evidenceRef,
2278
+ amountMinor: reservation.amountMinor
2279
+ }));
2280
+ await executeStep(input, `${prefix}:application`, (_invoice, state) => prepareQuickBooksReceivableRequest({
2281
+ kind: "application",
2282
+ mapping: state.mapping,
2283
+ source: creditSource(state),
2284
+ requestId: requestId(state, `${prefix}:application`),
2285
+ effectiveOn: reservation.effectiveOn,
2286
+ evidenceRef: reservation.evidenceRef,
2287
+ amountMinor: reservation.amountMinor,
2288
+ debit: {
2289
+ type: "JournalEntry",
2290
+ id: journal.externalId
2291
+ },
2292
+ credit: {
2293
+ type: "CreditMemo",
2294
+ id: reservation.externalCreditId
2295
+ }
2296
+ }));
2297
+ return locked(input, async (invoice, state) => {
2298
+ if (!state) reject("Retention provider state disappeared");
2299
+ const refund = state.refunds.find((r) => r.requestId === input.requestId);
2300
+ if (!refund) reject("Refund reservation disappeared");
2301
+ refund.committed = true;
2302
+ refreshStatus(state);
2303
+ await save(invoice, state);
2304
+ return state;
2305
+ });
2306
+ }
2307
+ async function reconcileRetainedCreditAccounting(input) {
2308
+ principal(input);
2309
+ const mapping = await locked(input, async (_invoice, state) => {
2310
+ const reserved = state?.credits.find((credit) => credit.creditId === input.creditInvoiceId);
2311
+ if (!reserved) reject("Retained credit has no approved provider reservation");
2312
+ return structuredClone(reserved.mapping);
2313
+ });
2314
+ return withRetentionAccountingAuthority(input.tenantId, input.invoiceId, () => reconcileFrozenInvoiceAccountingSync({
2315
+ ...input,
2316
+ invoiceId: input.creditInvoiceId,
2317
+ mapping
2318
+ }));
2319
+ }
2320
+ //#endregion
2321
+ export { historicalCreditRefundCommittedTotal as C, Payment as E, PaymentAllocation as S, PaymentCollection_exports as T, reconcileFrozenInvoiceAccountingSync as _, reconcileRetainedBaseInvoice as a, PaymentAllocationCollection as b, syncRetainedCreditToAccounting as c, toRetainedCreditAccountingInput as d, toRetainedInvoiceAccountingInput as f, readFrozenAccountingSyncState as g, loadFrozenInvoiceAccountingSnapshot as h, reconcileRetainedAccountingStep as i, syncRetainedInvoiceToAccounting as l, invoice_accounting_sync_exports as m, loadRetainedInvoiceAccountingSnapshot as n, reconcileRetainedCreditAccounting as o, calculateInvoiceMinorLine as p, readRetainedInvoiceAccountingState as r, releaseRetainedInvoiceToAccounting as s, loadRetainedCreditAccountingSnapshot as t, syncRetainedRefundToAccounting as u, syncFrozenInvoiceToAccounting as v, PaymentCollection as w, PaymentAllocationCollection_exports as x, toFrozenInvoiceAccountingInput as y };
2322
+
2323
+ //# sourceMappingURL=retention-accounting-Brdj9x-V.js.map