@happyvertical/smrt-commerce 0.40.70 → 0.41.0
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 +9 -2
- package/README.md +8 -8
- package/dist/chunks/money-WSMvSgWq.js +8 -0
- package/dist/chunks/money-WSMvSgWq.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +297 -115
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +613 -387
- package/dist/migrations/moneyMinorUnits.d.ts +86 -0
- package/dist/migrations/moneyMinorUnits.d.ts.map +1 -0
- package/dist/models/Contract.d.ts +19 -9
- package/dist/models/Contract.d.ts.map +1 -1
- package/dist/models/ContractLineItem.d.ts +21 -7
- package/dist/models/ContractLineItem.d.ts.map +1 -1
- package/dist/models/Customer.d.ts +7 -1
- package/dist/models/Customer.d.ts.map +1 -1
- package/dist/models/Invoice.d.ts +43 -37
- package/dist/models/Invoice.d.ts.map +1 -1
- package/dist/models/InvoiceLineItem.d.ts +34 -10
- package/dist/models/InvoiceLineItem.d.ts.map +1 -1
- package/dist/models/Payment.d.ts +31 -11
- package/dist/models/Payment.d.ts.map +1 -1
- package/dist/models/PaymentAllocation.d.ts +9 -2
- package/dist/models/PaymentAllocation.d.ts.map +1 -1
- package/dist/models/PaymentIntent.d.ts +7 -2
- package/dist/models/PaymentIntent.d.ts.map +1 -1
- package/dist/models/Payout.d.ts +15 -10
- package/dist/models/Payout.d.ts.map +1 -1
- package/dist/models/Vendor.d.ts +3 -1
- package/dist/models/Vendor.d.ts.map +1 -1
- package/dist/money.d.ts +27 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +155 -93
- package/package.json +8 -7
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,7 @@ E-commerce with Contract STI hierarchy, invoice lifecycle, payment tracking, pay
|
|
|
16
16
|
- **Payment** / **PaymentAllocation**: tracks payments against invoices. Status controlled by `Invoice.updatePaymentStatus()`, not Payment model. Carries optional backend-adapter fields for PaymentBackend-routed flows: `backendId` (rail adapter id — `base-usdc`, `btc`, `stripe`, distinct from `externalProvider` which names an accounting sync destination), `backendTxRef` (chain tx hash or gateway settlement id), `nativeAmount` / `nativeCurrency` (what actually arrived), `usdAtQuote` / `usdAtConfirmation` (drift accounting for volatile-currency rails). `Payment.usdDrift()` returns the confirmation - quote delta, or `0` when either side is unset.
|
|
17
17
|
- **PaymentInstrument**: reusable saved payment method ("card on file"). Stores only provider references and non-sensitive display metadata (`providerCustomerId`, `providerPaymentMethodId`, brand / last4 / expiry), never raw card data. Use `PaymentInstrumentCollection.setDefaultForCustomer()` to change the default so the single-default-per-customer invariant is enforced; generated create/update routes cannot write `isDefault` directly.
|
|
18
18
|
- **PaymentIntent**: short-lived pre-payment commitment with multi-option semantics. Locks a USD price for a fixed window (default 15 minutes) and lists one or more `PaymentOption`s describing different rails (`backendId`, `currency`, `payTo`, `nativeAmount`, optional `chain` / `memo` / `x402Capable` / `expiresAt`). First option to receive payment wins; the others are implicitly retired. State machine `awaiting_payment → paid → (issued | retired)` plus `expired` and `cancelled`. Mutate via dedicated `markPaid` / `markIssued` / `expire` / `cancel` / `retire` helpers — direct status assignment bypasses the invariant checks. Idempotency via natural key `(tenant_id, offering_ref, licensee_email, idempotency_key)` and `PaymentIntentCollection.getOrCreateByIdempotencyKey()`.
|
|
19
|
-
- **Payout**: operator-to-supplier remittance. Distinct from Payment because direction, status machine, and chain semantics differ. Status machine `pending → sent → confirmed → failed` (failed is terminal but resettable via `resetFromFailed()` after fixing the underlying problem). References source `paymentId` (plain string) and destination `vendorId` (foreign key). Amount invariant `supplierNet === grossAmount - operatorFee` (
|
|
19
|
+
- **Payout**: operator-to-supplier remittance. Distinct from Payment because direction, status machine, and chain semantics differ. Status machine `pending → sent → confirmed → failed` (failed is terminal but resettable via `resetFromFailed()` after fixing the underlying problem). References source `paymentId` (plain string) and destination `vendorId` (foreign key). Amount invariant `supplierNet === grossAmount - operatorFee` enforced exactly on save (integer minor units, no tolerance — #2401). `PayoutCollection.createFromPayment()` is the typical entry point; it pulls native amount / currency from the source Payment.
|
|
20
20
|
- **Fulfillment** / **FulfillmentLineItem**: shipment/delivery tracking.
|
|
21
21
|
|
|
22
22
|
## Ledger Integration
|
|
@@ -35,7 +35,14 @@ E-commerce with Contract STI hierarchy, invoice lifecycle, payment tracking, pay
|
|
|
35
35
|
## Gotchas
|
|
36
36
|
|
|
37
37
|
- **Optional tenancy**: all models `@TenantScoped({ mode: 'optional' })` + nullable tenantId
|
|
38
|
-
- **Currency
|
|
38
|
+
- **Currency is integer minor units** (cents, satoshis) — `$19.99` is `1999`, like affiliates. Money is exact, so money fields initialize `= 0`, never `= 0.0`: the integer literal is what maps them to INTEGER columns. Writing a fractional major-unit value is the bug — the model rejects it via `assertIntegerMinorUnits` on `Invoice`/`Payment`/`PaymentAllocation`/`Payout`/`Contract` before the database sees it, because PostgreSQL would reject it with `22P02` while SQLite's affinity silently stores it. `pnpm --filter @happyvertical/smrt-commerce test:postgres` is the lane that holds the line (#2361, #2401).
|
|
39
|
+
- **Rates are the opposite**: `InvoiceLineItem.taxRate` and `ContractLineItem.taxRate` are inherently fractional and must initialize `= 0.0`. INTEGER would truncate every rate to 0. `ContractLineItem.quantity` is decimal for the same reason (contracts price fractional hours/weight/bandwidth); `InvoiceLineItem.quantity` is an integer count.
|
|
40
|
+
- **Rounding happens where a rate meets money, and nowhere else**: `InvoiceLineItem.getTaxAmount()` and `ContractLineItem.calculateAmount()` `Math.round()` the fractional product to whole minor units. Everything downstream is exact integer arithmetic, which is why **there are no `EPSILON` tolerances left on any money path** (#2401). `Invoice`, `PaymentAllocation`, `Payout` and `PaymentIntent` compare with `===` / `>` / `>=`. Do not reintroduce a tolerance: against integers it forgives a real one-cent discrepancy rather than float fuzz. (`FulfillmentLineItem`'s quantity epsilon is unrelated — quantities are genuinely decimal there.)
|
|
41
|
+
- **Migrating an existing database to minor units**: `preflightCommerceMoneyMinorUnits(db)` reports, per column, whether it still holds major units and which rows would be rounded or overflow `int4`; `migrateCommerceMoneyToMinorUnits(db)` converts them. Both are exported from the package root. The rescale is idempotent via a `_smrt_backfills` marker, changes the column type in place on PostgreSQL/DuckDB (`ALTER … TYPE INTEGER USING round(col * 100)`), and on SQLite rescales values only — SQLite cannot alter a declared type in place, so those columns come back in `declaredTypeChangePending` and need the table-rebuild path (#2370).
|
|
42
|
+
- **`Payment.nativeAmount` is NOT in that migration, and must not be.** Its minor unit is a property of the *row's asset*: satoshis on a BTC rail (×1e8), cents on a fiat or stablecoin rail (×1e2), in one column discriminated only by `nativeCurrency`. A blanket ×100 turns 0.00713 BTC into `1` instead of `713000` sats, and a round 0.01 BTC even passes the preflight's integrality check while being wrong by six orders of magnitude. Convert it in two deliberate steps — normalise each row to its own asset's minor units while the column is still floating-point, then `rescaleMoneyColumnsToMinorUnits(db, COMMERCE_NATIVE_UNIT_COLUMNS, { scale: 1, … })` for the type change. `COMMERCE_NATIVE_UNIT_COLUMNS` is exported with a worked example; step one is deliberately the deployment's to write, because only it knows which `nativeCurrency` values it has used.
|
|
43
|
+
- **`PaymentIntent.paymentOptions[].nativeAmount` has the same per-asset problem inside a JSON column and is likewise not migrated.** An intent's price lock defaults to 15 minutes, so the supported answer is to let open intents expire and be re-quoted: an unconverted option fails the exact `!==` reconciliation against the payment that arrives, which is a loud failure rather than a silently mis-scaled quote.
|
|
44
|
+
- **`int4` ceiling**: INTEGER is 32-bit on PostgreSQL, so every money column tops out near 2.1e9 minor units — about $21.4M, or ~21 BTC in satoshis on `Payment.nativeAmount`. The preflight lists rows that would overflow. Widening to BIGINT is parked in #2373 and is a plain widening whenever it lands.
|
|
45
|
+
- **Journals posted from commerce carry minor units**: `Payment.recordPayment()` and `Invoice.recognizeRevenue()` write `amount` / `totalAmount` straight into smrt-ledgers entries. smrt-ledgers is unit-agnostic (its `BALANCE_EPSILON` only checks debits === credits), and integer entries balance exactly, so this is strictly tighter than the major-unit journals commerce used to post.
|
|
39
46
|
- **Invoice controls payment status**: not the Payment model — use `Invoice.updatePaymentStatus()`
|
|
40
47
|
- **Tax rate is external**: no tax rate field on Invoice — rate must be calculated externally
|
|
41
48
|
- **Profile linking**: separate `ProfileCollection.create()` needed to fetch actual Profile object
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
const customers = await CustomerCollection.create({ db });
|
|
24
24
|
const customer = await customers.create({
|
|
25
25
|
profileId: 'profile-uuid',
|
|
26
|
-
creditLimit:
|
|
26
|
+
creditLimit: 1000000, // $10,000.00 — money is integer minor units
|
|
27
27
|
paymentTerms: 'Net 30',
|
|
28
28
|
});
|
|
29
29
|
await customer.save();
|
|
@@ -33,9 +33,9 @@ const contracts = await ContractCollection.create({ db });
|
|
|
33
33
|
const order = await contracts.create({
|
|
34
34
|
_meta_type: 'Order',
|
|
35
35
|
customerId: customer.id,
|
|
36
|
-
subtotal:
|
|
37
|
-
taxAmount: 50.00
|
|
38
|
-
totalAmount:
|
|
36
|
+
subtotal: 100000, // $1,000.00
|
|
37
|
+
taxAmount: 5000, // $50.00
|
|
38
|
+
totalAmount: 105000, // $1,050.00
|
|
39
39
|
currency: 'CAD',
|
|
40
40
|
});
|
|
41
41
|
await order.save();
|
|
@@ -47,9 +47,9 @@ const invoice = await invoices.create({
|
|
|
47
47
|
customerId: customer.id,
|
|
48
48
|
contractId: order.id,
|
|
49
49
|
invoiceNumber,
|
|
50
|
-
subtotal:
|
|
51
|
-
taxAmount: 50.00
|
|
52
|
-
totalAmount:
|
|
50
|
+
subtotal: 100000, // $1,000.00
|
|
51
|
+
taxAmount: 5000, // $50.00
|
|
52
|
+
totalAmount: 105000, // $1,050.00
|
|
53
53
|
});
|
|
54
54
|
await invoice.save();
|
|
55
55
|
|
|
@@ -65,7 +65,7 @@ const payments = await PaymentCollection.create({ db });
|
|
|
65
65
|
const payment = await payments.create({
|
|
66
66
|
contractId: order.id,
|
|
67
67
|
customerId: customer.id,
|
|
68
|
-
amount:
|
|
68
|
+
amount: 105000,
|
|
69
69
|
method: PaymentMethod.CREDIT_CARD,
|
|
70
70
|
});
|
|
71
71
|
await payment.save();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/money.ts
|
|
2
|
+
function assertIntegerMinorUnits(context, label, entries) {
|
|
3
|
+
for (const [field, value] of entries) if (!Number.isFinite(value) || !Number.isInteger(value)) throw new Error(`${context} ${label}: ${field} must be an integer number of minor units (cents, satoshis) \u2014 got ${value}. Money is exact: $19.99 is 1999, not 19.99.`);
|
|
4
|
+
}
|
|
5
|
+
//#endregion
|
|
6
|
+
export { assertIntegerMinorUnits as t };
|
|
7
|
+
|
|
8
|
+
//# sourceMappingURL=money-WSMvSgWq.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"money-WSMvSgWq.js","names":[],"sources":["../../src/money.ts"],"sourcesContent":["/**\n * Money is exact: integer minor units, checked at the model boundary (#2401).\n *\n * Every money column in this package is INTEGER minor units — cents,\n * satoshis — so `$19.99` is `1999`. Before #2401 the models carried\n * `EPSILON = 0.01` tolerances on every comparison, because the values really\n * were floats and `0.1 * 3` really is `0.30000000000000004`. With exact\n * integers those tolerances are not merely unnecessary, they are wrong: a\n * one-cent discrepancy is a one-cent discrepancy, not rounding fuzz.\n *\n * Dropping the tolerances is only safe if the values are *actually* integers,\n * which is what this guard establishes. A fractional major-unit write (`19.99`\n * into a cents column) is the bug the whole convention exists to prevent;\n * PostgreSQL catches it with a `22P02` and SQLite's type affinity does not\n * catch it at all, so the model catches it first, on every engine and every\n * write surface.\n */\n\n/**\n * Throw unless every named value is a finite integer.\n *\n * @param context - Model name for the error message, e.g. `Invoice`.\n * @param label - Row identifier used in the error, e.g. an invoice number.\n * @param entries - `[fieldName, value]` pairs to check.\n * @throws When any value is non-finite or has a fractional part.\n */\nexport function assertIntegerMinorUnits(\n context: string,\n label: string,\n entries: ReadonlyArray<readonly [string, number]>,\n): void {\n for (const [field, value] of entries) {\n if (!Number.isFinite(value) || !Number.isInteger(value)) {\n throw new Error(\n `${context} ${label}: ${field} must be an integer number of minor ` +\n `units (cents, satoshis) — got ${value}. Money is exact: $19.99 is ` +\n '1999, not 19.99.',\n );\n }\n }\n}\n"],"mappings":";AA0BO,SAAS,wBACd,SACA,OACA,SACM;CACN,KAAA,MAAW,CAAC,OAAO,UAAU,SAC3B,IAAI,CAAC,OAAO,SAAS,KAAK,KAAK,CAAC,OAAO,UAAU,KAAK,GACpD,MAAM,IAAI,MACR,GAAG,QAAO,GAAI,MAAK,IAAK,MAAK,yEACM,MAAK,6CAE1C;AAGN"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { ContractCollection, ContractLineItemCollection, type CreatePayoutFromPaymentArgs, CustomerCollection, FulfillmentCollection, FulfillmentLineItemCollection, InvoiceCollection, InvoiceLineItemCollection, type InvoiceNumberOptions, PaymentAllocationCollection, PaymentCollection, PaymentInstrumentCollection, PaymentIntentCollection, type PaymentIntentIdempotencyArgs, type PaymentIntentSeed, PayoutCollection, UNPAID_STATUSES, VendorCollection, } from './collections/index.js';
|
|
2
|
+
export { COMMERCE_MONEY_COLUMNS, COMMERCE_MONEY_MINOR_UNITS_BACKFILL, COMMERCE_NATIVE_UNIT_COLUMNS, migrateCommerceMoneyToMinorUnits, preflightCommerceMoneyMinorUnits, } from './migrations/moneyMinorUnits.js';
|
|
2
3
|
export { Agreement, Cart, Contract, ContractLineItem, Customer, Estimate, Fulfillment, FulfillmentLineItem, Invoice, InvoiceLineItem, Lease, type LicenseRightsSnapshot, LicenseSale, Order, Payment, PaymentAllocation, PaymentInstrument, PaymentIntent, Payout, ProductionOrder, PurchaseOrder, Vendor, WholesaleOrder, } from './models/index.js';
|
|
4
|
+
export { assertIntegerMinorUnits } from './money.js';
|
|
3
5
|
export { type Address, ContractStatus, ContractType, CustomerStatus, CustomerType, FulfillmentStatus, FulfillmentType, InvoiceStatus, PaymentInstrumentStatus, PaymentIntentStatus, PaymentMethod, type PaymentOption, PaymentStatus, PayoutStatus, type RecognizeRevenueOptions, type RecordPaymentOptions, VendorStatus, } from './types/index.js';
|
|
4
6
|
export { COMMERCE_MODULE_META, COMMERCE_UI_SLOTS } from './ui.js';
|
|
5
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAKH,OAAO,wBAAwB,CAAC;AAGhC,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,KAAK,2BAA2B,EAChC,kBAAkB,EAClB,qBAAqB,EACrB,6BAA6B,EAC7B,iBAAiB,EACjB,yBAAyB,EACzB,KAAK,oBAAoB,EACzB,2BAA2B,EAC3B,iBAAiB,EACjB,2BAA2B,EAC3B,uBAAuB,EACvB,KAAK,4BAA4B,EACjC,KAAK,iBAAiB,EACtB,gBAAgB,EAChB,eAAe,EACf,gBAAgB,GACjB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EACL,sBAAsB,EACtB,mCAAmC,EACnC,4BAA4B,EAC5B,gCAAgC,EAChC,gCAAgC,GACjC,MAAM,iCAAiC,CAAC;AAEzC,OAAO,EACL,SAAS,EACT,IAAI,EACJ,QAAQ,EACR,gBAAgB,EAChB,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,mBAAmB,EACnB,OAAO,EACP,eAAe,EACf,KAAK,EACL,KAAK,qBAAqB,EAC1B,WAAW,EACX,KAAK,EACL,OAAO,EACP,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,MAAM,EACN,eAAe,EACf,aAAa,EACb,MAAM,EACN,cAAc,GACf,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAGrD,OAAO,EACL,KAAK,OAAO,EACZ,cAAc,EACd,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,iBAAiB,EACjB,eAAe,EACf,aAAa,EACb,uBAAuB,EACvB,mBAAmB,EACnB,aAAa,EACb,KAAK,aAAa,EAClB,aAAa,EACb,YAAY,EACZ,KAAK,uBAAuB,EAC5B,KAAK,oBAAoB,EACzB,YAAY,GACb,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC"}
|