@happyvertical/smrt-commerce 0.53.4 → 0.53.6
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 +19 -3
- package/README.md +29 -1
- package/agents/commercial-ui.md +78 -0
- package/agents/customer-vendor-ui.md +76 -0
- package/agents/invoice-calculations.md +17 -0
- package/agents/invoice-preparation.md +86 -0
- package/agents/invoice-quantity.md +46 -0
- package/agents/pricing-versions.md +28 -0
- package/agents/purchasing-ui.md +82 -0
- package/agents/quote-ui.md +82 -0
- package/dist/billing/units.d.ts +1 -7
- package/dist/billing/units.d.ts.map +1 -1
- package/dist/chunks/calculations-DPuldr1J.js +242 -0
- package/dist/chunks/calculations-DPuldr1J.js.map +1 -0
- package/dist/chunks/types-CRnln577.js.map +1 -1
- package/dist/chunks/{units-CpBepqG1.js → units-CJcAOQm1.js} +3 -39
- package/dist/chunks/units-CJcAOQm1.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -44
- package/dist/index.js.map +1 -1
- package/dist/invoices.d.ts +3 -0
- package/dist/invoices.d.ts.map +1 -0
- package/dist/invoices.js +2 -0
- package/dist/manifest.json +66 -8
- package/dist/migrations/invoiceEditorStorage.d.ts +39 -0
- package/dist/migrations/invoiceEditorStorage.d.ts.map +1 -0
- package/dist/models/ContractLineItem.d.ts +1 -1
- package/dist/models/InvoiceLineItem.d.ts +18 -36
- package/dist/models/InvoiceLineItem.d.ts.map +1 -1
- package/dist/playground.js +201 -3
- package/dist/playground.js.map +1 -1
- package/dist/smrt-knowledge.json +109 -7
- package/dist/svelte/components/CustomerDetail.svelte +150 -0
- package/dist/svelte/components/CustomerDetail.svelte.d.ts +28 -0
- package/dist/svelte/components/CustomerDetail.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CustomerDirectory.svelte +177 -0
- package/dist/svelte/components/CustomerDirectory.svelte.d.ts +42 -0
- package/dist/svelte/components/CustomerDirectory.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CustomerForm.svelte +234 -0
- package/dist/svelte/components/CustomerForm.svelte.d.ts +44 -0
- package/dist/svelte/components/CustomerForm.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InvoiceAllocationFields.svelte +72 -0
- package/dist/svelte/components/InvoiceAllocationFields.svelte.d.ts +8 -0
- package/dist/svelte/components/InvoiceAllocationFields.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InvoiceCard.svelte +2 -2
- package/dist/svelte/components/InvoiceEditor.svelte +134 -0
- package/dist/svelte/components/InvoiceEditor.svelte.d.ts +8 -0
- package/dist/svelte/components/InvoiceEditor.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InvoiceLineEditor.svelte +85 -0
- package/dist/svelte/components/InvoiceLineEditor.svelte.d.ts +8 -0
- package/dist/svelte/components/InvoiceLineEditor.svelte.d.ts.map +1 -0
- package/dist/svelte/components/InvoiceReview.svelte +23 -0
- package/dist/svelte/components/InvoiceReview.svelte.d.ts +10 -0
- package/dist/svelte/components/InvoiceReview.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PartyAddressFields.svelte +95 -0
- package/dist/svelte/components/PartyAddressFields.svelte.d.ts +25 -0
- package/dist/svelte/components/PartyAddressFields.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PartyContactFields.svelte +153 -0
- package/dist/svelte/components/PartyContactFields.svelte.d.ts +28 -0
- package/dist/svelte/components/PartyContactFields.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PricingVersionDecision.svelte +88 -0
- package/dist/svelte/components/PricingVersionDecision.svelte.d.ts +35 -0
- package/dist/svelte/components/PricingVersionDecision.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PricingVersionSummary.svelte +46 -0
- package/dist/svelte/components/PricingVersionSummary.svelte.d.ts +9 -0
- package/dist/svelte/components/PricingVersionSummary.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PurchaseOrderEditor.svelte +210 -0
- package/dist/svelte/components/PurchaseOrderEditor.svelte.d.ts +79 -0
- package/dist/svelte/components/PurchaseOrderEditor.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PurchaseSourceSelector.svelte +56 -0
- package/dist/svelte/components/PurchaseSourceSelector.svelte.d.ts +30 -0
- package/dist/svelte/components/PurchaseSourceSelector.svelte.d.ts.map +1 -0
- package/dist/svelte/components/PurchasingPlayground.svelte +133 -0
- package/dist/svelte/components/PurchasingPlayground.svelte.d.ts +9 -0
- package/dist/svelte/components/PurchasingPlayground.svelte.d.ts.map +1 -0
- package/dist/svelte/components/QuoteEditor.svelte +177 -0
- package/dist/svelte/components/QuoteEditor.svelte.d.ts +76 -0
- package/dist/svelte/components/QuoteEditor.svelte.d.ts.map +1 -0
- package/dist/svelte/components/QuotePlayground.svelte +108 -0
- package/dist/svelte/components/QuotePlayground.svelte.d.ts +10 -0
- package/dist/svelte/components/QuotePlayground.svelte.d.ts.map +1 -0
- package/dist/svelte/components/QuoteRevisionComparison.svelte +44 -0
- package/dist/svelte/components/QuoteRevisionComparison.svelte.d.ts +14 -0
- package/dist/svelte/components/QuoteRevisionComparison.svelte.d.ts.map +1 -0
- package/dist/svelte/components/QuoteRevisionHistory.svelte +55 -0
- package/dist/svelte/components/QuoteRevisionHistory.svelte.d.ts +15 -0
- package/dist/svelte/components/QuoteRevisionHistory.svelte.d.ts.map +1 -0
- package/dist/svelte/components/VendorDetail.svelte +134 -0
- package/dist/svelte/components/VendorDetail.svelte.d.ts +26 -0
- package/dist/svelte/components/VendorDetail.svelte.d.ts.map +1 -0
- package/dist/svelte/components/VendorDirectory.svelte +178 -0
- package/dist/svelte/components/VendorDirectory.svelte.d.ts +42 -0
- package/dist/svelte/components/VendorDirectory.svelte.d.ts.map +1 -0
- package/dist/svelte/components/VendorForm.svelte +224 -0
- package/dist/svelte/components/VendorForm.svelte.d.ts +42 -0
- package/dist/svelte/components/VendorForm.svelte.d.ts.map +1 -0
- package/dist/svelte/i18n.d.ts +102 -0
- package/dist/svelte/i18n.d.ts.map +1 -1
- package/dist/svelte/i18n.js +102 -0
- package/dist/svelte/index.d.ts +52 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +49 -0
- package/dist/svelte/invoices/calculations.d.ts +121 -0
- package/dist/svelte/invoices/calculations.d.ts.map +1 -0
- package/dist/svelte/invoices/calculations.js +259 -0
- package/dist/svelte/invoices/currency.d.ts +9 -0
- package/dist/svelte/invoices/currency.d.ts.map +1 -0
- package/dist/svelte/invoices/currency.js +55 -0
- package/dist/svelte/invoices/fields.d.ts +6 -0
- package/dist/svelte/invoices/fields.d.ts.map +1 -0
- package/dist/svelte/invoices/fields.js +22 -0
- package/dist/svelte/invoices/messages.d.ts +53 -0
- package/dist/svelte/invoices/messages.d.ts.map +1 -0
- package/dist/svelte/invoices/messages.js +53 -0
- package/dist/svelte/invoices/types.d.ts +194 -0
- package/dist/svelte/invoices/types.d.ts.map +1 -0
- package/dist/svelte/invoices/types.js +1 -0
- package/dist/svelte/party-types.d.ts +203 -0
- package/dist/svelte/party-types.d.ts.map +1 -0
- package/dist/svelte/party-types.js +82 -0
- package/dist/svelte/playground/InvoicePreparationPreview.svelte +116 -0
- package/dist/svelte/playground/InvoicePreparationPreview.svelte.d.ts +4 -0
- package/dist/svelte/playground/InvoicePreparationPreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground/PartyFormPreview.svelte +211 -0
- package/dist/svelte/playground/PartyFormPreview.svelte.d.ts +8 -0
- package/dist/svelte/playground/PartyFormPreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground/PricingVersionPreview.svelte +55 -0
- package/dist/svelte/playground/PricingVersionPreview.svelte.d.ts +4 -0
- package/dist/svelte/playground/PricingVersionPreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground.d.ts +442 -3
- package/dist/svelte/playground.d.ts.map +1 -1
- package/dist/svelte/playground.js +199 -3
- package/dist/svelte/pricing-i18n.d.ts +10 -0
- package/dist/svelte/pricing-i18n.d.ts.map +1 -0
- package/dist/svelte/pricing-i18n.js +10 -0
- package/dist/svelte/pricing-types.d.ts +38 -0
- package/dist/svelte/pricing-types.d.ts.map +1 -0
- package/dist/svelte/pricing-types.js +1 -0
- package/dist/svelte/purchasing-i18n.d.ts +53 -0
- package/dist/svelte/purchasing-i18n.d.ts.map +1 -0
- package/dist/svelte/purchasing-i18n.js +53 -0
- package/dist/svelte/purchasing-types.d.ts +91 -0
- package/dist/svelte/purchasing-types.d.ts.map +1 -0
- package/dist/svelte/purchasing-types.js +13 -0
- package/dist/svelte/quote-i18n.d.ts +49 -0
- package/dist/svelte/quote-i18n.d.ts.map +1 -0
- package/dist/svelte/quote-i18n.js +49 -0
- package/dist/svelte/quote-types.d.ts +81 -0
- package/dist/svelte/quote-types.d.ts.map +1 -0
- package/dist/svelte/quote-types.js +51 -0
- package/dist/types/index.d.ts +13 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +150 -6
- package/dist/ui.js.map +1 -1
- package/package.json +25 -13
- package/dist/chunks/units-CpBepqG1.js.map +0 -1
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-commerce",
|
|
6
|
-
"packageVersion": "0.53.
|
|
6
|
+
"packageVersion": "0.53.6",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
12
|
-
"agents": "
|
|
10
|
+
"manifest": "a0c5d8198cf0a5e37fa4e1fa458abe88fb14004c62d1684f1a0813f0494cd096",
|
|
11
|
+
"packageJson": "fb56c8635ffd03c987d417a3ccb9fc7a22e8deaac26786ccad00d50c91237760",
|
|
12
|
+
"agents": "350f098d5e6fad88cae8e1e63ebbbee51d8b33ca065273827cd47edc3a841a98",
|
|
13
|
+
"moduleDoc:agents/invoice-quantity.md": "bc3b617d4fb8cc87511763b281283107557ba64795e64522193ef967a3715d32",
|
|
14
|
+
"moduleDoc:agents/commercial-ui.md": "9be7022e98cbc509a92aeb5db015bc2e9e7fe134ce855c26f971c026c3a1ed60",
|
|
15
|
+
"moduleDoc:agents/quote-ui.md": "514568e4e7815c47113627ac044a1f31a6933377b3f6c70f5d54b2508d94200c",
|
|
16
|
+
"moduleDoc:agents/purchasing-ui.md": "51ab713d89949c9779973191dfa3529e5120d087321236fc48ad1948c88f9866"
|
|
13
17
|
},
|
|
14
18
|
"exports": [
|
|
15
19
|
".",
|
|
20
|
+
"./invoices",
|
|
16
21
|
"./manifest",
|
|
17
22
|
"./manifest.json",
|
|
18
23
|
"./playground",
|
|
@@ -10206,11 +10211,18 @@
|
|
|
10206
10211
|
},
|
|
10207
10212
|
{
|
|
10208
10213
|
"name": "quantity",
|
|
10209
|
-
"type": "
|
|
10214
|
+
"type": "decimal",
|
|
10210
10215
|
"required": false,
|
|
10211
|
-
"columnType": "
|
|
10216
|
+
"columnType": "REAL",
|
|
10212
10217
|
"default": 1
|
|
10213
10218
|
},
|
|
10219
|
+
{
|
|
10220
|
+
"name": "invoiceEditorStateJson",
|
|
10221
|
+
"type": "text",
|
|
10222
|
+
"required": false,
|
|
10223
|
+
"columnType": "TEXT",
|
|
10224
|
+
"default": ""
|
|
10225
|
+
},
|
|
10214
10226
|
{
|
|
10215
10227
|
"name": "unitPrice",
|
|
10216
10228
|
"type": "integer",
|
|
@@ -10297,18 +10309,36 @@
|
|
|
10297
10309
|
}
|
|
10298
10310
|
],
|
|
10299
10311
|
"methods": [
|
|
10312
|
+
"applyEditorDraft",
|
|
10300
10313
|
"calculateAmount",
|
|
10314
|
+
"getEditorDraft",
|
|
10301
10315
|
"getSubtotal",
|
|
10302
10316
|
"getTaxAmount",
|
|
10303
10317
|
"hasPeriod",
|
|
10304
10318
|
"hasSource",
|
|
10319
|
+
"save",
|
|
10305
10320
|
"toAccountingLineItem"
|
|
10306
10321
|
],
|
|
10307
10322
|
"methodSignatures": [
|
|
10323
|
+
{
|
|
10324
|
+
"name": "applyEditorDraft",
|
|
10325
|
+
"params": [
|
|
10326
|
+
"draft: InvoiceLineDraft",
|
|
10327
|
+
"context: InvoiceCalculationContext"
|
|
10328
|
+
],
|
|
10329
|
+
"returns": "void"
|
|
10330
|
+
},
|
|
10308
10331
|
{
|
|
10309
10332
|
"name": "calculateAmount",
|
|
10310
10333
|
"returns": "number"
|
|
10311
10334
|
},
|
|
10335
|
+
{
|
|
10336
|
+
"name": "getEditorDraft",
|
|
10337
|
+
"params": [
|
|
10338
|
+
"authorizedContext: InvoiceCalculationContext"
|
|
10339
|
+
],
|
|
10340
|
+
"returns": "InvoiceLineDraft | null"
|
|
10341
|
+
},
|
|
10312
10342
|
{
|
|
10313
10343
|
"name": "getSubtotal",
|
|
10314
10344
|
"returns": "number"
|
|
@@ -10325,6 +10355,11 @@
|
|
|
10325
10355
|
"name": "hasSource",
|
|
10326
10356
|
"returns": "boolean"
|
|
10327
10357
|
},
|
|
10358
|
+
{
|
|
10359
|
+
"name": "save",
|
|
10360
|
+
"async": true,
|
|
10361
|
+
"returns": "Promise<this>"
|
|
10362
|
+
},
|
|
10328
10363
|
{
|
|
10329
10364
|
"name": "toAccountingLineItem",
|
|
10330
10365
|
"params": [
|
|
@@ -10432,6 +10467,18 @@
|
|
|
10432
10467
|
"operation": "getTaxAmount",
|
|
10433
10468
|
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10434
10469
|
},
|
|
10470
|
+
{
|
|
10471
|
+
"kind": "cli",
|
|
10472
|
+
"name": "invoicelineitem_applyEditorDraft",
|
|
10473
|
+
"operation": "applyEditorDraft",
|
|
10474
|
+
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10475
|
+
},
|
|
10476
|
+
{
|
|
10477
|
+
"kind": "cli",
|
|
10478
|
+
"name": "invoicelineitem_getEditorDraft",
|
|
10479
|
+
"operation": "getEditorDraft",
|
|
10480
|
+
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10481
|
+
},
|
|
10435
10482
|
{
|
|
10436
10483
|
"kind": "cli",
|
|
10437
10484
|
"name": "invoicelineitem_hasSource",
|
|
@@ -10464,6 +10511,13 @@
|
|
|
10464
10511
|
}
|
|
10465
10512
|
],
|
|
10466
10513
|
"withheldSurfaces": [
|
|
10514
|
+
{
|
|
10515
|
+
"kind": "api",
|
|
10516
|
+
"operation": "applyEditorDraft",
|
|
10517
|
+
"code": "not-included",
|
|
10518
|
+
"reason": "not listed in api.include",
|
|
10519
|
+
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10520
|
+
},
|
|
10467
10521
|
{
|
|
10468
10522
|
"kind": "api",
|
|
10469
10523
|
"operation": "calculateAmount",
|
|
@@ -10471,6 +10525,13 @@
|
|
|
10471
10525
|
"reason": "not listed in api.include",
|
|
10472
10526
|
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10473
10527
|
},
|
|
10528
|
+
{
|
|
10529
|
+
"kind": "api",
|
|
10530
|
+
"operation": "getEditorDraft",
|
|
10531
|
+
"code": "not-included",
|
|
10532
|
+
"reason": "not listed in api.include",
|
|
10533
|
+
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10534
|
+
},
|
|
10474
10535
|
{
|
|
10475
10536
|
"kind": "api",
|
|
10476
10537
|
"operation": "getSubtotal",
|
|
@@ -10499,6 +10560,13 @@
|
|
|
10499
10560
|
"reason": "not listed in api.include",
|
|
10500
10561
|
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10501
10562
|
},
|
|
10563
|
+
{
|
|
10564
|
+
"kind": "api",
|
|
10565
|
+
"operation": "save",
|
|
10566
|
+
"code": "lifecycle-method",
|
|
10567
|
+
"reason": "`save` is a framework lifecycle method, not a distinct operation",
|
|
10568
|
+
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
10569
|
+
},
|
|
10502
10570
|
{
|
|
10503
10571
|
"kind": "api",
|
|
10504
10572
|
"operation": "toAccountingLineItem",
|
|
@@ -18810,6 +18878,18 @@
|
|
|
18810
18878
|
"operation": "getTaxAmount",
|
|
18811
18879
|
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
18812
18880
|
},
|
|
18881
|
+
{
|
|
18882
|
+
"kind": "cli",
|
|
18883
|
+
"name": "invoicelineitem_applyEditorDraft",
|
|
18884
|
+
"operation": "applyEditorDraft",
|
|
18885
|
+
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
18886
|
+
},
|
|
18887
|
+
{
|
|
18888
|
+
"kind": "cli",
|
|
18889
|
+
"name": "invoicelineitem_getEditorDraft",
|
|
18890
|
+
"operation": "getEditorDraft",
|
|
18891
|
+
"objectName": "@happyvertical/smrt-commerce:InvoiceLineItem"
|
|
18892
|
+
},
|
|
18813
18893
|
{
|
|
18814
18894
|
"kind": "cli",
|
|
18815
18895
|
"name": "invoicelineitem_hasSource",
|
|
@@ -19436,5 +19516,27 @@
|
|
|
19436
19516
|
"polymorphicAssociations": 0,
|
|
19437
19517
|
"uuidColumns": 162
|
|
19438
19518
|
},
|
|
19439
|
-
"agentDoc": "# @happyvertical/smrt-commerce\n\nE-commerce with Contract STI hierarchy, invoice lifecycle, payment tracking, payout remittance, and optional ledger integration.\n\n## Models\n\n- **Customer** / **Vendor**: linked to Profile via string ID (not FK). Customer has creditLimit, paymentTerms, customerType (DTC / WHOLESALE / RETAIL). Vendor has leadTimeDays, minimumOrder, and `payoutAddresses: Record<string, string>` — a flat map from payout-rail-qualified currency code (`USDC-base`, `BTC`, `USD-stripe`, ...) to destination string (EVM address, BTC address, Stripe Connect account id, IBAN). Use `getPayoutAddress(currency)` for a `Map.get`-style lookup; missing entries return `undefined` (caller decides skip-vs-error).\n- **Contract** (STI base → Estimate, Order, Lease, Agreement, PurchaseOrder, WholesaleOrder, ProductionOrder, Cart, LicenseSale): 9 contract types sharing one table. Carries `channelId` (open-ended string — `dtc-web`, `wholesale-b2b`, `pos-store-N`, etc.) so the same model serves DTC checkout, B2B portals, and POS.\n- **WholesaleOrder**: B2B order. Conventional pairing — customer has `customerType: 'wholesale'`, NET-30/60 terms, delivered via wholesale-portal channel.\n- **ProductionOrder**: manufacturing equivalent of a PurchaseOrder — commission your factory to make finished goods. Consumes raw materials per BOM (`@happyvertical/smrt-manufacturing`) and produces SKU stock (`@happyvertical/smrt-inventory`).\n- **Cart**: transient order-in-progress. Same shape as Order; the application promotes the row from `_meta_type: Cart` to `_meta_type: Order` at checkout instead of copying data between tables.\n- **LicenseSale**: industry-neutral licensing primitive. Carries an *immutable* rights snapshot (`rightsMedium`, `rightsDistributionScope`, `rightsExclusivity`, `rightsDuration`, `rightsTerritory`, `rightsSublicensing`, `rightsDerivatives` — typed `Meta<T>` fields), licensee identity (`licenseeEmail`, optional `licenseeLegalEntity` / `licenseeJurisdiction`), and a signed-PDF reference (`pdfUrl`, `pdfHash`, optional `onChainHashRegistryRef`). Once saved at `ContractStatus.ACCEPTED`, the rights snapshot is frozen — mutating any of the seven rights fields and re-saving throws. The only legal transition out of ACCEPTED is `revoke()` (moves to CANCELLED without touching rights). Useful for stock media, music licensing, code-asset marketplaces, license keys, anywhere rights are sold for a fee.\n- **ContractLineItem**: items on contracts.\n- **Invoice**: status machine `DRAFT → SENT → VIEWED → PARTIAL → PAID` (also OVERDUE, CANCELLED, WRITTEN_OFF). `recognizeRevenue()` creates balanced AR journal entry (DR: Accounts Receivable, CR: Revenue, CR: Tax Payable).\n- **InvoiceLineItem**: line items on invoices.\n- **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.\n- **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.\n- **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()`.\n- **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.\n- **Fulfillment** / **FulfillmentLineItem**: shipment/delivery tracking.\n\n## Ledger Integration\n\n`@happyvertical/smrt-ledgers` is a regular dependency, loaded lazily via dynamic `import()` so the coupling stays runtime-only (no hard static import; the package graph stays a DAG — see #1582). Invoice stores `arJournalId` and `revenueJournalId` as string references. `recognizeRevenue()` creates a balanced AR journal entry; `getArJournal()` returns null when no journal has been recognized yet.\n\n## Billing-period close (#3060)\n\n`src/billing/` closes periods from smrt-subscriptions charges; see the README\nfor usage.\n\n- **Port, not provider.** Period close, events, and credit checkout call only\n the `BillingProvider` port (integer minor units). `stripe.ts` adapts\n `@happyvertical/accounting`, which takes currency *major* units; convert with\n `units.ts` and never hand-roll a Stripe call here. `units.ts` exponents are\n an explicit ISO 4217 table matching the SDK's, never `Intl` display digits\n (CLDR differs for IQD, MGA, … and drifts by ICU release). The SDK's\n `*Minor` fields are already ISO minor units. Capabilities a rail may lack\n (`chargeSavedPaymentMethod`, `createSetupCheckout`, `getCheckout`,\n `setDefaultPaymentMethod`, `markInvoiceUncollectible`) are optional port\n methods: detect them by presence, and keep additions optional (#3139).\n `sendAndReconcile` refuses a provider invoice whose subtotal or total\n disagrees with the local invoice.\n- **Replay safety is identity.** Close, invoice, line, payment, allocation,\n and claim ids are `deterministicId()`s; `BillingLineSource` (conflict key\n `source_type, source_id`) is the only double-billing guard, so never bill a\n source without claiming it, and never widen that key. Each close step is\n persisted before the next; `acquireCloseLease()` serializes workers. A\n close that nets to a credit keeps its claims and ends `carried_forward`;\n its subtotal is a `credit_carry_forward` source for the payer's next billed\n close, which marks the carried close `completed`.\n- **Cycles and flat-plan coverage (#3116).** `cycles.ts` is the schedule\n (pure; `BillingAccount.billingAnchorAt`, null = calendar months). A close\n with no period closes each payer's `lastEndedBillingPeriod()`. Flat-plan\n claims are the exception to \"id from the source\": their `sourceId` is\n `<subscriptionId>:seq:<chainSequence>` and their `periodStart/End` is the\n exact time they bill. `claimFlatWindow()` reads the last number, then the\n coverage (claims ending after the window start), claims one gap as number\n n + 1, and retries on conflict; reading in that order is what makes it\n safe. That numbering, not the period, stops overlaps when schedules or\n payers change — never claim a flat plan outside it, and keep both reads\n bounded (no whole-history scans). Each numbered claim is its own invoice\n line keyed `lineKey|periodStart` (a function of the claim alone, so a\n resumed close rebuilds the same line ids). Proration is\n `prorateMinorUnits()` (half up, BigInt) against the period's full cycle.\n- **System tables.** `BillingAccount`, `BillingPeriodClose`, and\n `BillingLineSource` are not tenant-scoped and have no generated surface.\n Collections stay unexported; models are root exports (#3082). Invoices,\n payments, and journals are written in the seller's tenant; payer-owned\n subscriptions and credit grants in a system context, only after\n verification.\n- **Events** use smrt-jobs' delivery inbox under provider\n `<name>-billing:<sellerTenantId>` and a provider-filtered\n `ForgeProjectionRuntime`, so a runtime never claims another seller's\n events; any other runtime sharing the inbox must pass `providers` too. Only\n events this package acts on are stored: foreign checkouts are `ignored`,\n and only `smrt_*` checkout metadata is kept; it is\n HMAC-signed with the webhook secret at creation (empty values are omitted:\n Stripe drops them), so another integration on the same account cannot forge\n a credit purchase or a card setup; credit is granted only when the\n collected total equals it plus provider tax. Checkout amounts come from the\n re-read session (`getCheckout()`), never the Stripe event, whose amounts are\n in Stripe's unit. Rotating the webhook secret strands in-flight checkouts\n (up to 24 h). `observe()` re-reads provider state, so ordering never depends\n on delivery order; the only provider writes in `observe()` are idempotent\n (making a saved card the default). The standing hook runs inside the event\n transaction and must be idempotent.\n- **Card on file and top-ups (#3139).** A saved card is the payer customer's\n default `PaymentInstrument` (seller tenant, id from the provider method).\n `autoChargeInvoices` bills such payers `charge_automatically`, decided once\n and saved as `Invoice.collectionMethod` before the first push, which every\n retry reuses (never recompute it: a lost push response would record a\n method the provider was not asked for, #3190); activation follows `paid`, never send. An auto top-up attempt\n is a `PENDING` `Payment` (`reference` `auto-top-up:<policyId>`, id from the\n policy and `count()` of its attempts) inserted before the charge, so racing\n evaluations derive one id and one charge; the charge key is derived from\n that id. `applyAutoTopUpOutcome()` must run in a transaction and starts with\n a conditional `UPDATE … WHERE status = 'pending'` row lock, so the hook and\n the `payment` webhook settle once; the grant is keyed by the charge key.\n Never settle an attempt outside that function. Taxed accounts are charged\n with provider tax by default (#3194): the attempt row holds the credit\n (the charge subtotal), the tax is journaled to tax payable at settlement,\n and the attempt's tax decision is recorded in its `notes` so a re-drive\n repeats the same charge request (the provider refuses a key reused with\n different tax). `claimAttempt()` is the only place that decides a\n skip and reports it (`onAutoTopUpSkipped`); `cardOnFile()` and period close\n use `defaultCardInstrument()` — never copy that predicate. A provider write-off moves the local invoice to\n `WRITTEN_OFF` (a later payment, by card or on a rail (#3188), is recorded\n and reinstates the payer; the status stays, so \"paid in full\" for a\n written-off invoice is read from its allocations).\n- **`Invoice.providerTaxAmount`** is added to line-item tax; it is\n server-managed (not API-writable). `toAccountingInput()` emits major units.\n- **Payment rails (#3138).** A runtime has one issuing `provider` and any\n number of `paymentProviders` (unique names, one inbox namespace each; a\n provider with `capabilities.issuesInvoices === false` can never issue).\n `observe()` resolves the provider from the delivery namespace, never the\n payload. Crypto rails implement the port over `@happyvertical/payments`'\n `CryptoCheckoutGateway`; never call BTCPay (or any gateway) from here.\n Settlement is decided by the pure `decideAttempt()` from gateway state and\n `paymentPolicy` — the gateway's `settled` is authoritative and nothing here\n counts confirmations. A `BillingPaymentAttempt` (`(provider, checkout_id)`\n deterministic id) records every state and settles once; grants and payments\n are keyed by checkout id. Invoice payments close the issuer's invoice out of\n band in `observe()` (idempotent) before `project()` records the payment, and\n `settlePaidInvoice` skips (records nothing for) a `paidOutOfBand` invoice\n the rail latched with `outOfBandRequestedAt`; other out-of-band closes are\n recorded as `OTHER` payments. Rails never save cards or charge tax: a taxed\n credit purchase on a rail needs an explicit `automaticTax: false`. Exceptions are flags for operators;\n nothing reverses or refunds automatically.\n- **Known limits**: write-offs post no bad-debt journal (AR stays until paid\n or journaled by an operator); discounts still reach Stripe as negative lines (the SDK's\n line `discount` would change the lines a replayed push reconciles, so it\n needs its own migration); `RetailCharge` has no adjustment ledger. Flat\n plans are monthly, in arrears; plan changes are not prorated (#3119).\n\n## Cross-Package References\n\n- `customerId` → `@foreignKey('Customer')` (hard reference within package)\n- `profileId` → plain string to smrt-profiles\n- `arJournalId`, `revenueJournalId` → plain string to smrt-ledgers\n- `skuId` (on `PaymentIntent`, `LicenseSale`) → plain string to smrt-products\n- `paymentId` (on `PaymentIntent`, `Payout`, `LicenseSale`) → plain string to `Payment` (same package, but kept as plain string for cross-model consistency)\n- `vendorId` on `Payout` → `@foreignKey(Vendor)` (hard reference within package)\n- Billing records reference tenants with `@crossPackageRef('@happyvertical/smrt-users:Tenant')` and charges, plan periods, and adjustments by plain `sourceId`\n\n## Gotchas\n\n- **Optional tenancy**: all models `@TenantScoped({ mode: 'optional' })` + nullable tenantId\n- **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 (BIGINT on fresh PostgreSQL/DuckDB databases). 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, #2373).\n- **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.\n- **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.)\n- **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 exceed JavaScript's safe-integer range; `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 BIGINT 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).\n- **`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.\n- **`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.\n- **Range and existing deployments**: fresh PostgreSQL/DuckDB INTEGER columns are BIGINT, and the runtime rejects values outside JavaScript's safe-integer range rather than rounding them. Existing PostgreSQL `int4` columns do not self-repair; widen them through the explicit migration tracked in #2424.\n- **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.\n- **Invoice controls payment status**: not the Payment model — use `Invoice.updatePaymentStatus()`\n- **Tax rate is external**: no tax rate field on Invoice — rate must be calculated externally\n- **Profile linking**: separate `ProfileCollection.create()` needed to fetch actual Profile object\n- **PaymentIntent natural key**: `conflictColumns: ['tenant_id', 'offering_ref', 'licensee_email', 'idempotency_key']` — a retried `create` with the same tuple upserts the existing row. Use `getOrCreateByIdempotencyKey()` to branch on `{ intent, created }`. Empty natural-key inputs (e.g. blank `idempotencyKey`) disable dedup by design.\n- **Payout state-machine guards**: `markSent` requires a non-empty `backendTxRef`; `markConfirmed` only valid from `SENT`; `markFailed` only valid from `PENDING` / `SENT` (a confirmed payout can't fail — that path is a refund). `resetFromFailed()` is the dedicated escape hatch from `FAILED` back to `PENDING` after an operator fixes the underlying problem; it clears `backendTxRef` so the next attempt picks up a fresh one.\n- **PaymentInstrument defaults**: `isDefault` is domain-managed, not API-writable. Call `setDefaultForCustomer(customerId, instrumentId)` so the target is validated and the customer's other instruments are cleared.\n- **LicenseSale immutability**: rights snapshot freezes on save-with-status-ACCEPTED. The captured snapshot lives in a module-scoped `WeakMap<LicenseSale, string>` so it doesn't interact with the schema or round-trip through `_meta_data`. Drafts (status != ACCEPTED) remain mutable. To \"change\" an issued license: `revoke()` it, then issue a new `LicenseSale` row.\n- **Vendor.payoutAddresses normalization**: the constructor accepts either a `Record<string, string>` or a pre-serialized JSON string. `initialize()` re-normalizes after the framework's option-override pass; `save()` re-normalizes defensively against direct field assignment. Non-string values inside the input map are silently dropped to preserve the typed invariant downstream.\n"
|
|
19519
|
+
"agentDoc": "# @happyvertical/smrt-commerce\n\nE-commerce with Contract STI hierarchy, invoice lifecycle, payment tracking, payout remittance, and optional ledger integration.\n\n## Models\n\n- **Customer** / **Vendor**: linked to Profile via string ID (not FK). Customer has creditLimit, paymentTerms, customerType (DTC / WHOLESALE / RETAIL). Vendor has leadTimeDays, minimumOrder, and `payoutAddresses: Record<string, string>` — a flat map from payout-rail-qualified currency code (`USDC-base`, `BTC`, `USD-stripe`, ...) to destination string (EVM address, BTC address, Stripe Connect account id, IBAN). Use `getPayoutAddress(currency)` for a `Map.get`-style lookup; missing entries return `undefined` (caller decides skip-vs-error).\n- **Contract** (STI base → Estimate, Order, Lease, Agreement, PurchaseOrder, WholesaleOrder, ProductionOrder, Cart, LicenseSale): 9 contract types sharing one table. Carries `channelId` (open-ended string — `dtc-web`, `wholesale-b2b`, `pos-store-N`, etc.) so the same model serves DTC checkout, B2B portals, and POS.\n- **WholesaleOrder**: B2B order. Conventional pairing — customer has `customerType: 'wholesale'`, NET-30/60 terms, delivered via wholesale-portal channel.\n- **ProductionOrder**: manufacturing equivalent of a PurchaseOrder — commission your factory to make finished goods. Consumes raw materials per BOM (`@happyvertical/smrt-manufacturing`) and produces SKU stock (`@happyvertical/smrt-inventory`).\n- **Cart**: transient order-in-progress. Same shape as Order; the application promotes the row from `_meta_type: Cart` to `_meta_type: Order` at checkout instead of copying data between tables.\n- **LicenseSale**: industry-neutral licensing primitive. Carries an *immutable* rights snapshot (`rightsMedium`, `rightsDistributionScope`, `rightsExclusivity`, `rightsDuration`, `rightsTerritory`, `rightsSublicensing`, `rightsDerivatives` — typed `Meta<T>` fields), licensee identity (`licenseeEmail`, optional `licenseeLegalEntity` / `licenseeJurisdiction`), and a signed-PDF reference (`pdfUrl`, `pdfHash`, optional `onChainHashRegistryRef`). Once saved at `ContractStatus.ACCEPTED`, the rights snapshot is frozen — mutating any of the seven rights fields and re-saving throws. The only legal transition out of ACCEPTED is `revoke()` (moves to CANCELLED without touching rights). Useful for stock media, music licensing, code-asset marketplaces, license keys, anywhere rights are sold for a fee.\n- **ContractLineItem**: items on contracts.\n- **Invoice**: status machine `DRAFT → SENT → VIEWED → PARTIAL → PAID` (also OVERDUE, CANCELLED, WRITTEN_OFF). `recognizeRevenue()` creates balanced AR journal entry (DR: Accounts Receivable, CR: Revenue, CR: Tax Payable).\n- **InvoiceLineItem**: fractional-quantity line items with safe minor-unit validation/recalculation on save; editor-mode recovery and existing-column migration are documented in [invoice quantity persistence](agents/invoice-quantity.md).\n- **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.\n- **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.\n- **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()`.\n- **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.\n- **Fulfillment** / **FulfillmentLineItem**: shipment/delivery tracking.\n\n## Ledger Integration\n\n`@happyvertical/smrt-ledgers` is a regular dependency, loaded lazily via dynamic `import()` so the coupling stays runtime-only (no hard static import; the package graph stays a DAG — see #1582). Invoice stores `arJournalId` and `revenueJournalId` as string references. `recognizeRevenue()` creates a balanced AR journal entry; `getArJournal()` returns null when no journal has been recognized yet.\n\n## Billing-period close (#3060)\n\n`src/billing/` closes periods from smrt-subscriptions charges; see the README\nfor usage.\n\n- **Port, not provider.** Period close, events, and credit checkout call only\n the `BillingProvider` port (integer minor units). `stripe.ts` adapts\n `@happyvertical/accounting`, which takes currency *major* units; convert with\n `units.ts` and never hand-roll a Stripe call here. `units.ts` exponents are\n an explicit ISO 4217 table matching the SDK's, never `Intl` display digits\n (CLDR differs for IQD, MGA, … and drifts by ICU release). The SDK's\n `*Minor` fields are already ISO minor units. Capabilities a rail may lack\n (`chargeSavedPaymentMethod`, `createSetupCheckout`, `getCheckout`,\n `setDefaultPaymentMethod`, `markInvoiceUncollectible`) are optional port\n methods: detect them by presence, and keep additions optional (#3139).\n `sendAndReconcile` refuses a provider invoice whose subtotal or total\n disagrees with the local invoice.\n- **Replay safety is identity.** Close, invoice, line, payment, allocation,\n and claim ids are `deterministicId()`s; `BillingLineSource` (conflict key\n `source_type, source_id`) is the only double-billing guard, so never bill a\n source without claiming it, and never widen that key. Each close step is\n persisted before the next; `acquireCloseLease()` serializes workers. A\n close that nets to a credit keeps its claims and ends `carried_forward`;\n its subtotal is a `credit_carry_forward` source for the payer's next billed\n close, which marks the carried close `completed`.\n- **Cycles and flat-plan coverage (#3116).** `cycles.ts` is the schedule\n (pure; `BillingAccount.billingAnchorAt`, null = calendar months). A close\n with no period closes each payer's `lastEndedBillingPeriod()`. Flat-plan\n claims are the exception to \"id from the source\": their `sourceId` is\n `<subscriptionId>:seq:<chainSequence>` and their `periodStart/End` is the\n exact time they bill. `claimFlatWindow()` reads the last number, then the\n coverage (claims ending after the window start), claims one gap as number\n n + 1, and retries on conflict; reading in that order is what makes it\n safe. That numbering, not the period, stops overlaps when schedules or\n payers change — never claim a flat plan outside it, and keep both reads\n bounded (no whole-history scans). Each numbered claim is its own invoice\n line keyed `lineKey|periodStart` (a function of the claim alone, so a\n resumed close rebuilds the same line ids). Proration is\n `prorateMinorUnits()` (half up, BigInt) against the period's full cycle.\n- **System tables.** `BillingAccount`, `BillingPeriodClose`, and\n `BillingLineSource` are not tenant-scoped and have no generated surface.\n Collections stay unexported; models are root exports (#3082). Invoices,\n payments, and journals are written in the seller's tenant; payer-owned\n subscriptions and credit grants in a system context, only after\n verification.\n- **Events** use smrt-jobs' delivery inbox under provider\n `<name>-billing:<sellerTenantId>` and a provider-filtered\n `ForgeProjectionRuntime`, so a runtime never claims another seller's\n events; any other runtime sharing the inbox must pass `providers` too. Only\n events this package acts on are stored: foreign checkouts are `ignored`,\n and only `smrt_*` checkout metadata is kept; it is\n HMAC-signed with the webhook secret at creation (empty values are omitted:\n Stripe drops them), so another integration on the same account cannot forge\n a credit purchase or a card setup; credit is granted only when the\n collected total equals it plus provider tax. Checkout amounts come from the\n re-read session (`getCheckout()`), never the Stripe event, whose amounts are\n in Stripe's unit. Rotating the webhook secret strands in-flight checkouts\n (up to 24 h). `observe()` re-reads provider state, so ordering never depends\n on delivery order; the only provider writes in `observe()` are idempotent\n (making a saved card the default). The standing hook runs inside the event\n transaction and must be idempotent.\n- **Card on file and top-ups (#3139).** A saved card is the payer customer's\n default `PaymentInstrument` (seller tenant, id from the provider method).\n `autoChargeInvoices` bills such payers `charge_automatically`, decided once\n and saved as `Invoice.collectionMethod` before the first push, which every\n retry reuses (never recompute it: a lost push response would record a\n method the provider was not asked for, #3190); activation follows `paid`, never send. An auto top-up attempt\n is a `PENDING` `Payment` (`reference` `auto-top-up:<policyId>`, id from the\n policy and `count()` of its attempts) inserted before the charge, so racing\n evaluations derive one id and one charge; the charge key is derived from\n that id. `applyAutoTopUpOutcome()` must run in a transaction and starts with\n a conditional `UPDATE … WHERE status = 'pending'` row lock, so the hook and\n the `payment` webhook settle once; the grant is keyed by the charge key.\n Never settle an attempt outside that function. Taxed accounts are charged\n with provider tax by default (#3194): the attempt row holds the credit\n (the charge subtotal), the tax is journaled to tax payable at settlement,\n and the attempt's tax decision is recorded in its `notes` so a re-drive\n repeats the same charge request (the provider refuses a key reused with\n different tax). `claimAttempt()` is the only place that decides a\n skip and reports it (`onAutoTopUpSkipped`); `cardOnFile()` and period close\n use `defaultCardInstrument()` — never copy that predicate. A provider write-off moves the local invoice to\n `WRITTEN_OFF` (a later payment, by card or on a rail (#3188), is recorded\n and reinstates the payer; the status stays, so \"paid in full\" for a\n written-off invoice is read from its allocations).\n- **`Invoice.providerTaxAmount`** is added to line-item tax; it is\n server-managed (not API-writable). `toAccountingInput()` emits major units.\n- **Payment rails (#3138).** A runtime has one issuing `provider` and any\n number of `paymentProviders` (unique names, one inbox namespace each; a\n provider with `capabilities.issuesInvoices === false` can never issue).\n `observe()` resolves the provider from the delivery namespace, never the\n payload. Crypto rails implement the port over `@happyvertical/payments`'\n `CryptoCheckoutGateway`; never call BTCPay (or any gateway) from here.\n Settlement is decided by the pure `decideAttempt()` from gateway state and\n `paymentPolicy` — the gateway's `settled` is authoritative and nothing here\n counts confirmations. A `BillingPaymentAttempt` (`(provider, checkout_id)`\n deterministic id) records every state and settles once; grants and payments\n are keyed by checkout id. Invoice payments close the issuer's invoice out of\n band in `observe()` (idempotent) before `project()` records the payment, and\n `settlePaidInvoice` skips (records nothing for) a `paidOutOfBand` invoice\n the rail latched with `outOfBandRequestedAt`; other out-of-band closes are\n recorded as `OTHER` payments. Rails never save cards or charge tax: a taxed\n credit purchase on a rail needs an explicit `automaticTax: false`. Exceptions are flags for operators;\n nothing reverses or refunds automatically.\n- **Known limits**: write-offs post no bad-debt journal (AR stays until paid\n or journaled by an operator); discounts still reach Stripe as negative lines (the SDK's\n line `discount` would change the lines a replayed push reconciles, so it\n needs its own migration); `RetailCharge` has no adjustment ledger. Flat\n plans are monthly, in arrears; plan changes are not prorated (#3119).\n\n## Cross-Package References\n\n- `customerId` → `@foreignKey('Customer')` (hard reference within package)\n- `profileId` → plain string to smrt-profiles\n- `arJournalId`, `revenueJournalId` → plain string to smrt-ledgers\n- `skuId` (on `PaymentIntent`, `LicenseSale`) → plain string to smrt-products\n- `paymentId` (on `PaymentIntent`, `Payout`, `LicenseSale`) → plain string to `Payment` (same package, but kept as plain string for cross-model consistency)\n- `vendorId` on `Payout` → `@foreignKey(Vendor)` (hard reference within package)\n- Billing records reference tenants with `@crossPackageRef('@happyvertical/smrt-users:Tenant')` and charges, plan periods, and adjustments by plain `sourceId`\n\n## Gotchas\n\n- **Optional tenancy**: all models `@TenantScoped({ mode: 'optional' })` + nullable tenantId\n- **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 (BIGINT on fresh PostgreSQL/DuckDB databases). 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, #2373).\n- **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 also decimal (six fractional places); existing integer columns require the explicit [quantity migration](agents/invoice-quantity.md).\n- **Rounding happens where a rate meets money, and nowhere else**: `InvoiceLineItem` uses exact shared invoice arithmetic to round fractional quantity × price and then tax to minor units; `ContractLineItem.calculateAmount()` uses `Math.round()` for tax. 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.)\n- **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 exceed JavaScript's safe-integer range; `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 BIGINT 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).\n- **`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.\n- **`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.\n- **Range and existing deployments**: fresh PostgreSQL/DuckDB INTEGER columns are BIGINT, and the runtime rejects values outside JavaScript's safe-integer range rather than rounding them. Existing PostgreSQL `int4` columns do not self-repair; widen them through the explicit migration tracked in #2424.\n- **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.\n- **Invoice controls payment status**: not the Payment model — use `Invoice.updatePaymentStatus()`\n- **Tax rate is external**: no tax rate field on Invoice — rate must be calculated externally\n- **Profile linking**: separate `ProfileCollection.create()` needed to fetch actual Profile object\n- **PaymentIntent natural key**: `conflictColumns: ['tenant_id', 'offering_ref', 'licensee_email', 'idempotency_key']` — a retried `create` with the same tuple upserts the existing row. Use `getOrCreateByIdempotencyKey()` to branch on `{ intent, created }`. Empty natural-key inputs (e.g. blank `idempotencyKey`) disable dedup by design.\n- **Payout state-machine guards**: `markSent` requires a non-empty `backendTxRef`; `markConfirmed` only valid from `SENT`; `markFailed` only valid from `PENDING` / `SENT` (a confirmed payout can't fail — that path is a refund). `resetFromFailed()` is the dedicated escape hatch from `FAILED` back to `PENDING` after an operator fixes the underlying problem; it clears `backendTxRef` so the next attempt picks up a fresh one.\n- **PaymentInstrument defaults**: `isDefault` is domain-managed, not API-writable. Call `setDefaultForCustomer(customerId, instrumentId)` so the target is validated and the customer's other instruments are cleared.\n- **LicenseSale immutability**: rights snapshot freezes on save-with-status-ACCEPTED. The captured snapshot lives in a module-scoped `WeakMap<LicenseSale, string>` so it doesn't interact with the schema or round-trip through `_meta_data`. Drafts (status != ACCEPTED) remain mutable. To \"change\" an issued license: `revoke()` it, then issue a new `LicenseSale` row.\n- **Vendor.payoutAddresses normalization**: the constructor accepts either a `Record<string, string>` or a pre-serialized JSON string. `initialize()` re-normalizes after the framework's option-override pass; `save()` re-normalizes defensively against direct field assignment. Non-string values inside the input map are silently dropped to preserve the typed invariant downstream.\n\n## Commercial UI\n\nSee [commercial UI composition](agents/commercial-ui.md) for native form recovery, amount compatibility and backend ownership. Package-owned guides under `agents/` ship with the package.\n\n## Quote and estimate UI\n\n`src/svelte/quote-types.ts` separates unparsed draft strings from explicit\nminor-unit revision DTOs. Native row submitters, caller-owned retry identity and\nextension conventions are documented in [agents/quote-ui.md](agents/quote-ui.md).\n\n## Purchasing UI\n\n`PurchaseOrderEditor` preserves allocation IDs and explicit zeroes, including\nretained reduction rows; the server validates review fingerprints before recording.\nSee [agents/purchasing-ui.md](agents/purchasing-ui.md) for caller responsibilities.\n",
|
|
19520
|
+
"moduleDocs": [
|
|
19521
|
+
{
|
|
19522
|
+
"path": "agents/invoice-quantity.md",
|
|
19523
|
+
"module": "invoice-quantity",
|
|
19524
|
+
"content": "# Invoice quantity persistence and editor recovery\n\nInvoiceLineItem accepts nonnegative decimal quantities with at most six decimal\nplaces. The `1.0` default declares a floating-point quantity column. Unit price,\nflat discount, and calculated amount remain safe integer minor units. Save always\nvalidates resolved fields and recalculates amount; a supplied amount is never\ntrusted. Gross quantity × price and then tax on discounted gross round to minor\nunits, with ties toward positive infinity (including existing negative-price\ncredit lines). Tax is a resolved fraction in [0, 1], with eight decimal places.\nAccounting export retains an explicit zero rate instead of omitting it.\n\n`applyEditorDraft(draft, authorizedContext)` resolves the shared invoice draft\nhelper into description/SKU/quantity/price/flat discount/tax and stores cloned\n`invoiceEditorStateJson` TEXT, accessed through a guarded typed `invoiceEditorState` getter/setter. It does not save.\n`getEditorDraft(authorizedContext)` returns a clone only when currency, inherited\ntax authority, and all source fields still agree. Otherwise it returns null so the\ncaller can reconstruct a flat-discount/explicit-tax editor from resolved fields.\nNormal save clears stale editor state and never derives money from metadata.\nAll other object metadata, authorization, and transaction ownership are unchanged.\n\n## Existing integer columns\n\nRun a backup and pause invoice writers for this explicit migration. With a root\ndatabase handle and its known engine (`sqlite`, `postgres`, or `duckdb`), call\n`preflightInvoiceEditorStorage(db, engine)`, resolve every reported invalid row,\nthen `migrateInvoiceEditorStorage(db, engine)`. The preflight rejects negative,\nnonfinite, unsafe, excess-precision, and numerically non-roundtripping quantities.\nThe migration uses a transaction (and a PostgreSQL writer lock) and never rescales\nor rounds stored values. PostgreSQL widens INTEGER/BIGINT to DOUBLE PRECISION;\nDuckDB widens to DOUBLE. Repeating it leaves an already widened declaration alone.\nAn absent table is reported as absent and is never marked migrated.\n\nSQLite uses the public core table-rebuild planner to change the declaration to\nREAL while preserving existing columns, constraints, indexes, and triggers. If\nincoming foreign keys make that rebuild unsafe, the migration throws with the\nplanner reason and rolls back; it does not disable constraints or lose children.\nResolve that maintenance condition before deploying fractional writers. The same\ntransaction adds the nullable TEXT `invoice_editor_state_json` column with an empty\nstring default on every engine. This column is required for updated model saves.\nAn existing floating quantity column still receives the missing state column.\nNo schema mutation runs implicitly during runtime initialization or model save.\n\nThe actual DuckDB quantity/storage migration is tested. Complete Commerce model\npersistence on DuckDB is currently unsupported because its hard foreign keys use\nON UPDATE CASCADE, which DuckDB cannot preserve; the schema builder refuses that\ncontract. SQLite and PostgreSQL exercise complete save/reload model behavior.\n"
|
|
19525
|
+
},
|
|
19526
|
+
{
|
|
19527
|
+
"path": "agents/commercial-ui.md",
|
|
19528
|
+
"module": "commercial-ui",
|
|
19529
|
+
"content": "# Commercial UI composition\n\nThe commercial UI release is tracked in [#3423](https://github.com/happyvertical/smrt/issues/3423).\nComponents compose the provider-free controls in `@happyvertical/smrt-ui/forms`\nand visual controls in `@happyvertical/smrt-ui`. They do not require SvelteKit,\na database, or the top-level `smrt-svelte` integration package.\n\n## Native forms and recovery\n\nThe application owns action URLs, HTTP methods, submitter names/values, field\nnames, request identity, tenant context and returned validation errors. Native\nforms use the base `Form` with `preventDefault={false}`. Enhancement is optional;\na handler that takes over submission is responsible for preventing navigation.\n\nA failed action returns the submitted strings, including invalid amounts and\nrepeated fields, alongside errors. Components must not reconstruct these values\nfrom saved models or generate a replacement request token. Unknown commit outcomes\nretain the same identity and payload until the application resolves the result.\nFile inputs cannot be repopulated after navigation: explain that the file must be\nselected again and retain the caller's recovery identity.\n\nThe existing [form retry contract](../../../docs/content/form-retry.md) provides\noptional browser enhancement. It does not replace server-side tenant/actor checks\nor idempotency. Permission props control presentation only; applications must\nsupply already-authorized data and reauthorize every submitted action.\n\n## Amounts and public compatibility\n\nNew commercial editors use explicitly documented integer minor-unit amounts.\nEditable values remain strings until the owning server validates them; blank,\ninvalid and zero are distinct. Currency and its display precision are explicit.\nDo not mix currencies in totals or infer an unknown allocation as zero.\n\nThe original invoice display DTOs in `src/svelte/types.ts` and their components\naccept decimal major-unit amounts. Preserve that published behavior. Applications\nadapting model amounts into those legacy components must explicitly convert units;\ndo not pass a model's integer amount directly or silently change a legacy prop's\nmeaning. New editor DTOs are separate contracts.\n\n## Domain boundaries\n\nCustomer and Vendor reference Profiles identity data. A consumer's Client record\nis not automatically a Commerce Customer; UI adoption performs no identity merge\nor persistence migration. Domain-specific contact fields, trades, project/package\nallocations and approval policies enter through typed adapters and extension slots.\n\nQuote/estimate preparation, customer acceptance, vendor award, expense review and\ninvoice approval are separate decisions. A UI status or selection must never\nimplicitly perform another transition. The server supplies current decision and\nsource availability, including restricted-reader presentation.\n\n## Backend capability assessment\n\nAssessment baseline: SMRT `98c872be4` and reviewed DomaCraft epic-138\n`b03b322f75fbfdcb9193b18e73d3ec818ef18811`.\n\n| Responsibility | Existing capability | Release decision |\n| --- | --- | --- |\n| Commitment versus actual | `ExpenseCollection.commitmentPosition()` sums reviewed, non-duplicate expenses in the commitment currency, optionally per line. | Reuse Expenses; keep DomaCraft award lineage and package-cap constraints in its adapter. |\n| Receipt identity and duplicate review | `ExpenseReceiptCollection` owns receipt joins and duplicate detection; `Expense` owns guarded review transitions. | Reuse these contracts. Private upload authorization, draft capacity and storage compensation stay in application composition. |\n| Idempotent submission and uncertain outcomes | Core `runOnce()` and UI `createFormRetry()` provide shared server/browser primitives. | Preserve the caller's request identity. Do not replace DomaCraft's transaction-bound financial submission logic without proving equivalent transaction and recovery semantics. |\n| Invoice source reservations | DomaCraft `finance.ts` reserves its own cost allocations under project transaction locks; Commerce Invoice has no equivalent source-allocation contract. | Keep the reservation service local: extracting it changes a published financial contract and requires an agreed cross-domain source model. UI accepts supplied source availability. |\n| Source-change approval invalidation | DomaCraft `withdrawInvoiceApprovalForSource()` and invoice fingerprint checks bind project policy, actor decisions and retained sources. | Keep this policy local. Shared presentation renders server-supplied approval state and cannot authorize execution. |\n\nNo generic backend redesign is required by these UI slices. A future extraction\nneeds evidence that a contract works beyond the application-specific project and\npolicy model; UI reuse alone does not establish that requirement.\n\n## Validation and consumer adoption\n\nEach slice covers its public exports, SSR/native payload, retained errors,\nread-only and empty states, keyboard operation and a 390px viewport. The release\nalso validates the complete touched packages and integrated workbench. A browser\nviewport test does not establish physical-device camera behavior.\n\nDomaCraft adopts a published release through normal dependency admission. Its\nnative financial, authorization, tenant and uncertain-outcome tests remain required;\nshared component tests do not establish those server-side properties.\n"
|
|
19530
|
+
},
|
|
19531
|
+
{
|
|
19532
|
+
"path": "agents/quote-ui.md",
|
|
19533
|
+
"module": "quote-ui",
|
|
19534
|
+
"content": "# Quotation and estimate presentation\n\nImport `QuoteEditor`, `QuoteRevisionHistory`, `QuoteRevisionComparison` and their\nProps/DTOs from `@happyvertical/smrt-commerce/svelte`. Registry slots are\n`quote-editor`, `quote-revision-history`, and `quote-revision-comparison`.\nCommerce playground has interactive vendor quotation and customer estimate demos.\n\n`kind: 'vendor-quotation'` means received supplier evidence;\n`kind: 'customer-estimate'` means pricing proposed to a customer. Neither widget\nselects pricing, accepts budgets, awards work, sends messages or mutates a model.\nAdapt public Commerce Estimate/Contract fields (`currency`, `totalAmount`,\n`status`, `customerId`/`vendorId`) to these presentation DTOs;\nconstruction package allocation and approval policy remain caller extensions.\nClient records need not become Commerce Customers.\n\n`QuoteRevision.total.amountMinor` is a safe integer or null (unknown), with an\nexplicit `minorUnitDigits` from the owning currency/asset contract. No locale\ninference, monetary rounding or unknown-as-zero comparison occurs. Different\nkinds/currencies/scales cannot produce a delta. Existing invoice `LineItem`\nmajor-unit props retain their established contract. Form money values are raw\n**major-unit strings**: the caller validates and converts before saving; invalid\nstrings remain visible after failed actions. Dates likewise remain text to avoid\nnative date inputs silently clearing rejected server values.\n\nSupply `values`, authorized `counterparties`, `action`, optional `method`,\n`hiddenFields` (name/value pairs), `errors` and `message`. `names` maps payload\nnames to an existing endpoint. `labels.counterparty = 'Client'` supports local\nterminology. `extensions` composes pricing-source/allocation fields;\n`attachments` composes private Assets controls without loading bytes here.\n`QuoteRevisionHistory.evidence(revision)` composes authorized document links.\nAll fixed component text uses the shared i18n catalog; data/status/date labels\ncome from the caller.\n\nCurrency uses the shared `CurrencySelect`, localized by the active smrt-ui i18n\ncontext. Its native name still comes from `names.currency`, and rejected or\nunknown raw strings remain selected for a corrected submission. Pass\n`currencyOptions` only when the caller must restrict the catalog.\n\nRows submit repeated `lineId`, `lineDescription`, `lineQuantity`, `lineUnitRate`.\nDefault submitter `intent` is `save`, `addLine`, or `removeLine:<stable-id>`;\noverride via `intents`. Add/remove are ordinary `formnovalidate` POSTs, including\nall current strings and hidden tokens even without JavaScript. The action must\nread all repeated fields, preserve row IDs, and re-render them; add/remove are\nform-edit operations, not financial writes. Never treat an add-row POST as a save.\n\nThe editor does not generate or rotate submission tokens. Caller-owned\n`@happyvertical/smrt-ui/form-retry` can be attached via `formAttributes` using\nSvelte's `createAttachmentKey()` and `retry.attach`; caller transport enhancement\nmay use `onsubmit`. Use the same server-owned hidden identity when retrying an\nuncertain commit. Never blindly reset after a response. The caller must preserve\nhidden tenant/request/predecessor values, validate authorization on every action,\nand enforce idempotency transactionally. See `docs/content/form-retry.md`.\n`readonly`/`busy` only disable the presented fields; these props are not security.\n\nValidation: `pnpm --filter @happyvertical/smrt-commerce exec vitest run --config vitest.quotes.config.ts`,\n`node packages/commerce/test-support/quote-browser.mjs`, package typecheck/build.\nThe browser harness executes actual SSR POST recovery with JavaScript disabled\nand hydrated demo interactions at 390px. Full release CI/E2E runs at #3423's\nconsolidated release boundary by explicit owner instruction.\n\nOn NixOS, the browser lane accepts the repository-standard\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/run/current-system/sw/bin/chromium`.\nSet `QUOTE_EVIDENCE_DIR` and `CI_TEST_TMPDIR` to an owned evidence directory outside\n`/tmp`. Other commercial tests may reuse its host via\n`QUOTE_BROWSER_EXTENSION=/absolute/fixture.mjs`: export `handleRequest(req, res,\n{vite, render, css, requests})` returning true when handled, and\n`run({browser, baseUrl, evidence, requests})` for additional assertions.\n\nInvalid or unsafe display amounts/scales render localized unavailable text.\nStrict money helpers continue to reject malformed data; presentation recovery\ndoes not validate or authorize submitted money.\n\nThe `attachments` snippet renders outside the editor form so an Assets\n`AttachmentPanel`/`AttachmentUpload` keeps its own multipart action and request\nidentity. The attachment region remains disabled while the editor is read-only\nor busy. Use `extensions` only for controls belonging to the editor submission;\nnever place another form there.\n\nComposition regression: run the quote SSR lane, then the maintained quote browser\nhost with `QUOTE_BROWSER_EXTENSION=packages/commerce/test-support/quote-attachments-browser.mjs`.\nIt submits actual Assets upload bytes and the separate editor action with and\nwithout JavaScript at 390px.\n"
|
|
19535
|
+
},
|
|
19536
|
+
{
|
|
19537
|
+
"path": "agents/purchasing-ui.md",
|
|
19538
|
+
"module": "purchasing-ui",
|
|
19539
|
+
"content": "# Purchasing and award presentation\n\nPublic `@happyvertical/smrt-commerce/svelte` exports `PurchaseSourceSelector`,\n`PurchaseOrderEditor`, their Props and `Purchase*` DTOs. Registry slots are\n`purchase-source-selector` and `purchase-order-editor`. Interactive playground\nentries cover ordinary amendment and explicit retained-allocation reduction.\n\nThese components use public Commerce PurchaseOrder/Contract as their owning\nfinancial domain without adding a backend command, ledger or fixed approval\npolicy. The caller decides instrument options, source availability, allocation\nlabels, prior commitments, reviewed actuals, floors/ceilings and exact source\nidentity. Construction work packages, accepted budgets, contractor entitlement\nand vendor award policy stay in the consumer. Capability/disabled props only\ncontrol presentation. Every POST needs authoritative server authorization.\n\n`PurchaseSourceSelector` uses a native GET to the supplied action with `name`\n(default `source`) and optional hidden search/context fields. It never writes an\naward. Disabled sources remain visible with caller notices; unknown saved IDs\nremain visible as unavailable. Keep source selection outside a reduction form;\nthe server must reject a downward amendment under an ordinary amendment mode\nand enforce the retained source/instrument during reductions.\n\n`PurchaseOrderEditor` receives `operation` (`create`, `amend`, `reduce`), a\nretained `source`, `currency`, explicit `minorUnitDigits`, raw `values`,\n`instruments`, native POST `action`, and caller-owned `hiddenFields`. Supply\nsource ID, currency, predecessor, tenant and retry IDs in those hidden fields\nunder the exact names your endpoint expects. `names` adapts all built-in fields.\n`labels` adapts approval language; `extensions` and `attachments` compose domain\nfields and authorized Assets evidence without fetching bytes in this component.\n`formAttributes` supports native encoding/ID and caller-owned form-retry Svelte\nattachments; `onsubmit` supports caller transport enhancement. Tokens are never\nminted or rotated here. See the shared [form/action contract](commercial-ui.md)\nand repository `docs/content/form-retry.md`.\n\nAll editable money is **raw major-unit text**, including malformed values and\nempty unknown amounts. The caller parses against the exact currency contract;\nthere is no UI floating-point calculation or implicit blank-to-zero conversion.\nSaved/source/review numbers are safe integer minor units formatted by the\nshared quotation helper. `sourceMinor: null` renders Unknown. Displayed bounds\nare guidance; the server validates floors, ceilings and source eligibility.\n\nEvery allocation posts its stable `id` and amount, even when zero. Mark every\npredecessor allocation `retained: true`. A `reduce` form requires those amounts\nexplicitly and never offers their removal. Supplying fewer predecessor rows is\nnot safe: the server must compare the complete ID set and reject omissions.\nOptional `rowActions` uses native formnovalidate add/remove POSTs carrying all\ncurrent fields; the caller re-renders unchanged rejected strings and IDs.\nNew-row IDs belong to the caller. Row editing never records a commitment.\n\nPressing Enter in an input activates review or record, preserving draft rows.\nExplicit add/remove actions remain available without JavaScript.\nDefault submitter `intent` values are `review`, `record`, `edit`, all configurable.\nOnly a caller-produced `review` snapshot or a retained `retryFingerprint` exposes\nexplicit confirmation. `review.fingerprint` posts under `reviewFingerprint` by\ndefault. After an uncertain outcome, re-render the exact submitted values,\nrequest identity and fingerprint; never fabricate a new intent/token. Confirmation\nis an unchecked required checkbox on native renders and when a new review\nfingerprint arrives in a hydrated form. Its value defaults to\n`yes`. The server must bind fingerprint to all submitted values and authority,\nreject changed/stale review data, and replay identical committed retries.\nRendering a review snapshot or selecting a source never accepts/records anything.\n\nValidation: `pnpm --filter @happyvertical/smrt-commerce exec vitest run --config\nvitest.purchasing.config.ts`, owning build/typecheck/pack checks, and the shared\nquote native/hydrated browser harness with `QUOTE_BROWSER_EXTENSION` pointing at\n`packages/commerce/test-support/purchasing-browser-extension.mjs`. Full repository\nCI/E2E is consolidated at the #3423 release boundary by owner instruction.\n\nInvalid or unsafe display amounts/scales render localized unavailable text.\nStrict money helpers continue to reject malformed data; presentation recovery\ndoes not validate or authorize submitted money.\n\nThe `attachments` snippet renders outside the editor form so an Assets\n`AttachmentPanel`/`AttachmentUpload` keeps its own multipart action and request\nidentity. The attachment region remains disabled while the editor is read-only\nor busy. Use `extensions` only for controls belonging to the editor submission;\nnever place another form there.\n\nComposition regression: run the quote SSR lane, then the maintained quote browser\nhost with `QUOTE_BROWSER_EXTENSION=packages/commerce/test-support/quote-attachments-browser.mjs`.\nIt submits actual Assets upload bytes and the separate editor action with and\nwithout JavaScript at 390px.\n"
|
|
19540
|
+
}
|
|
19541
|
+
]
|
|
19440
19542
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { StatusBadge } from '@happyvertical/smrt-ui';
|
|
3
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
4
|
+
import { PageHeader } from '@happyvertical/smrt-ui/layout';
|
|
5
|
+
import { Button, Card } from '@happyvertical/smrt-ui/ui';
|
|
6
|
+
import { M } from '../i18n.js';
|
|
7
|
+
import type {
|
|
8
|
+
CustomerDisplayData,
|
|
9
|
+
PartyContactLabels,
|
|
10
|
+
PartyExtension,
|
|
11
|
+
PartySurfaceLabels,
|
|
12
|
+
} from '../party-types.js';
|
|
13
|
+
import { formatPartyAddress, formatPartyMinorUnits } from '../party-types.js';
|
|
14
|
+
import PartyContactFields from './PartyContactFields.svelte';
|
|
15
|
+
|
|
16
|
+
/** Props for the customer detail/summary surface. */
|
|
17
|
+
export interface Props {
|
|
18
|
+
/** Customer and Profile projection to render. */
|
|
19
|
+
customer: CustomerDisplayData;
|
|
20
|
+
/** Customer terminology and action-label overrides. */
|
|
21
|
+
labels?: PartySurfaceLabels;
|
|
22
|
+
/** Contact terminology overrides. */
|
|
23
|
+
contactLabels?: PartyContactLabels;
|
|
24
|
+
/** Caller-owned URL back to the customer directory. */
|
|
25
|
+
backHref?: string;
|
|
26
|
+
/** Caller-owned URL for editing this customer. */
|
|
27
|
+
editHref?: string;
|
|
28
|
+
/** Controls whether the edit affordance is presented. */
|
|
29
|
+
canEdit?: boolean;
|
|
30
|
+
/** ISO currency code used to display the minor-unit credit limit. */
|
|
31
|
+
currency?: string;
|
|
32
|
+
/** Currency exponent used only for minor-unit display conversion. */
|
|
33
|
+
minorUnitExponent?: number;
|
|
34
|
+
/** Locale used for currency display. */
|
|
35
|
+
locale?: string;
|
|
36
|
+
/** Consumer content rendered after the standard customer sections. */
|
|
37
|
+
extension?: PartyExtension<CustomerDisplayData>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
customer,
|
|
42
|
+
labels = {},
|
|
43
|
+
contactLabels,
|
|
44
|
+
backHref,
|
|
45
|
+
editHref,
|
|
46
|
+
canEdit = false,
|
|
47
|
+
currency = 'USD',
|
|
48
|
+
minorUnitExponent = 2,
|
|
49
|
+
locale = 'en',
|
|
50
|
+
extension,
|
|
51
|
+
}: Props = $props();
|
|
52
|
+
|
|
53
|
+
const { t } = useI18n();
|
|
54
|
+
const singular = $derived(
|
|
55
|
+
labels.singular ?? t(M['commerce.customer.singular']),
|
|
56
|
+
);
|
|
57
|
+
const plural = $derived(labels.plural ?? t(M['commerce.customer.plural']));
|
|
58
|
+
const notRecorded = $derived(t(M['commerce.party.not_recorded']));
|
|
59
|
+
const amountUnavailable = $derived(t(M['commerce.party.amount_unavailable']));
|
|
60
|
+
const shippingAddress = $derived(
|
|
61
|
+
formatPartyAddress(customer.defaultShippingAddress),
|
|
62
|
+
);
|
|
63
|
+
const billingAddress = $derived(
|
|
64
|
+
formatPartyAddress(customer.defaultBillingAddress),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
function statusLabel(value: string): string {
|
|
68
|
+
if (value === 'active') return t(M['commerce.party.status_active']);
|
|
69
|
+
if (value === 'inactive') return t(M['commerce.party.status_inactive']);
|
|
70
|
+
if (value === 'suspended') return t(M['commerce.party.status_suspended']);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function customerTypeLabel(value: string | undefined): string {
|
|
75
|
+
if (value === 'dtc') return t(M['commerce.customer.type_dtc']);
|
|
76
|
+
if (value === 'wholesale') return t(M['commerce.customer.type_wholesale']);
|
|
77
|
+
if (value === 'retail') return t(M['commerce.customer.type_retail']);
|
|
78
|
+
return value ?? notRecorded;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function identityKindLabel(value: string | undefined): string {
|
|
82
|
+
if (value === 'business') return t(M['commerce.party.identity_business']);
|
|
83
|
+
if (value === 'person') return t(M['commerce.party.identity_person']);
|
|
84
|
+
return value || notRecorded;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function creditLimit(amountMinor: number): string {
|
|
88
|
+
try {
|
|
89
|
+
return formatPartyMinorUnits(
|
|
90
|
+
amountMinor,
|
|
91
|
+
currency,
|
|
92
|
+
minorUnitExponent,
|
|
93
|
+
locale,
|
|
94
|
+
);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof RangeError) return amountUnavailable;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<section class="party-detail">
|
|
103
|
+
{#if backHref}<Button href={backHref} variant="ghost">{t(M['commerce.party.back_to'], { plural })}</Button>{/if}
|
|
104
|
+
<PageHeader title={customer.profile.name} subtitle={customer.profile.description ?? t(M['commerce.customer.details'])}>
|
|
105
|
+
{#snippet actions()}
|
|
106
|
+
{#if canEdit && editHref}<Button href={editHref}>{labels.edit ?? (labels.singular ? t(M['commerce.party.edit_named'], { singular }) : t(M['commerce.customer.edit']))}</Button>{/if}
|
|
107
|
+
{/snippet}
|
|
108
|
+
</PageHeader>
|
|
109
|
+
|
|
110
|
+
<div class="summary-grid">
|
|
111
|
+
<Card padding="md">
|
|
112
|
+
<h2>{t(M['commerce.party.identity'])}</h2>
|
|
113
|
+
<dl>
|
|
114
|
+
<dt>{t(M['commerce.party.type'])}</dt><dd>{identityKindLabel(customer.profile.identityKind)}</dd>
|
|
115
|
+
<dt>{t(M['commerce.party.status'])}</dt><dd><StatusBadge status={customer.status} label={statusLabel(customer.status)} /></dd>
|
|
116
|
+
<dt>{t(M['commerce.customer.type'])}</dt><dd>{customerTypeLabel(customer.customerType)}</dd>
|
|
117
|
+
<dt>{t(M['commerce.party.email'])}</dt><dd>{#if customer.profile.email}<a href={`mailto:${customer.profile.email}`}>{customer.profile.email}</a>{:else}{notRecorded}{/if}</dd>
|
|
118
|
+
</dl>
|
|
119
|
+
</Card>
|
|
120
|
+
<Card padding="md">
|
|
121
|
+
<h2>{t(M['commerce.party.commercial_terms'])}</h2>
|
|
122
|
+
<dl>
|
|
123
|
+
<dt>{t(M['commerce.customer.credit_limit'])}</dt><dd>{customer.creditLimitMinor === undefined ? notRecorded : creditLimit(customer.creditLimitMinor)}</dd>
|
|
124
|
+
<dt>{t(M['commerce.party.payment_terms'])}</dt><dd>{customer.paymentTerms || notRecorded}</dd>
|
|
125
|
+
<dt>{t(M['commerce.customer.tax_exempt'])}</dt><dd>{customer.taxExempt ? t(M['commerce.party.yes']) : t(M['commerce.party.no'])}</dd>
|
|
126
|
+
</dl>
|
|
127
|
+
</Card>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="summary-grid">
|
|
131
|
+
<Card padding="md"><h2>{t(M['commerce.party.shipping_address'])}</h2><p class="address">{shippingAddress || notRecorded}</p></Card>
|
|
132
|
+
<Card padding="md"><h2>{t(M['commerce.party.billing_address'])}</h2><p class="address">{billingAddress || notRecorded}</p></Card>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<PartyContactFields contacts={customer.contacts ?? []} labels={contactLabels} readOnly />
|
|
136
|
+
{#if customer.notes}<Card padding="md"><h2>{t(M['commerce.party.notes'])}</h2><p class="notes">{customer.notes}</p></Card>{/if}
|
|
137
|
+
{@render extension?.(customer)}
|
|
138
|
+
</section>
|
|
139
|
+
|
|
140
|
+
<style>
|
|
141
|
+
.party-detail { display: grid; gap: var(--smrt-spacing-5); max-inline-size: 64rem; min-inline-size: 0; overflow-wrap: anywhere; }
|
|
142
|
+
.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--smrt-spacing-4); }
|
|
143
|
+
h2 { margin: 0 0 var(--smrt-spacing-4); font: var(--smrt-typography-title-medium-font); color: var(--smrt-color-on-surface); }
|
|
144
|
+
dl { display: grid; grid-template-columns: minmax(7rem, max-content) minmax(0, 1fr); gap: var(--smrt-spacing-2) var(--smrt-spacing-4); margin: 0; }
|
|
145
|
+
dt { color: var(--smrt-color-on-surface-variant); }
|
|
146
|
+
dd { margin: 0; }
|
|
147
|
+
a { color: var(--smrt-color-primary); }
|
|
148
|
+
.address, .notes { margin: 0; white-space: pre-wrap; }
|
|
149
|
+
@media (max-width: 42rem) { .summary-grid { grid-template-columns: minmax(0, 1fr); } dl { grid-template-columns: minmax(0, 1fr); gap: var(--smrt-spacing-1); } dd { margin-block-end: var(--smrt-spacing-2); } }
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CustomerDisplayData, PartyContactLabels, PartyExtension, PartySurfaceLabels } from '../party-types.js';
|
|
2
|
+
/** Props for the customer detail/summary surface. */
|
|
3
|
+
export interface Props {
|
|
4
|
+
/** Customer and Profile projection to render. */
|
|
5
|
+
customer: CustomerDisplayData;
|
|
6
|
+
/** Customer terminology and action-label overrides. */
|
|
7
|
+
labels?: PartySurfaceLabels;
|
|
8
|
+
/** Contact terminology overrides. */
|
|
9
|
+
contactLabels?: PartyContactLabels;
|
|
10
|
+
/** Caller-owned URL back to the customer directory. */
|
|
11
|
+
backHref?: string;
|
|
12
|
+
/** Caller-owned URL for editing this customer. */
|
|
13
|
+
editHref?: string;
|
|
14
|
+
/** Controls whether the edit affordance is presented. */
|
|
15
|
+
canEdit?: boolean;
|
|
16
|
+
/** ISO currency code used to display the minor-unit credit limit. */
|
|
17
|
+
currency?: string;
|
|
18
|
+
/** Currency exponent used only for minor-unit display conversion. */
|
|
19
|
+
minorUnitExponent?: number;
|
|
20
|
+
/** Locale used for currency display. */
|
|
21
|
+
locale?: string;
|
|
22
|
+
/** Consumer content rendered after the standard customer sections. */
|
|
23
|
+
extension?: PartyExtension<CustomerDisplayData>;
|
|
24
|
+
}
|
|
25
|
+
declare const CustomerDetail: import("svelte").Component<Props, {}, "">;
|
|
26
|
+
type CustomerDetail = ReturnType<typeof CustomerDetail>;
|
|
27
|
+
export default CustomerDetail;
|
|
28
|
+
//# sourceMappingURL=CustomerDetail.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CustomerDetail.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/CustomerDetail.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,mBAAmB,EACnB,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EACnB,MAAM,mBAAmB,CAAC;AAK3B,qDAAqD;AACrD,MAAM,WAAW,KAAK;IACpB,iDAAiD;IACjD,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,uDAAuD;IACvD,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,qCAAqC;IACrC,aAAa,CAAC,EAAE,kBAAkB,CAAC;IACnC,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,SAAS,CAAC,EAAE,cAAc,CAAC,mBAAmB,CAAC,CAAC;CACjD;AAoHD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|