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