@happyvertical/smrt-expenses 0.56.0 → 0.56.1
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 +35 -4
- package/README.md +18 -4
- package/dist/actions/expense-actions.d.ts +77 -0
- package/dist/actions/expense-actions.d.ts.map +1 -0
- package/dist/actions/principal.d.ts +54 -0
- package/dist/actions/principal.d.ts.map +1 -0
- package/dist/chunks/{Expense-i8NeN2D3.js → Expense-7afLcUTn.js} +10 -5
- package/dist/chunks/Expense-7afLcUTn.js.map +1 -0
- package/dist/chunks/ExpenseReceiptCollection-y1SlmnBS.js +304 -0
- package/dist/chunks/ExpenseReceiptCollection-y1SlmnBS.js.map +1 -0
- package/dist/chunks/expense-actions-Ce5CBaBE.js +132 -0
- package/dist/chunks/expense-actions-Ce5CBaBE.js.map +1 -0
- package/dist/chunks/principal-_1CdcvvW.js +107 -0
- package/dist/chunks/principal-_1CdcvvW.js.map +1 -0
- package/dist/collections/ExpenseCollection.d.ts +55 -3
- package/dist/collections/ExpenseCollection.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +178 -310
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +269 -7
- package/dist/models/Expense.d.ts +7 -3
- package/dist/models/Expense.d.ts.map +1 -1
- package/dist/recipes.d.ts +118 -0
- package/dist/recipes.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +275 -8
- package/package.json +9 -8
- package/dist/chunks/Expense-i8NeN2D3.js.map +0 -1
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-expenses",
|
|
6
|
-
"packageVersion": "0.56.
|
|
6
|
+
"packageVersion": "0.56.1",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
12
|
-
"agents": "
|
|
10
|
+
"manifest": "be805516392b4f8aeddd60fc1a4e454014a52e02b86b68bbf9e4bb067c3754d0",
|
|
11
|
+
"packageJson": "d43dc00d530ed9c6343137ed9444f2728ac7e50cb7667b94acfe358d0c4b2ab8",
|
|
12
|
+
"agents": "9ce0e6213406ad655e3490340de494ffe7ac9831982d138c25a09c2936070bdc",
|
|
13
13
|
"moduleDoc:agents/financial-facts-test-matrix.md": "798c9f6c7c5694057ec438cf17252832a38164dd80a47f77c9cd4642b3e5cbd6",
|
|
14
14
|
"moduleDoc:agents/expense-ui.md": "0bdea9e6c627f8cbab2a1f21ef4da3ab7f00409764a1ef48572dd40694fb6785"
|
|
15
15
|
},
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@happyvertical/smrt-tenancy": "workspace:*",
|
|
29
29
|
"@happyvertical/smrt-types": "workspace:*",
|
|
30
30
|
"@happyvertical/smrt-ui": "workspace:*",
|
|
31
|
+
"@happyvertical/smrt-users": "workspace:*",
|
|
31
32
|
"@happyvertical/smrt-vitest": "workspace:*",
|
|
32
33
|
"@types/node": "24.13.6",
|
|
33
34
|
"typescript": "6.0.3",
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"@happyvertical/smrt-tenancy",
|
|
46
47
|
"@happyvertical/smrt-types",
|
|
47
48
|
"@happyvertical/smrt-ui",
|
|
49
|
+
"@happyvertical/smrt-users",
|
|
48
50
|
"@happyvertical/smrt-vitest"
|
|
49
51
|
],
|
|
50
52
|
"sdkDependencies": [],
|
|
@@ -72,11 +74,24 @@
|
|
|
72
74
|
"fields": [],
|
|
73
75
|
"relationships": [],
|
|
74
76
|
"methods": [
|
|
77
|
+
"attachReceipt",
|
|
75
78
|
"commitmentPosition",
|
|
76
79
|
"forCostObject",
|
|
80
|
+
"listReviewedTaxFacts",
|
|
81
|
+
"record",
|
|
82
|
+
"recordReimbursement",
|
|
83
|
+
"recordReview",
|
|
77
84
|
"totalsByCurrency"
|
|
78
85
|
],
|
|
79
86
|
"methodSignatures": [
|
|
87
|
+
{
|
|
88
|
+
"name": "attachReceipt",
|
|
89
|
+
"async": true,
|
|
90
|
+
"params": [
|
|
91
|
+
"options: AttachExpenseReceiptActionInput"
|
|
92
|
+
],
|
|
93
|
+
"returns": "Promise<ExpenseReceipt>"
|
|
94
|
+
},
|
|
80
95
|
{
|
|
81
96
|
"name": "commitmentPosition",
|
|
82
97
|
"async": true,
|
|
@@ -95,6 +110,38 @@
|
|
|
95
110
|
],
|
|
96
111
|
"returns": "Promise<Expense[]>"
|
|
97
112
|
},
|
|
113
|
+
{
|
|
114
|
+
"name": "listReviewedTaxFacts",
|
|
115
|
+
"async": true,
|
|
116
|
+
"params": [
|
|
117
|
+
"options: ReviewedExpenseTaxFactsOptions"
|
|
118
|
+
],
|
|
119
|
+
"returns": "Promise<ReviewedExpenseTaxFact[]>"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"name": "record",
|
|
123
|
+
"async": true,
|
|
124
|
+
"params": [
|
|
125
|
+
"options: RecordExpenseActionInput"
|
|
126
|
+
],
|
|
127
|
+
"returns": "Promise<Expense>"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "recordReimbursement",
|
|
131
|
+
"async": true,
|
|
132
|
+
"params": [
|
|
133
|
+
"options: MarkExpenseReimbursedActionInput"
|
|
134
|
+
],
|
|
135
|
+
"returns": "Promise<Expense>"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "recordReview",
|
|
139
|
+
"async": true,
|
|
140
|
+
"params": [
|
|
141
|
+
"options: ReviewExpenseActionInput"
|
|
142
|
+
],
|
|
143
|
+
"returns": "Promise<Expense>"
|
|
144
|
+
},
|
|
98
145
|
{
|
|
99
146
|
"name": "totalsByCurrency",
|
|
100
147
|
"async": true,
|
|
@@ -104,7 +151,40 @@
|
|
|
104
151
|
"returns": "Promise<CurrencyTotals>"
|
|
105
152
|
}
|
|
106
153
|
],
|
|
107
|
-
"surfaces": [
|
|
154
|
+
"surfaces": [
|
|
155
|
+
{
|
|
156
|
+
"kind": "api",
|
|
157
|
+
"name": "expenses.record",
|
|
158
|
+
"operation": "record",
|
|
159
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
160
|
+
"path": "/expenses/record",
|
|
161
|
+
"method": "POST"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"kind": "api",
|
|
165
|
+
"name": "expenses.attachReceipt",
|
|
166
|
+
"operation": "attachReceipt",
|
|
167
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
168
|
+
"path": "/expenses/attachReceipt",
|
|
169
|
+
"method": "POST"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"kind": "api",
|
|
173
|
+
"name": "expenses.recordReview",
|
|
174
|
+
"operation": "recordReview",
|
|
175
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
176
|
+
"path": "/expenses/recordReview",
|
|
177
|
+
"method": "POST"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"kind": "api",
|
|
181
|
+
"name": "expenses.recordReimbursement",
|
|
182
|
+
"operation": "recordReimbursement",
|
|
183
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
184
|
+
"path": "/expenses/recordReimbursement",
|
|
185
|
+
"method": "POST"
|
|
186
|
+
}
|
|
187
|
+
],
|
|
108
188
|
"withheldSurfaces": [
|
|
109
189
|
{
|
|
110
190
|
"kind": "api",
|
|
@@ -120,6 +200,13 @@
|
|
|
120
200
|
"reason": "not listed in api.include",
|
|
121
201
|
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection"
|
|
122
202
|
},
|
|
203
|
+
{
|
|
204
|
+
"kind": "api",
|
|
205
|
+
"operation": "listReviewedTaxFacts",
|
|
206
|
+
"code": "not-included",
|
|
207
|
+
"reason": "not listed in api.include",
|
|
208
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection"
|
|
209
|
+
},
|
|
123
210
|
{
|
|
124
211
|
"kind": "api",
|
|
125
212
|
"operation": "totalsByCurrency",
|
|
@@ -846,7 +933,40 @@
|
|
|
846
933
|
"risks": []
|
|
847
934
|
}
|
|
848
935
|
],
|
|
849
|
-
"surfaces": [
|
|
936
|
+
"surfaces": [
|
|
937
|
+
{
|
|
938
|
+
"kind": "api",
|
|
939
|
+
"name": "expenses.record",
|
|
940
|
+
"operation": "record",
|
|
941
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
942
|
+
"path": "/expenses/record",
|
|
943
|
+
"method": "POST"
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
"kind": "api",
|
|
947
|
+
"name": "expenses.attachReceipt",
|
|
948
|
+
"operation": "attachReceipt",
|
|
949
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
950
|
+
"path": "/expenses/attachReceipt",
|
|
951
|
+
"method": "POST"
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
"kind": "api",
|
|
955
|
+
"name": "expenses.recordReview",
|
|
956
|
+
"operation": "recordReview",
|
|
957
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
958
|
+
"path": "/expenses/recordReview",
|
|
959
|
+
"method": "POST"
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
"kind": "api",
|
|
963
|
+
"name": "expenses.recordReimbursement",
|
|
964
|
+
"operation": "recordReimbursement",
|
|
965
|
+
"objectName": "@happyvertical/smrt-expenses:ExpenseCollection",
|
|
966
|
+
"path": "/expenses/recordReimbursement",
|
|
967
|
+
"method": "POST"
|
|
968
|
+
}
|
|
969
|
+
],
|
|
850
970
|
"prompts": [],
|
|
851
971
|
"relationshipsV2": {
|
|
852
972
|
"foreignKeyFields": 3,
|
|
@@ -856,7 +976,7 @@
|
|
|
856
976
|
"polymorphicAssociations": 0,
|
|
857
977
|
"uuidColumns": 30
|
|
858
978
|
},
|
|
859
|
-
"agentDoc": "# @happyvertical/smrt-expenses\n\nIncurred costs against a cost object, with deduplicated receipts, an explicit\nreview, reimbursement recording, and smrt-commerce commitment drawdown (#3289).\n\n## Models\n\n- **Expense** (`expenses`): `costObjectType` (qualified class name) +\n `costObjectId` (bare string), `category` (lowercase kebab-case; suggestions\n in `SUGGESTED_EXPENSE_CATEGORIES`), `amount` integer minor units + ISO 4217\n `currency`, `incurredOn` (`YYYY-MM-DD` text: a calendar date, not an\n instant), `recordedAt`, `recordedByProfileId`, `paidBy` (`company` |\n `person` | `third-party`) + `paidByProfileId` / `paidByName`, `vendorId`, `commitmentId` /\n `commitmentLineId`, review fields, `reimbursable` / `reimbursedAt` /\n `reimbursementReference`.\n- **ExpenseReceipt** (`expense_receipts`): noun-specific asset join\n (`expenseId` → Expense, `assetId` → smrt-assets Asset) with uploader,\n `filename`, `mimeType`, `byteCount`, `contentSha256`. Collection extends\n `SmrtJunction` (left expense, right asset, ordered by `created_at`).\n\n## Invariants\n\n- **Review is method-only.** `review()`, `reject()`, `markDuplicate()`,\n `reopen()` read the persisted status, check the move, and authorize exactly\n one save through a module `WeakMap`. Every transition assigns every review\n field, and the token carries that complete resulting state, so a field set\n directly beside `review()`/`reject()` (e.g. an injected `duplicateOfId`)\n is refused. A `reviewed` expense never carries `duplicateOfId`. `save()` compares every review field\n (status, reviewer, time, note, `duplicateOfId`) with the stored row and\n refuses an unauthorized change (`EXPENSE_REVIEW_FIELDS_LOCKED`), including\n on a brand-new row. Review fields are also `readonly` for generated writes.\n- **Writes are pinned to the guarded row.** The guards read the row stored\n under `id`, but core's natural-key save adopts any same-owner row on\n `(tenant_id, slug, context)` and upserts it with no revision predicate;\n these models' slugs come from the id. So `pinNaturalKey()` lets a fresh\n (not `isPersisted`) instance only INSERT (`requireInsertOnSave()`): naming\n an existing row's id is `EXPENSE_IDENTITY_CONFLICT`, and a row appearing\n mid-save fails the INSERT instead of being adopted. An existing row changes\n only through a loaded instance (revision compare-and-swap) that keeps its\n slug/context. Never `create({ id: <existing> })` or `getOrUpsert()` these\n models; load and save instead.\n- **Reviewed money is frozen.** While the stored status is `reviewed`,\n `LOCKED_WHEN_REVIEWED` fields (money, currency, both dates, cost object,\n vendor, commitment, payer) cannot change; `reopen()` first. `recordedAt` is\n compared as an instant, since drivers return it as a `Date` or a string.\n- **Drawdown counts once.** `ExpenseCollection.commitmentPosition()` sums only\n `reviewed` rows with no `duplicateOfId`, in the commitment's currency, deduped\n by id. Committed is the contract's `totalAmount` (or the line's `amount`).\n- **Currencies never mix.** A commitment match must share the currency and\n tenant (`EXPENSE_COMMITMENT_MISMATCH`), checked when the match, line or\n currency is set or changed — not on every save, so a later commitment edit\n never blocks e.g. `markReimbursed()`. `totalsByCurrency()` keys by currency.\n Rows left in another currency by a later commitment change land in\n `otherCurrencies`, never in `drawn`.\n- **Duplicate receipts.** Same `(expense, sha256)` is refused by a pre-insert\n read plus the unique index `expense_receipts_expense_sha256_key`\n `(expense_id, content_sha256)`; a unique violation is rethrown as\n `DuplicateReceiptError`, classified from the error itself (violated key\n name, or `details.fieldName` columns on core's typed error) because on\n PostgreSQL the violation aborts a caller's transaction and nothing can be\n re-read; only an error naming no key falls back to a re-read. The index omits `tenant_id` deliberately: the\n expense already fixes the tenant, and a nullable leading `tenant_id` would\n leave NULL-tenant (global) receipts unenforced. Same hash on different\n expenses is allowed; `findDuplicateReceipts()` reports it (facet on\n `contentSha256`, count > 1). Receipt `tenantId` (derived from the\n expense on insert), `expenseId`, `assetId` and `contentSha256` are\n immutable. The inherited `SmrtJunction.attach()` and\n `setLinks()` are overridden to throw (`setLinks()` would delete every\n receipt, then fail to re-attach without hashes); `attachReceipt()` adds and\n `detach()` removes a link, keeping the asset. Because `ExpenseReceipt`\n overrides `save()`, `hasBaseJunctionLifecycle()` is false and no batch\n junction path can write a hashless row around it.\n- **Vendor ownership.** `vendorId` is a `crossPackageRef` (no FK, no\n validation), so `assertVendor()` checks the vendor is visible in and owned\n by the expense's tenant whenever it is set or changed\n (`EXPENSE_VENDOR_MISMATCH`).\n- **Tenant never moves.** Expense and receipt tenants are fixed once\n stored, compared as `effectiveTenant()` (the field, or the active context\n the `beforeSave` interceptor would fill an unset one from; unset is falsy,\n so `''` counts). `normalizeTenantId()` stores `''` as NULL (global), never\n as a pseudo-tenant, matching core/tenancy's falsy \"unset\". Otherwise a\n global (NULL-tenant) row saved inside `withTenant()` would be silently\n populated and split from its global expense or receipts.\n- **Tenancy.** Optional (`@TenantScoped({ mode: 'optional' })`, nullable\n `tenantId`), like smrt-commerce. A receipt takes its expense's tenant; its\n expense and asset must be visible in that tenant. A commitment must belong\n to the expense's tenant; before `super.save()` that is\n `tenantId ?? getTenantId()`, because the interceptor has not populated it.\n Consumers make it required with a qualified\n `registerTenantScopedClass(…, { mode: 'required' })`.\n- **Closed generated surface.** Models AND collections declare\n `api: { include: [] }`, `mcp: { include: [] }`, `cli: false`. Collection\n classes are registered too: an undecorated collection publishes its CRUD and\n public methods as MCP tools. Core has no consumer-side switch to reopen a\n package model's surface; consumers write their own routes.\n- **Cost object is not dereferenced on save.** `getCostObject()` resolves it\n through the registry and returns `null` for a foreign-tenant row. One cost\n object per expense (columns, not a `SmrtPolymorphicAssociation` join) so a\n cost can never roll up into two jobs.\n\n## Optional financial facts\n\n`financialBreakdown` is guarded JSON text (`get/setFinancialBreakdown`), absent\nfor legacy rows. Exact `getCostBasis()` returns null without facts. Complete facts\nreconcile subtotal + signed tax components to amount/currency; recoverable amounts\nare explicit. No tax law or customer pricing is inferred. `third-party` payer\nrequires `paidByName` and is not reimbursable. Negative amounts require `refundOfId`\n(same-owner/currency positive source, checked through the bound executor).\nSource edits also check inbound same-owner refunds: a referenced source stays\npositive, non-refund, and in their currency. `save()` encloses guards and write\nin the public `withTransaction()` boundary; PostgreSQL locks its row and proposed\nsource in id order before either refund check, while SQLite serializes writes.\nThe inbound projected lookup uses the existing `refund_of_id` index and an\nexplicit owner filter. Keep caller transaction affinity and per-row revision\nchecks; neither an unlocked lookup nor revision CAS alone protects this pair.\nAll three fields participate in the reviewed-money freeze. See the README and\n[financial facts test matrix](agents/financial-facts-test-matrix.md).\n\nLinking a new refund (or changing its source) also claims the source revision in\nthat same transaction through `claimRevision(source.updated_at)`. This makes a\nsource edit under an older PostgreSQL `REPEATABLE READ` snapshot fail with a\nserialization error instead of missing the new refund. Reload a source after\nlinking a refund before editing/reviewing it; on a serialization failure, retry\nthe entire caller transaction with fresh instances. Failed outer transactions\nroll back both the refund and the source revision claim. Existing-link saves do\nnot advance the source revision.\n\n## Cross-package references\n\n- `vendorId` → `@crossPackageRef('@happyvertical/smrt-commerce:Vendor')`\n- `commitmentId` → `…smrt-commerce:PurchaseContract` (`PurchaseOrder` or `ProductionOrder`)\n- `commitmentLineId` → `…smrt-commerce:ContractLineItem`\n- `assetId` → `…smrt-assets:Asset`\n- profile ids → `…smrt-profiles:Profile` (no dependency; references only)\n- `expenseId`, `duplicateOfId` → `@foreignKey` within the package\n\n## Scope\n\nAllowance (a planned amount) is not an expense kind; it belongs with\nbudget/commitment records. Labour belongs to `@happyvertical/smrt-timesheets`\n(#3288). No OCR, no accounting-provider posting.\n\n## Validation\n\n```bash\npnpm --filter @happyvertical/smrt-expenses test\npnpm --filter @happyvertical/smrt-expenses typecheck\npnpm --filter @happyvertical/smrt-expenses test:postgres\n```\n\n`src/__tests__/helpers/expense-suite.ts` is the shared behaviour suite: the\nSQLite file and the PostgreSQL lane (`*.optional.test.ts`, schema from\n`migrateSmrtSchemas` plus live parity) both run it. Use UUID tenant and\nprofile ids in tests: PostgreSQL stores them as `uuid`.\n\n## UI\n\n`src/svelte/` owns native expense entry and review presentation. See\n[the UI contract](agents/expense-ui.md) for DTOs, native payloads and recovery.\nDraft amounts are lossless currency-unit text; model/review amounts remain integer\nminor units. Receipt upload is composed from Assets, never reimplemented here.\n`test` includes backend and UI SSR; `test:e2e` proves native browser submissions.\n",
|
|
979
|
+
"agentDoc": "# @happyvertical/smrt-expenses\n\nIncurred costs against a cost object, with deduplicated receipts, an explicit\nreview, reimbursement recording, and smrt-commerce commitment drawdown (#3289).\n\n## Models\n\n- **Expense** (`expenses`): `costObjectType` (qualified class name) +\n `costObjectId` (bare string), `category` (lowercase kebab-case; suggestions\n in `SUGGESTED_EXPENSE_CATEGORIES`), `amount` integer minor units + ISO 4217\n `currency`, `incurredOn` (`YYYY-MM-DD` text: a calendar date, not an\n instant), `recordedAt`, `recordedByProfileId`, `paidBy` (`company` |\n `person` | `third-party`) + `paidByProfileId` / `paidByName`, `vendorId`, `commitmentId` /\n `commitmentLineId`, review fields, `reimbursable` / `reimbursedAt` /\n `reimbursementReference`.\n- **ExpenseReceipt** (`expense_receipts`): noun-specific asset join\n (`expenseId` → Expense, `assetId` → smrt-assets Asset) with uploader,\n `filename`, `mimeType`, `byteCount`, `contentSha256`. Collection extends\n `SmrtJunction` (left expense, right asset, ordered by `created_at`).\n\n## Recipe\n\n`src/recipes.ts` declares one `SmrtRecipe`, `expenses.tracking` (label\n\"Expenses\", section `accounting`, shared with `ledgers.bookkeeping`), emitted\ninto the built `manifest.json` and `smrt-knowledge.json` `recipes`. It brings\n`Expense` with `ExpenseReceipt` as a child model, hides the cost-object type,\ncommitment and reviewer plumbing, and leaves the read-only review fields as\nthey are (review is method-only; see below). Help lives in\n`src/tracking.recipe.md` (#3591). See [core recipes](../core/agents/recipes.md).\n\n## Invariants\n\n- **Review is method-only.** `review()`, `reject()`, `markDuplicate()`,\n `reopen()` read the persisted status, check the move, and authorize exactly\n one save through a module `WeakMap`. Every transition assigns every review\n field, and the token carries that complete resulting state, so a field set\n directly beside `review()`/`reject()` (e.g. an injected `duplicateOfId`)\n is refused. A `reviewed` expense never carries `duplicateOfId`. `save()` compares every review field\n (status, reviewer, time, note, `duplicateOfId`) with the stored row and\n refuses an unauthorized change (`EXPENSE_REVIEW_FIELDS_LOCKED`), including\n on a brand-new row. Review fields are also `readonly` for generated writes.\n- **Writes are pinned to the guarded row.** The guards read the row stored\n under `id`, but core's natural-key save adopts any same-owner row on\n `(tenant_id, slug, context)` and upserts it with no revision predicate;\n these models' slugs come from the id. So `pinNaturalKey()` lets a fresh\n (not `isPersisted`) instance only INSERT (`requireInsertOnSave()`): naming\n an existing row's id is `EXPENSE_IDENTITY_CONFLICT`, and a row appearing\n mid-save fails the INSERT instead of being adopted. An existing row changes\n only through a loaded instance (revision compare-and-swap) that keeps its\n slug/context. Never `create({ id: <existing> })` or `getOrUpsert()` these\n models; load and save instead.\n- **Reviewed money is frozen.** While the stored status is `reviewed`,\n `LOCKED_WHEN_REVIEWED` fields (money, currency, both dates, cost object,\n vendor, commitment, payer) cannot change; `reopen()` first. `recordedAt` is\n compared as an instant, since drivers return it as a `Date` or a string.\n- **Drawdown counts once.** `ExpenseCollection.commitmentPosition()` sums only\n `reviewed` rows with no `duplicateOfId`, in the commitment's currency, deduped\n by id. Committed is the contract's `totalAmount` (or the line's `amount`).\n- **Currencies never mix.** A commitment match must share the currency and\n tenant (`EXPENSE_COMMITMENT_MISMATCH`), checked when the match, line or\n currency is set or changed — not on every save, so a later commitment edit\n never blocks e.g. `markReimbursed()`. `totalsByCurrency()` keys by currency.\n Rows left in another currency by a later commitment change land in\n `otherCurrencies`, never in `drawn`.\n- **Duplicate receipts.** Same `(expense, sha256)` is refused by a pre-insert\n read plus the unique index `expense_receipts_expense_sha256_key`\n `(expense_id, content_sha256)`; a unique violation is rethrown as\n `DuplicateReceiptError`, classified from the error itself (violated key\n name, or `details.fieldName` columns on core's typed error) because on\n PostgreSQL the violation aborts a caller's transaction and nothing can be\n re-read; only an error naming no key falls back to a re-read. The index omits `tenant_id` deliberately: the\n expense already fixes the tenant, and a nullable leading `tenant_id` would\n leave NULL-tenant (global) receipts unenforced. Same hash on different\n expenses is allowed; `findDuplicateReceipts()` reports it (facet on\n `contentSha256`, count > 1). Receipt `tenantId` (derived from the\n expense on insert), `expenseId`, `assetId` and `contentSha256` are\n immutable. The inherited `SmrtJunction.attach()` and\n `setLinks()` are overridden to throw (`setLinks()` would delete every\n receipt, then fail to re-attach without hashes); `attachReceipt()` adds and\n `detach()` removes a link, keeping the asset. Because `ExpenseReceipt`\n overrides `save()`, `hasBaseJunctionLifecycle()` is false and no batch\n junction path can write a hashless row around it.\n- **Vendor ownership.** `vendorId` is a `crossPackageRef` (no FK, no\n validation), so `assertVendor()` checks the vendor is visible in and owned\n by the expense's tenant whenever it is set or changed\n (`EXPENSE_VENDOR_MISMATCH`).\n- **Tenant never moves.** Expense and receipt tenants are fixed once\n stored, compared as `effectiveTenant()` (the field, or the active context\n the `beforeSave` interceptor would fill an unset one from; unset is falsy,\n so `''` counts). `normalizeTenantId()` stores `''` as NULL (global), never\n as a pseudo-tenant, matching core/tenancy's falsy \"unset\". Otherwise a\n global (NULL-tenant) row saved inside `withTenant()` would be silently\n populated and split from its global expense or receipts.\n- **Tenancy.** Optional (`@TenantScoped({ mode: 'optional' })`, nullable\n `tenantId`), like smrt-commerce. A receipt takes its expense's tenant; its\n expense and asset must be visible in that tenant. A commitment must belong\n to the expense's tenant; before `super.save()` that is\n `tenantId ?? getTenantId()`, because the interceptor has not populated it.\n Consumers make it required with a qualified\n `registerTenantScopedClass(…, { mode: 'required' })`.\n- **Closed generated surface.** Models AND collections declare\n `mcp: { include: [] }`, `cli: false` and no generated CRUD or reads\n (generated reads check no read permission, and cost visibility is\n permission-gated). Collection classes are registered too: an undecorated\n collection publishes its CRUD and public methods as MCP tools. Core has no\n consumer-side switch to reopen a package model's surface.\n- **Generated write actions (#4001).** `ExpenseCollection` hosts the only\n generated writes, on REST: `record`, `attachReceipt`, `recordReview`\n (`decision: 'approve' | 'reject'`, reject needs `reason`) and\n `recordReimbursement`. `Expense` and `ExpenseCollection` both list them in\n `api.include` (runtime REST gates on the item, SvelteKit on the collection).\n Their names differ from `Expense.review()` / `markReimbursed()` on purpose:\n allowlisting a collection action must never open an instance method that\n takes the actor from its input. `src/actions/` delegates to the guarded\n model flow; identity comes from the tenancy/session ALS, never the body;\n each authorizes `expenses.<action>` via `assertOperationPermission` (system\n context never bypasses; review and reimbursement refuse super-admin bypass).\n Acting on someone else's expense needs `recordReview`. There is no submit\n state: a recorded expense is `unreviewed`, i.e. awaiting review.\n- **Cost object is not dereferenced on save.** `getCostObject()` resolves it\n through the registry and returns `null` for a foreign-tenant row. One cost\n object per expense (columns, not a `SmrtPolymorphicAssociation` join) so a\n cost can never roll up into two jobs.\n\n## Optional financial facts\n\n`financialBreakdown` is guarded JSON text (`get/setFinancialBreakdown`), absent\nfor legacy rows. Exact `getCostBasis()` returns null without facts. Complete facts\nreconcile subtotal + signed tax components to amount/currency; recoverable amounts\nare explicit. No tax law or customer pricing is inferred. `third-party` payer\nrequires `paidByName` and is not reimbursable. Negative amounts require `refundOfId`\n(same-owner/currency positive source, checked through the bound executor).\nSource edits also check inbound same-owner refunds: a referenced source stays\npositive, non-refund, and in their currency. `save()` encloses guards and write\nin the public `withTransaction()` boundary; PostgreSQL locks its row and proposed\nsource in id order before either refund check, while SQLite serializes writes.\nThe inbound projected lookup uses the existing `refund_of_id` index and an\nexplicit owner filter. Keep caller transaction affinity and per-row revision\nchecks; neither an unlocked lookup nor revision CAS alone protects this pair.\n\n`ExpenseCollection.listReviewedTaxFacts({ tenantId, from, to, currency? })`\nis the reporting input for supplier facts: it returns only that tenant's reviewed,\nnon-duplicate expenses in the inclusive calendar-date range. A null\n`financialBreakdown` remains an unknown legacy fact, never zero tax.\nEach fact carries `expenseId` as the commerce reporting identity; `id` remains\nthe compatible owner alias.\nAll three fields participate in the reviewed-money freeze. See the README and\n[financial facts test matrix](agents/financial-facts-test-matrix.md).\n\nLinking a new refund (or changing its source) also claims the source revision in\nthat same transaction through `claimRevision(source.updated_at)`. This makes a\nsource edit under an older PostgreSQL `REPEATABLE READ` snapshot fail with a\nserialization error instead of missing the new refund. Reload a source after\nlinking a refund before editing/reviewing it; on a serialization failure, retry\nthe entire caller transaction with fresh instances. Failed outer transactions\nroll back both the refund and the source revision claim. Existing-link saves do\nnot advance the source revision.\n\n## Cross-package references\n\n- `vendorId` → `@crossPackageRef('@happyvertical/smrt-commerce:Vendor')`\n- `commitmentId` → `…smrt-commerce:PurchaseContract` (`PurchaseOrder` or `ProductionOrder`)\n- `commitmentLineId` → `…smrt-commerce:ContractLineItem`\n- `assetId` → `…smrt-assets:Asset`\n- profile ids → `…smrt-profiles:Profile` (no dependency; references only)\n- `expenseId`, `duplicateOfId` → `@foreignKey` within the package\n\n## Scope\n\nAllowance (a planned amount) is not an expense kind; it belongs with\nbudget/commitment records. Labour belongs to `@happyvertical/smrt-timesheets`\n(#3288). No OCR, no accounting-provider posting.\n\n## Validation\n\n```bash\npnpm --filter @happyvertical/smrt-expenses test\npnpm --filter @happyvertical/smrt-expenses typecheck\npnpm --filter @happyvertical/smrt-expenses test:postgres\n```\n\n`src/__tests__/helpers/expense-suite.ts` is the shared behaviour suite: the\nSQLite file and the PostgreSQL lane (`*.optional.test.ts`, schema from\n`migrateSmrtSchemas` plus live parity) both run it. Use UUID tenant and\nprofile ids in tests: PostgreSQL stores them as `uuid`.\n\n## UI\n\n`src/svelte/` owns native expense entry and review presentation. See\n[the UI contract](agents/expense-ui.md) for DTOs, native payloads and recovery.\nDraft amounts are lossless currency-unit text; model/review amounts remain integer\nminor units. Receipt upload is composed from Assets, never reimplemented here.\n`test` includes backend and UI SSR; `test:e2e` proves native browser submissions.\n",
|
|
860
980
|
"moduleDocs": [
|
|
861
981
|
{
|
|
862
982
|
"path": "agents/financial-facts-test-matrix.md",
|
|
@@ -869,21 +989,168 @@
|
|
|
869
989
|
"content": "# Expense entry and receipt review\n\nImport `ExpenseForm`, `ExpenseReviewPanel` and their named Props types from\n`@happyvertical/smrt-expenses/svelte`. Registry slots are `expense-form` and\n`expense-review`. `@happyvertical/smrt-expenses/ui` exposes metadata without\nSvelte; `./playground` exposes an interactive retained-draft/review example.\n\n`ExpenseDraftValues` is a complete set of native strings: `amount`, `currency`,\n`incurredOn`, `description`, `category`, `vendorId`, `commitmentId`, `paidBy`,\n`paidByProfileId`, and `correctionReason`. Initialize unused fields to `''`.\nAmount is ordinary currency text (`'125.00'` CAD), distinct from the public\nExpense model's integer minor-unit `amount` (`12500`). Server adapters parse\ncurrency precision, safe integer range and business rules. Calendar dates stay\nlossless text, normally `YYYY-MM-DD`; invalid amounts/dates never disappear due\nto browser number/date coercion. Every validation failure returns original\nstrings, not reconstructed saved-model values.\n\n```svelte\n<script lang=\"ts\">\n import { ExpenseForm, ExpenseReviewPanel } from '@happyvertical/smrt-expenses/svelte';\n let { data, form } = $props();\n</script>\n<ExpenseForm\n action=\"?/save\"\n title=\"Record purchase\"\n values={form?.values ?? data.values}\n errors={form?.errors}\n message={form?.message}\n canEdit={data.mayEdit}\n correcting={data.correcting}\n vendors={data.authorizedVendors}\n commitments={data.authorizedCommitments}\n hiddenFields={[\n { name: 'requestId', value: form?.requestId ?? data.requestId },\n { name: 'costObjectId', value: data.costObjectId },\n { name: 'expectedTenantId', value: data.tenantId },\n ]}\n/>\n<ExpenseReviewPanel\n expense={data.expenseSummary}\n duplicates={data.authorizedDuplicateCandidates}\n history={data.history}\n receipts={data.receiptPanel}\n/>\n```\n\nVendor and commitment options are authorized Commerce `{id, label}` projections.\nResolve Profile names and application-specific award lineage in the consumer.\nUnknown retained references remain selectable/postable; selecting a reference\nnever establishes tenant ownership or commitment eligibility. `fields` maps\nlogical keys to native names; `errors` always uses logical keys. Submitter\n`intentField`/`intent` default to `intent=save`. `hiddenFields` preserves exact\nvalues and repeated names. Avoid field-name collisions. `children` adds\napplication-owned package allocation, quantity, reimbursement and correction\npolicy fields. `correcting` shows a retained reason, but does not implement a\nnew Expense correction model. The caller applies its own correction/history\npolicy and invokes existing guarded methods where appropriate.\n\nThe base form performs native POST by default, with or without JavaScript.\n`onsubmit` allows optional enhancement. `pending` or a supplied\n`smrt-ui/form-retry` `retryStatus` controls progress presentation without clearing\nvalues or creating/rotating request keys. `canEdit=false` disables fields and\nhides save; server-side authorization and replay enforcement remain mandatory.\n\nCurrency uses the shared `CurrencySelect`, localized by the active smrt-ui i18n\ncontext. It preserves the exact retained draft string and the field name from\n`fields.currency`, including native no-JavaScript correction flows. Pass\n`currencyOptions` only when the caller must restrict the shared currency catalog.\n\n`ExpenseReviewSummary` projects public Expense description, integer minor-unit\namount, currency and incurredOn, with caller-localized `statusLabel` and `note`.\n`duplicates` and `history` are explicit authorized presentation DTOs, not a\nparallel ledger. The server supplies findings from ExpenseReceipt duplicate\nqueries and application history. Unknown labels render verbatim. No state or\naction is inferred: `canReview`, `action`, `actions` and hidden fingerprints\nexplicitly provide a native review form. `action=\"\"` posts to the current page;\nomitting `action` hides the review form; `children` can supply retained reason\nor duplicate-selection inputs. A server must reauthorize, reload the model and\ninvoke `review()`, `reject()`, `markDuplicate()` or `reopen()` under its policy.\nNever assign review fields through UI payload binding. Reviewed money remains\nfrozen by the existing model; UI props cannot bypass it.\n\n`receipts` is exactly the public Assets `AttachmentPanelProps`, supporting\nprivate view/download URLs, version notes, native upload and recovery. It is\nrendered outside the review form so upload and review remain separate native\nactions. Use authorized receipt projections; attaching a file does not review\nan expense, mark a duplicate, or change invoice approval. Assets owns file\nselection and reselect-file help; Expenses does not duplicate uploads/storage.\n\nValidation: `pnpm --filter @happyvertical/smrt-expenses test`, `test:ui`,\n`test:e2e`, `typecheck`, `build`, and `verify:pack`. PostgreSQL model validation\nremains in the existing opt-in `test:postgres` lane; this UI does not alter it.\n\n`visibleFields` optionally limits the logical `ExpenseDraftValues` fields rendered by `ExpenseForm`. Omitted renders all existing fields; an empty array renders none. Excluded fields do not emit hidden fallbacks, including `correctionReason`; fixed values can be supplied explicitly through `hiddenFields` without duplicate names. This supports policy-fixed payer fields and domain-owned reference selectors. Visibility is presentation, never authority: endpoints must validate every fixed or submitted value. Unknown retained values and validation errors remain visible for included fields.\n\nComposition validation: native Svelte SSR contracts cover defaults, omitted fields, retained malformed values, caller identity, no duplicate fixed inputs, correction/read-only/pending behavior. No persistence, transaction, tenant authority or external provider changes occur; those matrix dimensions are N/A for this presentation-only extension. Node26/Svelte5 native POST markup is the supported runtime edge.\n\n## Cost-object lifecycle (#3462)\n\nThe existing #3423/#3429 entry and review controls are extended with\n`ExpenseList`, `ExpenseReceiptCapture` and `ExpenseReviewQueue` in the same\n`./svelte` export. Their registry slots are `expense-list`,\n`expense-receipt-capture` and `expense-review-queue`; named Props and row types\nare public. The playground composes these with the existing controls.\n\n`ExpenseForm` defaults to an optional touch keypad beside amount entry. It is\navailable after hydration, with ordinary text/keyboard entry and native POST\navailable without JavaScript. `amountKeypad=false` hides it. Digits, decimal and\nbackspace edit the exact currency-unit string at its selection; no parsing or\nrounding occurs. The server validates currency precision and safe minor units.\n`costObject={{ type, id }}` enables two editable native reference inputs, with\n`costObjectFields` name adapters and `costObjectErrors` retained server errors.\nOmit it when references are fixed in `hiddenFields` (the existing consumer\ncontract). Do not supply the same reference both ways. Qualified type and bare\nid are caller strings and must be validated/authorized by the endpoint.\n\n`ExpenseList` takes caller-authorized `ExpenseListItem[]` plus exact\n`costObjectType`/`costObjectId`; both keys must match. It displays each amount\nin its own currency, never sums different currencies, and retains unknown\n`statusLabel`, optional `note`, authorized `href`, empty and load-error states.\nFiltering is presentation only: do not pass unauthorized rows to the browser.\n\n`ExpenseReceiptCapture` is a separate multipart POST action, with\n`canAttach=false` by default. `fileField` defaults to `receipt`, submitter to\n`intent=attach-receipt`. It forwards `hiddenFields` exactly, including the\nexpense and caller-owned request tokens. A native file picker works without\nJavaScript; “Use camera” mounts the existing smrt-ui `CameraCapture` only after\nan explicit click. Capture/retake/commit and stream cleanup are owned by that\nshared component. Toggle back to file selection to recover from camera denial\nor unsupported APIs. Switching modes discards the previous file; selection is\nnever uploaded automatically. Pending prevents another upload or mode change;\na committed camera photo remains held for retry. Navigation cannot restore\nfile inputs: select the file again after a failed upload or page reload. The\nserver authenticates, validates file content/limits and expense visibility,\nstores the Asset and uses `ExpenseReceiptCollection.attachReceipt()` with its\nduplicate guard; this component performs no storage, deduplication or model\nmutation. Do not place this component inside another form.\n\n`ExpenseReviewQueue` takes authorized `ExpenseReviewQueueItem[]` in caller order.\nDecisions require both queue `canReview=true`, row `canReview=true`, and a row\n`action` (empty string explicitly means this page). Each row posts its own\n`expenseId`, retained `reason`, hidden request context and clicked\n`intent=approve|reject`. Field and intent names are configurable. Rejection\nrequires a reason in native browser validation; approval bypasses that browser\nrequirement. The server must enforce the reason and reauthorize/reload before\ncalling `review()` or `reject()`; these UI gates provide no authority. Return\nrow `reason`, `message` and original request tokens after rejection or an\nuncertain outcome. Pending disables decisions. No action is inferred from\nstatus, no submitted row is removed optimistically, and no request key rotates.\nAvoid collisions between hidden field names and component identity/reason/intent\nfields. Empty, read-only, unknown status and load-error states remain visible.\n\nLifecycle evidence is in the maintained `test:ui` SSR contracts and `test:e2e`\nChromium proofs: 390px keypad, exact cost-object payload, native multipart file\nselection with and without JavaScript, camera denial recovery, and native queue\napprove/reject with retained reason and row request identity. Persistence,\ntransaction affinity and tenant authorization remain existing server contracts;\nno database/model surface is changed by these presentation components.\n"
|
|
870
990
|
}
|
|
871
991
|
],
|
|
992
|
+
"recipes": [
|
|
993
|
+
{
|
|
994
|
+
"id": "expenses.tracking",
|
|
995
|
+
"className": "ExpensesRecipe",
|
|
996
|
+
"label": "Expenses",
|
|
997
|
+
"summary": "Record what the business spends, with receipts, and review it.",
|
|
998
|
+
"synonyms": [
|
|
999
|
+
"costs",
|
|
1000
|
+
"receipts",
|
|
1001
|
+
"spending",
|
|
1002
|
+
"reimbursements"
|
|
1003
|
+
],
|
|
1004
|
+
"models": [
|
|
1005
|
+
"@happyvertical/smrt-expenses:Expense",
|
|
1006
|
+
"@happyvertical/smrt-expenses:ExpenseReceipt"
|
|
1007
|
+
],
|
|
1008
|
+
"nav": [
|
|
1009
|
+
{
|
|
1010
|
+
"label": "Expenses",
|
|
1011
|
+
"model": "@happyvertical/smrt-expenses:Expense",
|
|
1012
|
+
"icon": "receipt",
|
|
1013
|
+
"description": "Costs you have paid, with the receipt and who paid."
|
|
1014
|
+
}
|
|
1015
|
+
],
|
|
1016
|
+
"requires": [],
|
|
1017
|
+
"section": {
|
|
1018
|
+
"id": "accounting",
|
|
1019
|
+
"label": "Accounting",
|
|
1020
|
+
"icon": "bank",
|
|
1021
|
+
"description": "Your books: accounts, journals and balances."
|
|
1022
|
+
},
|
|
1023
|
+
"options": {
|
|
1024
|
+
"@happyvertical/smrt-expenses:Expense": {
|
|
1025
|
+
"fields": {
|
|
1026
|
+
"description": {
|
|
1027
|
+
"order": 1,
|
|
1028
|
+
"required": true
|
|
1029
|
+
},
|
|
1030
|
+
"amount": {
|
|
1031
|
+
"order": 2
|
|
1032
|
+
},
|
|
1033
|
+
"currency": {
|
|
1034
|
+
"order": 3
|
|
1035
|
+
},
|
|
1036
|
+
"incurredOn": {
|
|
1037
|
+
"label": "Date",
|
|
1038
|
+
"order": 4,
|
|
1039
|
+
"help": "The date on the receipt, as YYYY-MM-DD."
|
|
1040
|
+
},
|
|
1041
|
+
"category": {
|
|
1042
|
+
"order": 5,
|
|
1043
|
+
"help": "One word, such as material, travel, freight or other."
|
|
1044
|
+
},
|
|
1045
|
+
"vendorId": {
|
|
1046
|
+
"label": "Vendor",
|
|
1047
|
+
"order": 6
|
|
1048
|
+
},
|
|
1049
|
+
"paidBy": {
|
|
1050
|
+
"order": 7
|
|
1051
|
+
},
|
|
1052
|
+
"paidByProfileId": {
|
|
1053
|
+
"label": "Person who paid",
|
|
1054
|
+
"order": 8,
|
|
1055
|
+
"help": "Who paid out of their own pocket, when a person paid."
|
|
1056
|
+
},
|
|
1057
|
+
"paidByName": {
|
|
1058
|
+
"order": 9
|
|
1059
|
+
},
|
|
1060
|
+
"reimbursable": {
|
|
1061
|
+
"order": 10
|
|
1062
|
+
},
|
|
1063
|
+
"costObjectId": {
|
|
1064
|
+
"label": "Charged to",
|
|
1065
|
+
"order": 11,
|
|
1066
|
+
"help": "The job, project or client the cost belongs to."
|
|
1067
|
+
},
|
|
1068
|
+
"costObjectType": {
|
|
1069
|
+
"visibility": "hidden"
|
|
1070
|
+
},
|
|
1071
|
+
"financialBreakdown": {
|
|
1072
|
+
"visibility": "hidden"
|
|
1073
|
+
},
|
|
1074
|
+
"refundOfId": {
|
|
1075
|
+
"visibility": "hidden"
|
|
1076
|
+
},
|
|
1077
|
+
"recordedByProfileId": {
|
|
1078
|
+
"visibility": "hidden"
|
|
1079
|
+
},
|
|
1080
|
+
"commitmentId": {
|
|
1081
|
+
"visibility": "hidden"
|
|
1082
|
+
},
|
|
1083
|
+
"commitmentLineId": {
|
|
1084
|
+
"visibility": "hidden"
|
|
1085
|
+
},
|
|
1086
|
+
"reviewedByProfileId": {
|
|
1087
|
+
"visibility": "hidden"
|
|
1088
|
+
},
|
|
1089
|
+
"duplicateOfId": {
|
|
1090
|
+
"visibility": "hidden"
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
"@happyvertical/smrt-expenses:ExpenseReceipt": {
|
|
1095
|
+
"fields": {
|
|
1096
|
+
"contentSha256": {
|
|
1097
|
+
"visibility": "hidden"
|
|
1098
|
+
},
|
|
1099
|
+
"byteCount": {
|
|
1100
|
+
"visibility": "hidden"
|
|
1101
|
+
},
|
|
1102
|
+
"uploadedByProfileId": {
|
|
1103
|
+
"visibility": "hidden"
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
"help": {
|
|
1109
|
+
"markdown": "## Overview\n\nExpenses are the costs the business pays: a receipt, who paid it and what it\nwas for. Each expense is reviewed once, and a reimbursable one is marked when\nthe person is paid back.\n\n## Tasks\n\n### Record an expense\n\n1. Create a new expense and write a short **{field:description}**.\n2. Enter the **{field:amount}** and its **{field:currency}**.\n3. Set the **{field:incurredOn}** date from the receipt and pick a\n **{field:category}**.\n4. Choose who **{field:paidBy}**: the company, a person or a third party.\n5. When a person paid, pick the **{field:paidByProfileId}** and tick\n **{field:reimbursable}** when they are owed the money back.\n\n### Review it\n\n1. Open the expense and check the receipt attached to it.\n2. **{field:reviewStatus}** moves from unreviewed to reviewed, or to rejected,\n when someone reviews it. A rejected expense can be reopened.\n",
|
|
1110
|
+
"fieldRefs": [
|
|
1111
|
+
"amount",
|
|
1112
|
+
"category",
|
|
1113
|
+
"currency",
|
|
1114
|
+
"description",
|
|
1115
|
+
"incurredOn",
|
|
1116
|
+
"paidBy",
|
|
1117
|
+
"paidByProfileId",
|
|
1118
|
+
"reimbursable",
|
|
1119
|
+
"reviewStatus"
|
|
1120
|
+
]
|
|
1121
|
+
},
|
|
1122
|
+
"demo": {
|
|
1123
|
+
"mode": "server",
|
|
1124
|
+
"reasons": [
|
|
1125
|
+
"Needs a server because its package does not build for a browser: node:crypto; and its package depends on @happyvertical/smrt-agents, @happyvertical/smrt-commerce, @happyvertical/smrt-jobs, @happyvertical/smrt-ledgers, @happyvertical/smrt-products, @happyvertical/smrt-profiles, @happyvertical/smrt-reports, @happyvertical/smrt-secrets, @happyvertical/smrt-subscriptions, @happyvertical/smrt-tenancy, @happyvertical/smrt-users, which do not build for a browser (#3615, #3616, #3617, #3624, #3625, #3626, #3627)."
|
|
1126
|
+
]
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
],
|
|
872
1130
|
"browser": {
|
|
873
1131
|
"status": "server-only",
|
|
874
1132
|
"issues": [
|
|
875
1133
|
"#3615",
|
|
1134
|
+
"#3616",
|
|
1135
|
+
"#3617",
|
|
876
1136
|
"#3624",
|
|
1137
|
+
"#3625",
|
|
877
1138
|
"#3626",
|
|
878
1139
|
"#3627"
|
|
879
1140
|
],
|
|
880
1141
|
"reason": "node:crypto",
|
|
881
1142
|
"via": [
|
|
1143
|
+
"@happyvertical/smrt-agents",
|
|
882
1144
|
"@happyvertical/smrt-commerce",
|
|
883
1145
|
"@happyvertical/smrt-jobs",
|
|
884
1146
|
"@happyvertical/smrt-ledgers",
|
|
1147
|
+
"@happyvertical/smrt-products",
|
|
1148
|
+
"@happyvertical/smrt-profiles",
|
|
1149
|
+
"@happyvertical/smrt-reports",
|
|
1150
|
+
"@happyvertical/smrt-secrets",
|
|
885
1151
|
"@happyvertical/smrt-subscriptions",
|
|
886
|
-
"@happyvertical/smrt-tenancy"
|
|
1152
|
+
"@happyvertical/smrt-tenancy",
|
|
1153
|
+
"@happyvertical/smrt-users"
|
|
887
1154
|
]
|
|
888
1155
|
}
|
|
889
1156
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-expenses",
|
|
3
|
-
"version": "0.56.
|
|
3
|
+
"version": "0.56.1",
|
|
4
4
|
"description": "Expense records for the SMRT framework - reviewed costs against a cost object, deduplicated receipts, and commitment drawdown",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,15 +33,16 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@happyvertical/smrt-assets": "0.56.
|
|
37
|
-
"@happyvertical/smrt-commerce": "0.56.
|
|
38
|
-
"@happyvertical/smrt-core": "0.56.
|
|
39
|
-
"@happyvertical/smrt-tenancy": "0.56.
|
|
40
|
-
"@happyvertical/smrt-types": "0.56.
|
|
41
|
-
"@happyvertical/smrt-ui": "0.56.
|
|
36
|
+
"@happyvertical/smrt-assets": "0.56.1",
|
|
37
|
+
"@happyvertical/smrt-commerce": "0.56.1",
|
|
38
|
+
"@happyvertical/smrt-core": "0.56.1",
|
|
39
|
+
"@happyvertical/smrt-tenancy": "0.56.1",
|
|
40
|
+
"@happyvertical/smrt-types": "0.56.1",
|
|
41
|
+
"@happyvertical/smrt-ui": "0.56.1",
|
|
42
|
+
"@happyvertical/smrt-users": "0.56.1"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
|
-
"@happyvertical/smrt-vitest": "0.56.
|
|
45
|
+
"@happyvertical/smrt-vitest": "0.56.1",
|
|
45
46
|
"@types/node": "24.13.6",
|
|
46
47
|
"typescript": "6.0.3",
|
|
47
48
|
"vite": "8.1.4",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Expense-i8NeN2D3.js","names":[],"sources":["../../src/validation.ts","../../src/financial-facts.ts","../../src/models/Expense.ts"],"sourcesContent":["/**\n * Small, pure validators shared by the expense models.\n *\n * @packageDocumentation\n */\n\nimport { createHash } from 'node:crypto';\nimport { getTenantId } from '@happyvertical/smrt-tenancy';\nimport { ExpenseError, type ExpenseErrorCode } from './types.js';\n\n/** Lowercase kebab-case: `material`, `outside-service`. */\nexport const CATEGORY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\n\nconst ISO_CURRENCY = /^[A-Z]{3}$/;\nconst ISO_DATE = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\nconst QUALIFIED_CLASS = /^[^\\s:]+:[A-Za-z_$][\\w$]*$/;\nconst SHA256_HEX = /^[0-9a-f]{64}$/;\n\n/**\n * Throw unless `value` is a non-negative, JavaScript-safe integer number of\n * minor units. Money is exact: `$19.99` is `1999`, never `19.99`.\n */\nexport function assertMinorUnits(\n model: string,\n label: string,\n fieldName: string,\n value: number,\n code: ExpenseErrorCode = 'EXPENSE_INVALID',\n): void {\n if (!Number.isSafeInteger(value) || value < 0) {\n throw new ExpenseError(\n code,\n `${model} ${label}: ${fieldName} must be a non-negative integer ` +\n `number of minor units (cents) — got ${value}. $19.99 is 1999.`,\n );\n }\n}\n\n/** Throw unless `value` is an upper-case ISO 4217 code such as `USD`. */\nexport function assertIsoCurrency(\n model: string,\n label: string,\n value: string,\n): void {\n if (!ISO_CURRENCY.test(value)) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `${model} ${label}: currency must be a three-letter ISO 4217 code ` +\n `(for example 'USD'), got '${value}'.`,\n );\n }\n}\n\n/** Throw unless `value` is a real calendar date written `YYYY-MM-DD`. */\nexport function assertIsoDate(\n model: string,\n label: string,\n fieldName: string,\n value: string,\n): void {\n const match = ISO_DATE.exec(value);\n const valid =\n match !== null &&\n (() => {\n const [year, month, day] = match.slice(1).map(Number);\n const date = new Date(Date.UTC(year, month - 1, day));\n return (\n date.getUTCFullYear() === year &&\n date.getUTCMonth() === month - 1 &&\n date.getUTCDate() === day\n );\n })();\n if (!valid) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `${model} ${label}: ${fieldName} must be a calendar date written ` +\n `YYYY-MM-DD, got '${value}'.`,\n );\n }\n}\n\n/**\n * Throw unless `value` is a package-qualified class name such as\n * `@happyvertical/smrt-projects:Project`. A bare class name is ambiguous\n * (packages can share names) and is refused.\n */\nexport function assertQualifiedClassName(\n model: string,\n label: string,\n value: string,\n): void {\n if (!QUALIFIED_CLASS.test(value)) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `${model} ${label}: costObjectType must be a qualified class name ` +\n `such as '@happyvertical/smrt-projects:Project', got '${value}'.`,\n );\n }\n}\n\n/** Normalize and check a SHA-256 hex digest; returns it in lower case. */\nexport function normalizeSha256(model: string, label: string, value: string) {\n const digest = String(value ?? '')\n .trim()\n .toLowerCase();\n if (!SHA256_HEX.test(digest)) {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n `${model} ${label}: contentSha256 must be a 64-character hex SHA-256 ` +\n 'digest of the file bytes.',\n );\n }\n return digest;\n}\n\n/**\n * Lowercase hex SHA-256 of a file's bytes — the value\n * `ExpenseReceipt.contentSha256` expects.\n *\n * @example\n * ```typescript\n * const digest = computeContentSha256(await readFile('receipt.pdf'));\n * ```\n */\nexport function computeContentSha256(data: Uint8Array | string): string {\n return createHash('sha256').update(data).digest('hex');\n}\n\n/** Milliseconds since the epoch of a date-ish value; `null` when empty. */\nexport function instantMs(value: unknown): number | null {\n if (value === null || value === undefined || value === '') return null;\n const ms =\n value instanceof Date ? value.getTime() : new Date(String(value)).getTime();\n return Number.isNaN(ms) ? null : ms;\n}\n\n/** The slice of `SmrtObject` the natural-key identity guard needs. */\ninterface NaturalKeyed {\n id?: string | null;\n slug?: string | null;\n context?: string | null;\n readonly isPersisted: boolean;\n getSlug(): Promise<unknown>;\n requireInsertOnSave(): void;\n}\n\n/**\n * Make a save write only the row its guards checked.\n *\n * Core's natural-key save adopts any same-owner row on\n * `(tenant_id, slug, context)` and upserts it without a revision predicate,\n * and these models have no human slug. So:\n *\n * - a fresh (never loaded, never saved) instance may only INSERT\n * (`requireInsertOnSave`): naming an existing row's id is refused here, and\n * a row that appears between this check and the write makes the INSERT\n * fail rather than be adopted;\n * - an existing row is changed only through a loaded instance, whose save\n * carries core's revision compare-and-swap, and it must keep the slug and\n * context it is stored under, so the write targets that row and no other.\n *\n * @returns `true` when this save is an INSERT.\n * @throws {ExpenseError} `EXPENSE_IDENTITY_CONFLICT` when a fresh instance\n * names an existing row, or a loaded one changes its natural key.\n */\nexport async function pinNaturalKey(\n model: string,\n object: NaturalKeyed,\n persisted: Record<string, unknown> | null,\n): Promise<boolean> {\n await object.getSlug();\n if (!object.isPersisted) {\n if (persisted) {\n throw new ExpenseError(\n 'EXPENSE_IDENTITY_CONFLICT',\n `${model} ${object.id} already exists; load it and save the loaded ` +\n 'instance instead of creating one with its id.',\n );\n }\n object.requireInsertOnSave();\n return true;\n }\n if (\n persisted &&\n (String(persisted.slug ?? '') !== String(object.slug ?? '') ||\n String(persisted.context ?? '') !== String(object.context ?? ''))\n ) {\n throw new ExpenseError(\n 'EXPENSE_IDENTITY_CONFLICT',\n `${model} ${object.id}: slug and context cannot change; they would ` +\n 'point the save at a different row.',\n );\n }\n return false;\n}\n\n/** The error a refused natural-key INSERT is reported as. */\nexport function identityConflict(\n model: string,\n object: { id?: string | null; slug?: string | null },\n cause: unknown,\n): ExpenseError {\n return new ExpenseError(\n 'EXPENSE_IDENTITY_CONFLICT',\n `${model} ${object.id}: another row already uses slug ` +\n `'${object.slug}' in this tenant; a new ${model} cannot take it over.`,\n { cause },\n );\n}\n\n/**\n * The tenant a save will actually write: tenancy's `beforeSave` interceptor\n * fills an unset `tenantId` from the active tenant context, after the\n * model's own checks have run, and \"unset\" is falsy there — `''` included.\n * Compare against this, not the raw field.\n */\nexport function effectiveTenant(\n tenantId: string | null | undefined,\n): string | null {\n return tenantId || getTenantId() || null;\n}\n\n/**\n * An empty tenant id is unset, never a tenant of its own: core and tenancy\n * treat it as falsy (and real tenant ids are UUIDs), so it is stored as\n * NULL — a global row — rather than as a `''` pseudo-tenant.\n */\nexport function normalizeTenantId(\n tenantId: string | null | undefined,\n): string | null {\n return tenantId || null;\n}\n","/** Exact validation of optional supplier facts. No tax policy is inferred. */\nimport {\n type ExpenseCostBasis,\n ExpenseError,\n type ExpenseFinancialBreakdown,\n} from './types.js';\nimport { assertIsoCurrency } from './validation.js';\n\nfunction invalid(message: string): never {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense financial breakdown: ${message}`,\n );\n}\n\nfunction record(value: unknown, keys: string[]): Record<string, unknown> {\n if (!value || typeof value !== 'object' || Array.isArray(value)) {\n return invalid('expected an object.');\n }\n const object = value as Record<string, unknown>;\n if (\n Object.keys(object).length !== keys.length ||\n keys.some((key) => !Object.hasOwn(object, key))\n ) {\n return invalid('missing or unknown fields.');\n }\n return object;\n}\n\nfunction minor(value: unknown): bigint {\n if (typeof value !== 'number' || !Number.isSafeInteger(value)) {\n return invalid('amounts must be safe integer minor units.');\n }\n return BigInt(value);\n}\n\nfunction safe(value: bigint): number {\n const result = Number(value);\n if (!Number.isSafeInteger(result))\n return invalid('unsafe calculated amount.');\n return result;\n}\n\n/** Validate facts and derive an exact signed basis, without rounding or defaults. */\nexport function expenseCostBasis(\n value: unknown,\n amount: number,\n currency: string,\n): ExpenseCostBasis {\n const facts = record(value, ['currency', 'subtotal', 'taxes', 'total']);\n if (typeof facts.currency !== 'string' || facts.currency !== currency) {\n return invalid('currency must match the expense.');\n }\n assertIsoCurrency('Expense', '<financial-breakdown>', facts.currency);\n const total = minor(facts.total);\n const subtotal = minor(facts.subtotal);\n if (total !== minor(amount))\n return invalid('total must match the expense amount.');\n const consistent = (part: bigint) =>\n part === 0n || (total > 0n ? part > 0n : total < 0n && part < 0n);\n if (!consistent(subtotal)) return invalid('subtotal sign must match total.');\n if (!Array.isArray(facts.taxes))\n return invalid('taxes must be an explicit array.');\n let tax = 0n;\n let recoverable = 0n;\n const codes = new Set<string>();\n for (const item of facts.taxes) {\n const component = record(item, ['code', 'amount', 'recoverableAmount']);\n if (\n typeof component.code !== 'string' ||\n !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(component.code) ||\n component.code.length > 64 ||\n codes.has(component.code)\n ) {\n return invalid(\n 'tax codes must be unique lower-case kebab-case identifiers.',\n );\n }\n codes.add(component.code);\n const charged = minor(component.amount);\n const recovered = minor(component.recoverableAmount);\n const chargedMagnitude = charged < 0n ? -charged : charged;\n const recoveredMagnitude = recovered < 0n ? -recovered : recovered;\n if (\n !consistent(charged) ||\n !consistent(recovered) ||\n recoveredMagnitude > chargedMagnitude\n ) {\n return invalid(\n 'tax and recovery must share the total sign, with recovery bounded by tax.',\n );\n }\n tax += charged;\n recoverable += recovered;\n }\n if (subtotal + tax !== total)\n return invalid('subtotal plus taxes must equal total.');\n return {\n currency,\n subtotal: safe(subtotal),\n taxTotal: safe(tax),\n recoverableTax: safe(recoverable),\n nonRecoverableTax: safe(tax - recoverable),\n total: safe(total),\n costBasis: safe(total - recoverable),\n };\n}\n\n/** Decode stored facts, failing closed for corrupt or unknown persisted values. */\nexport function parseExpenseFinancialBreakdown(\n stored: string | null,\n amount: number,\n currency: string,\n): ExpenseFinancialBreakdown | null {\n if (stored === '' || stored === null || stored === undefined) return null;\n if (typeof stored !== 'string') return invalid('storage must be JSON text.');\n let parsed: unknown;\n try {\n parsed = JSON.parse(stored);\n } catch {\n return invalid('invalid JSON.');\n }\n expenseCostBasis(parsed, amount, currency);\n return parsed as ExpenseFinancialBreakdown;\n}\n","/**\n * Expense — a cost someone incurred, against a cost object, with a review\n * state and optional reimbursement and commitment links.\n *\n * @packageDocumentation\n */\n\nimport {\n ContractLineItemCollection,\n PurchaseContractCollection,\n VendorCollection,\n} from '@happyvertical/smrt-commerce';\nimport {\n crossPackageRef,\n field,\n foreignKey,\n isPostgresDatabase,\n isUniqueViolationError,\n ObjectRegistry,\n SmrtObject,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport {\n expenseCostBasis,\n parseExpenseFinancialBreakdown,\n} from '../financial-facts.js';\nimport {\n type ExpenseCostBasis,\n ExpenseError,\n type ExpenseFinancialBreakdown,\n type ExpenseOptions,\n type ExpensePayer,\n type ExpenseReviewStatus,\n type MarkDuplicateExpenseInput,\n type MarkReimbursedInput,\n type RejectExpenseInput,\n type ReopenExpenseInput,\n type ReviewExpenseInput,\n} from '../types.js';\nimport {\n assertIsoCurrency,\n assertIsoDate,\n assertMinorUnits,\n assertQualifiedClassName,\n CATEGORY_PATTERN,\n effectiveTenant,\n identityConflict,\n instantMs,\n normalizeTenantId,\n pinNaturalKey,\n} from '../validation.js';\n\nconst PROFILE = '@happyvertical/smrt-profiles:Profile';\n\n/**\n * Legal review moves, keyed by the persisted status. Only the dedicated\n * methods perform them; `save()` refuses a review-field change they did not\n * authorize.\n */\nconst REVIEW_TRANSITIONS: Record<\n 'review' | 'reject' | 'markDuplicate' | 'reopen',\n { from: readonly ExpenseReviewStatus[]; to: ExpenseReviewStatus }\n> = {\n review: { from: ['unreviewed'], to: 'reviewed' },\n reject: { from: ['unreviewed', 'reviewed'], to: 'rejected' },\n markDuplicate: { from: ['unreviewed', 'reviewed'], to: 'rejected' },\n reopen: { from: ['reviewed', 'rejected'], to: 'unreviewed' },\n};\n\n/**\n * Fields a reviewed expense freezes. Changing one needs `reopen()` first, so\n * a reviewed amount can never move without a fresh review.\n */\nconst LOCKED_WHEN_REVIEWED = [\n ['amount', 'amount'],\n ['financialBreakdown', 'financial_breakdown'],\n ['paidByName', 'paid_by_name'],\n ['refundOfId', 'refund_of_id'],\n ['currency', 'currency'],\n ['incurredOn', 'incurred_on'],\n ['recordedAt', 'recorded_at'],\n ['category', 'category'],\n ['costObjectType', 'cost_object_type'],\n ['costObjectId', 'cost_object_id'],\n ['vendorId', 'vendor_id'],\n ['commitmentId', 'commitment_id'],\n ['commitmentLineId', 'commitment_line_id'],\n ['paidBy', 'paid_by'],\n ['paidByProfileId', 'paid_by_profile_id'],\n] as const;\n\n/** The review transition a dedicated method has authorized for one save. */\nconst authorizedTransition = new WeakMap<\n Expense,\n { from: ExpenseReviewStatus; expected: ReviewFieldState }\n>();\n\n/** Every review field, normalized for comparison. */\ninterface ReviewFieldState {\n status: ExpenseReviewStatus;\n reviewer: string | null;\n reviewedAtMs: number | null;\n note: string;\n duplicateOf: string | null;\n}\n\nfunction sameReviewState(a: ReviewFieldState, b: ReviewFieldState): boolean {\n return (\n a.status === b.status &&\n a.reviewer === b.reviewer &&\n a.reviewedAtMs === b.reviewedAtMs &&\n a.note === b.note &&\n a.duplicateOf === b.duplicateOf\n );\n}\n\ntype PersistedRow = Record<string, unknown>;\n\nfunction asNullableString(value: unknown): string | null {\n return value === null || value === undefined || value === ''\n ? null\n : String(value);\n}\n\n/**\n * A cost someone incurred.\n *\n * - **Cost object** — polymorphic `(costObjectType, costObjectId)`: a job, a\n * work package, a project, any registered SMRT object. Exactly one per\n * expense, so a cost is never rolled up into two jobs.\n * - **Money** — `amount` in integer minor units with an ISO 4217 `currency`.\n * - **Dates** — `incurredOn` (calendar date, `YYYY-MM-DD`) is when the cost\n * happened; `recordedAt` is when it was entered.\n * - **Review** — `unreviewed → reviewed | rejected`, only through\n * {@link Expense.review}, {@link Expense.reject},\n * {@link Expense.markDuplicate} and {@link Expense.reopen}. A plain field\n * write to the review state is refused on save.\n * - **Reimbursement** — recording only: whether a person who paid is owed\n * back and when they were paid. Never changes review state.\n * - **Commitment** — optional commerce `Contract` (usually a `PurchaseOrder`)\n * or one of its lines that this actual draws down.\n *\n * The generated REST/MCP/CLI surface is closed (`api: { include: [] }`):\n * cost visibility is permission-gated by every consumer, so they expose\n * expenses through their own routes and call the domain methods here.\n */\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'expenses',\n api: { include: [] },\n mcp: { include: [] },\n cli: false,\n indexes: [\n {\n name: 'expenses_cost_object_idx',\n columns: ['tenantId', 'costObjectType', 'costObjectId', 'incurredOn'],\n },\n {\n name: 'expenses_commitment_review_idx',\n columns: ['tenantId', 'commitmentId', 'reviewStatus'],\n },\n {\n name: 'expenses_review_status_idx',\n columns: ['tenantId', 'reviewStatus', 'incurredOn'],\n },\n ],\n})\nexport class Expense extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-expenses\";\n /** Owning tenant. Nullable: optional tenancy, like every commerce model. */\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n /**\n * Qualified class of the cost object, e.g.\n * `@happyvertical/smrt-projects:Project`. Empty only for an unallocated\n * expense (then `costObjectId` is empty too).\n */\n @field({ type: 'text' })\n costObjectType: string = '';\n\n /** Id of the cost object. Bare string: the target table is polymorphic. */\n @field({ type: 'text' })\n costObjectId: string = '';\n\n /**\n * Lowercase kebab-case category. See `SUGGESTED_EXPENSE_CATEGORIES`;\n * any value matching the pattern is accepted.\n */\n @field({ type: 'text' })\n category: string = 'other';\n\n /** What was bought or paid for. */\n @field({ type: 'text' })\n description: string = '';\n\n /** Integer minor units; negative only for a refund linked by refundOfId. */\n @field({ type: 'integer' })\n amount: number = 0;\n\n /** ISO 4217 currency code, upper case. Required. */\n @field({ type: 'text' })\n currency: string = '';\n\n /** Optional supplier facts as guarded JSON text; null or empty means unknown. */\n @field({ type: 'text' })\n financialBreakdown: string | null = null;\n\n /** Name of the third party who paid; no employee reimbursement is implied. */\n @field({ type: 'text' })\n paidByName: string | null = null;\n\n /** Positive source expense reversed by this negative refund; no settlement. */\n @foreignKey('Expense', { onDelete: 'RESTRICT' })\n refundOfId: string | null = null;\n\n /** Calendar date the cost was incurred, `YYYY-MM-DD`. Required. */\n @field({ type: 'text' })\n incurredOn: string = '';\n\n /** When the expense was recorded; set on first save when empty. */\n recordedAt: Date | null = null;\n\n /** Profile that recorded the expense. */\n @crossPackageRef(PROFILE)\n recordedByProfileId: string | null = null;\n\n /** Company, named person (profile), or named third party (paidByName). */\n @field({ type: 'text' })\n paidBy: ExpensePayer = 'company';\n\n /** Profile that paid out of pocket; required when `paidBy` is `person`. */\n @crossPackageRef(PROFILE)\n paidByProfileId: string | null = null;\n\n /** Optional commerce vendor the cost was paid to. */\n @crossPackageRef('@happyvertical/smrt-commerce:Vendor')\n vendorId: string | null = null;\n\n /**\n * Optional purchase commitment this actual draws down (normally a\n * `PurchaseOrder`). Must be in the same currency and tenant.\n */\n @crossPackageRef('@happyvertical/smrt-commerce:PurchaseContract', {\n validateSubtype: true,\n })\n commitmentId: string | null = null;\n\n /** Optional line of {@link Expense.commitmentId} this actual draws down. */\n @crossPackageRef('@happyvertical/smrt-commerce:ContractLineItem')\n commitmentLineId: string | null = null;\n\n /** Review state. Changed only by the review methods. */\n @field({ type: 'text', readonly: true })\n reviewStatus: ExpenseReviewStatus = 'unreviewed';\n\n /** Profile that made the current review decision. */\n @crossPackageRef(PROFILE, { readonly: true })\n reviewedByProfileId: string | null = null;\n\n /** When the current review decision was made. */\n @field({ type: 'datetime', readonly: true })\n reviewedAt: Date | null = null;\n\n /** Review note or rejection reason. */\n @field({ type: 'text', readonly: true })\n reviewNote: string = '';\n\n /**\n * Set when a reviewer marked this expense a duplicate of another one. A\n * duplicate is `rejected` and never counts toward any total.\n */\n @foreignKey('Expense', { readonly: true, onDelete: 'SET NULL' })\n duplicateOfId: string | null = null;\n\n /** Whether the person who paid is owed the amount back. */\n @field({ type: 'boolean' })\n reimbursable: boolean = false;\n\n /** When the person was paid back; set by {@link Expense.markReimbursed}. */\n reimbursedAt: Date | null = null;\n\n /** Payroll line, payables reference, or transfer id of the payback. */\n @field({ type: 'text' })\n reimbursementReference: string = '';\n\n constructor(options: ExpenseOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.costObjectType !== undefined)\n this.costObjectType = options.costObjectType;\n if (options.costObjectId !== undefined)\n this.costObjectId = options.costObjectId;\n if (options.category !== undefined) this.category = options.category;\n if (options.description !== undefined)\n this.description = options.description;\n if (options.amount !== undefined) this.amount = options.amount;\n if (options.currency !== undefined) this.currency = options.currency;\n if (options.financialBreakdown !== undefined)\n this.financialBreakdown = options.financialBreakdown;\n if (options.paidByName !== undefined) this.paidByName = options.paidByName;\n if (options.refundOfId !== undefined) this.refundOfId = options.refundOfId;\n if (options.incurredOn !== undefined) this.incurredOn = options.incurredOn;\n if (options.recordedAt !== undefined) this.recordedAt = options.recordedAt;\n if (options.recordedByProfileId !== undefined)\n this.recordedByProfileId = options.recordedByProfileId;\n if (options.paidBy !== undefined) this.paidBy = options.paidBy;\n if (options.paidByProfileId !== undefined)\n this.paidByProfileId = options.paidByProfileId;\n if (options.vendorId !== undefined) this.vendorId = options.vendorId;\n if (options.commitmentId !== undefined)\n this.commitmentId = options.commitmentId;\n if (options.commitmentLineId !== undefined)\n this.commitmentLineId = options.commitmentLineId;\n if (options.reimbursable !== undefined)\n this.reimbursable = options.reimbursable;\n if (options.reimbursedAt !== undefined)\n this.reimbursedAt = options.reimbursedAt;\n if (options.reimbursementReference !== undefined)\n this.reimbursementReference = options.reimbursementReference;\n }\n\n /** Read validated supplier facts, or null when none were recorded. */\n getFinancialBreakdown(): ExpenseFinancialBreakdown | null {\n return parseExpenseFinancialBreakdown(\n this.financialBreakdown,\n this.amount,\n this.currency,\n );\n }\n\n /** Set complete supplier facts; null explicitly clears facts on an unreviewed row. */\n setFinancialBreakdown(value: ExpenseFinancialBreakdown | null): void {\n if (value === null) {\n this.financialBreakdown = null;\n return;\n }\n expenseCostBasis(value, this.amount, this.currency);\n this.financialBreakdown = JSON.stringify(value);\n }\n\n /** Return exact retained purchase cost; legacy unknown tax facts return null. */\n getCostBasis(): ExpenseCostBasis | null {\n const facts = this.getFinancialBreakdown();\n return facts === null\n ? null\n : expenseCostBasis(facts, this.amount, this.currency);\n }\n\n /** True when the person who paid has been paid back. */\n isReimbursed(): boolean {\n return this.reimbursedAt !== null && this.reimbursedAt !== undefined;\n }\n\n /** True when this expense counts toward reviewed totals and drawdown. */\n isCountable(): boolean {\n return this.reviewStatus === 'reviewed' && !this.duplicateOfId;\n }\n\n /**\n * Accept the expense as a real cost: `unreviewed → reviewed`.\n *\n * This is the separately authorizable review action: gate it in your route\n * or service (for example on `expenses.review`). The expense must already\n * be saved and must not be marked a duplicate.\n *\n * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from any other state.\n */\n async review(input: ReviewExpenseInput): Promise<this> {\n const reviewer = requireReviewer(input?.reviewerProfileId);\n return this.transition('review', () => {\n this.reviewStatus = 'reviewed';\n this.reviewedByProfileId = reviewer;\n this.reviewedAt = input.at ?? new Date();\n this.reviewNote = input.note ?? '';\n this.duplicateOfId = null;\n });\n }\n\n /**\n * Refuse the expense: `unreviewed | reviewed → rejected`. A rejected\n * expense is kept and shown, and never counted.\n *\n * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from `rejected`, or\n * `EXPENSE_INVALID` without a reason.\n */\n async reject(input: RejectExpenseInput): Promise<this> {\n const reviewer = requireReviewer(input?.reviewerProfileId);\n const reason = (input.reason ?? '').trim();\n if (!reason) {\n throw new ExpenseError('EXPENSE_INVALID', 'A rejection needs a reason.');\n }\n return this.transition('reject', () => {\n this.reviewStatus = 'rejected';\n this.reviewedByProfileId = reviewer;\n this.reviewedAt = input.at ?? new Date();\n this.reviewNote = reason;\n this.duplicateOfId = null;\n });\n }\n\n /**\n * Mark this expense a duplicate of another expense in the same tenant:\n * `unreviewed | reviewed → rejected` with {@link Expense.duplicateOfId}\n * set. A duplicate never counts toward any total.\n *\n * @throws {ExpenseError} `EXPENSE_INVALID` when the target is this expense\n * or is not visible in this expense's tenant.\n */\n async markDuplicate(input: MarkDuplicateExpenseInput): Promise<this> {\n const reviewer = requireReviewer(input?.reviewerProfileId);\n const targetId = String(input.duplicateOfId ?? '').trim();\n if (!targetId || targetId === this.id) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'An expense can only be a duplicate of a different expense.',\n );\n }\n const { ExpenseCollection } = await import(\n '../collections/ExpenseCollection.js'\n );\n const expenses = await ExpenseCollection.create({ db: this.db });\n const target = await expenses.get({ id: targetId });\n if (!target || (target.tenantId ?? null) !== (this.tenantId ?? null)) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${targetId} was not found in this expense's tenant.`,\n );\n }\n return this.transition('markDuplicate', () => {\n this.reviewStatus = 'rejected';\n this.reviewedByProfileId = reviewer;\n this.reviewedAt = input.at ?? new Date();\n this.reviewNote = `Duplicate of expense ${targetId}`;\n this.duplicateOfId = targetId;\n });\n }\n\n /**\n * Send the expense back for review: `reviewed | rejected → unreviewed`.\n * Clears the reviewer, the review time and any duplicate marker.\n *\n * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from `unreviewed`.\n */\n async reopen(input: ReopenExpenseInput): Promise<this> {\n requireReviewer(input?.reviewerProfileId);\n return this.transition('reopen', () => {\n this.reviewStatus = 'unreviewed';\n this.reviewedByProfileId = null;\n this.reviewedAt = null;\n this.reviewNote = input.note ?? '';\n this.duplicateOfId = null;\n });\n }\n\n /**\n * Record that the person who paid has been paid back. Recording only:\n * paying happens in payroll or payables, and this never changes the review\n * state.\n *\n * @throws {ExpenseError} `EXPENSE_INVALID` unless the expense is\n * `reimbursable`.\n */\n async markReimbursed(input: MarkReimbursedInput = {}): Promise<this> {\n if (!this.reimbursable) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${this.id} is not owed back to anyone.`,\n );\n }\n this.reimbursedAt = input.at ?? new Date();\n if (input.reference !== undefined)\n this.reimbursementReference = input.reference;\n return this.save();\n }\n\n /**\n * Load the cost object this expense is charged to, through its registered\n * collection (so tenant interceptors apply). Returns `null` when unset,\n * when the type is not registered, when the row is gone, or when the row\n * belongs to a different tenant than this expense.\n */\n async getCostObject<T extends SmrtObject = SmrtObject>(): Promise<T | null> {\n if (!this.costObjectType || !this.costObjectId) return null;\n let registered = ObjectRegistry.getClassByQualifiedName(\n this.costObjectType,\n );\n if (!registered) {\n const loaded = await ObjectRegistry.tryLoadFromExternalPackage(\n this.costObjectType,\n );\n if (!loaded) return null;\n registered = ObjectRegistry.getClassByQualifiedName(this.costObjectType);\n }\n if (!registered) return null;\n const collection = await ObjectRegistry.getCollection<SmrtObject>(\n registered.qualifiedName ?? this.costObjectType,\n { db: this.db },\n );\n const target = await collection.get({ id: this.costObjectId });\n if (!target) return null;\n const targetTenant = (target as unknown as { tenantId?: string | null })\n .tenantId;\n if (\n targetTenant !== undefined &&\n targetTenant !== null &&\n this.tenantId !== null &&\n targetTenant !== this.tenantId\n ) {\n return null;\n }\n return target as T;\n }\n\n /**\n * Validate, enforce the review and reviewed-field locks, check the\n * commitment, then persist.\n */\n override async save(): Promise<this> {\n this.normalize();\n this.validateShape();\n return this.withTransaction(() => this.saveValidated());\n }\n\n /** Keep both directions of refund validation and the write on one executor. */\n private async saveValidated(): Promise<this> {\n await this.lockRefundRows();\n const persisted = await this.readPersisted();\n // The guards below check `persisted`; pin the write to that row.\n const inserting = await pinNaturalKey('Expense', this, persisted);\n if (\n persisted &&\n String(persisted.tenant_id ?? '') !==\n String(effectiveTenant(this.tenantId) ?? '')\n ) {\n // Includes the quiet case: saving a global expense inside a tenant\n // context, where the interceptor would fill in that tenant and split\n // the expense from its global receipts and links.\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${this.id}: its tenant cannot change after it is recorded.`,\n );\n }\n this.assertReviewFields(persisted);\n this.assertReviewedLock(persisted);\n await this.assertCommitment(persisted);\n await this.assertVendor(persisted);\n await this.assertInboundRefunds(persisted);\n await this.assertRefund(persisted);\n\n if (!this.recordedAt) this.recordedAt = new Date();\n try {\n return await super.save();\n } catch (error) {\n // A new expense is a plain INSERT; its only unique keys are the id and\n // the natural key, so a violation means it named an existing row.\n if (inserting && isUniqueViolationError(error)) {\n throw identityConflict('Expense', this, error);\n }\n throw error;\n }\n }\n\n private normalize(): void {\n this.tenantId = normalizeTenantId(this.tenantId);\n this.currency = String(this.currency ?? '')\n .trim()\n .toUpperCase();\n this.category = String(this.category ?? '').trim();\n this.costObjectType = String(this.costObjectType ?? '').trim();\n this.costObjectId = String(this.costObjectId ?? '').trim();\n this.incurredOn = String(this.incurredOn ?? '').trim();\n this.vendorId = asNullableString(this.vendorId);\n this.commitmentId = asNullableString(this.commitmentId);\n this.commitmentLineId = asNullableString(this.commitmentLineId);\n this.paidByProfileId = asNullableString(this.paidByProfileId);\n this.recordedByProfileId = asNullableString(this.recordedByProfileId);\n }\n\n private validateShape(): void {\n const label = this.id || '<new>';\n assertMinorUnits(\n 'Expense',\n label,\n 'amount',\n this.refundOfId && typeof this.amount === 'number'\n ? Math.abs(this.amount)\n : this.amount,\n );\n assertIsoCurrency('Expense', label, this.currency);\n assertIsoDate('Expense', label, 'incurredOn', this.incurredOn);\n if (!CATEGORY_PATTERN.test(this.category) || this.category.length > 64) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: category must be lowercase kebab-case ` +\n `(for example 'outside-service'), got '${this.category}'.`,\n );\n }\n if (Boolean(this.costObjectType) !== Boolean(this.costObjectId)) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: costObjectType and costObjectId are set together ` +\n 'or not at all.',\n );\n }\n if (this.costObjectType) {\n assertQualifiedClassName('Expense', label, this.costObjectType);\n }\n this.getFinancialBreakdown();\n if (\n this.paidBy === 'third-party'\n ? typeof this.paidByName !== 'string' || !this.paidByName.trim()\n : this.paidByName !== null && this.paidByName !== ''\n ) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'Only a third-party payer requires paidByName.',\n );\n }\n if (\n this.refundOfId !== null &&\n (typeof this.refundOfId !== 'string' || !this.refundOfId.trim())\n ) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'refundOfId must name a source expense or be null.',\n );\n }\n if (\n this.paidBy !== 'company' &&\n this.paidBy !== 'person' &&\n this.paidBy !== 'third-party'\n ) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: paidBy must be 'company', 'person' or 'third-party'.`,\n );\n }\n if (this.paidBy === 'person' && !this.paidByProfileId) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: an expense paid by a person names that person ` +\n '(paidByProfileId).',\n );\n }\n if (this.paidBy !== 'person' && this.paidByProfileId) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: paidByProfileId is only set when paidBy is 'person'.`,\n );\n }\n if (this.reimbursable && this.paidBy !== 'person') {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: only an expense a person paid can be owed back.`,\n );\n }\n if (this.reimbursedAt && !this.reimbursable) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: reimbursedAt needs reimbursable.`,\n );\n }\n if (this.reviewStatus === 'reviewed' && this.duplicateOfId) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: a reviewed expense cannot be marked a duplicate.`,\n );\n }\n if (this.commitmentLineId && !this.commitmentId) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n `Expense ${label}: a commitment line needs its commitment ` +\n '(commitmentId).',\n );\n }\n }\n\n /**\n * Read the stored row directly. The authoritative prior state for the\n * review and lock guards: the in-memory copy may have been mass-assigned.\n */\n private async readPersisted(): Promise<PersistedRow | null> {\n if (!this.id) return null;\n const row = await this.db.get(this.tableName, { id: this.id });\n return (row as PersistedRow | null | undefined) ?? null;\n }\n\n private reviewState(): ReviewFieldState {\n return {\n status: this.reviewStatus,\n reviewer: asNullableString(this.reviewedByProfileId),\n reviewedAtMs: instantMs(this.reviewedAt),\n note: this.reviewNote ?? '',\n duplicateOf: asNullableString(this.duplicateOfId),\n };\n }\n\n /**\n * Refuse any review-field change a transition did not authorize. The token\n * binds the transition's complete resulting state — status, reviewer,\n * time, note and duplicate marker — so a field set directly beside a\n * legitimate `review()` / `reject()` cannot ride along with it.\n */\n private assertReviewFields(persisted: PersistedRow | null): void {\n const token = authorizedTransition.get(this);\n const prior: ReviewFieldState = {\n status: (persisted?.review_status ?? 'unreviewed') as ExpenseReviewStatus,\n reviewer: asNullableString(persisted?.reviewed_by_profile_id),\n reviewedAtMs: instantMs(persisted?.reviewed_at),\n note: String(persisted?.review_note ?? ''),\n duplicateOf: asNullableString(persisted?.duplicate_of_id),\n };\n const current = this.reviewState();\n if (sameReviewState(current, prior)) return;\n if (\n !token ||\n token.from !== prior.status ||\n !sameReviewState(current, token.expected)\n ) {\n throw new ExpenseError(\n 'EXPENSE_REVIEW_FIELDS_LOCKED',\n `Expense ${this.id || '<new>'}: review state changes only through ` +\n 'review(), reject(), markDuplicate() or reopen().',\n );\n }\n }\n\n private assertReviewedLock(persisted: PersistedRow | null): void {\n if (persisted?.review_status !== 'reviewed') return;\n const self = this as unknown as Record<string, unknown>;\n for (const [fieldName, column] of LOCKED_WHEN_REVIEWED) {\n const before = persisted[column];\n const after = self[fieldName];\n const same =\n fieldName === 'amount'\n ? Number(before) === Number(after)\n : fieldName === 'recordedAt'\n ? instantMs(before) === instantMs(after)\n : asNullableString(before) === asNullableString(after);\n if (!same) {\n throw new ExpenseError(\n 'EXPENSE_REVIEW_FIELDS_LOCKED',\n `Expense ${this.id}: ${fieldName} of a reviewed expense cannot ` +\n 'change; reopen() it first.',\n );\n }\n }\n }\n\n /**\n * A matched commitment must be visible in this expense's tenant, owned by\n * the same tenant, and in the same currency; a matched line must belong to\n * it. Mixed currencies are refused here rather than summed later.\n *\n * Checked when the match is made or changed, not on every save: a later\n * change to the commitment (its currency, or its deletion) must not stop\n * an unrelated save such as recording a reimbursement.\n */\n private async assertCommitment(\n persisted: PersistedRow | null,\n ): Promise<void> {\n if (!this.commitmentId) return;\n if (\n persisted &&\n asNullableString(persisted.commitment_id) === this.commitmentId &&\n asNullableString(persisted.commitment_line_id) ===\n this.commitmentLineId &&\n String(persisted.currency ?? '') === this.currency\n ) {\n return;\n }\n const contracts = await PurchaseContractCollection.create({ db: this.db });\n const commitment = await contracts.get({ id: this.commitmentId });\n // Before super.save() the tenant interceptor has not populated tenantId\n // yet, so compare against the tenant the row is about to be written in.\n const owner = effectiveTenant(this.tenantId);\n if (!commitment || !sameTenant(commitment.tenantId, owner)) {\n throw new ExpenseError(\n 'EXPENSE_COMMITMENT_MISMATCH',\n `Expense ${this.id || '<new>'}: commitment ${this.commitmentId} was ` +\n \"not found in this expense's tenant.\",\n );\n }\n if (String(commitment.currency).toUpperCase() !== this.currency) {\n throw new ExpenseError(\n 'EXPENSE_COMMITMENT_MISMATCH',\n `Expense ${this.id || '<new>'}: currency ${this.currency} does not ` +\n `match commitment ${this.commitmentId} (${commitment.currency}). ` +\n 'Amounts in different currencies are never summed.',\n );\n }\n if (this.commitmentLineId) {\n const lines = await ContractLineItemCollection.create({ db: this.db });\n const line = await lines.get({ id: this.commitmentLineId });\n if (!line || line.contractId !== this.commitmentId) {\n throw new ExpenseError(\n 'EXPENSE_COMMITMENT_MISMATCH',\n `Expense ${this.id || '<new>'}: line ${this.commitmentLineId} is ` +\n `not a line of commitment ${this.commitmentId}.`,\n );\n }\n }\n }\n\n /**\n * A vendor must be visible in, and owned by, the expense's tenant. A\n * `crossPackageRef` neither validates its target nor creates a foreign\n * key, so without this an expense in one tenant could name another\n * tenant's vendor. Checked when the vendor is set or changed.\n */\n private async assertVendor(persisted: PersistedRow | null): Promise<void> {\n if (!this.vendorId) return;\n if (persisted && asNullableString(persisted.vendor_id) === this.vendorId) {\n return;\n }\n const vendors = await VendorCollection.create({ db: this.db });\n const vendor = await vendors.get({ id: this.vendorId });\n const owner = effectiveTenant(this.tenantId);\n if (!vendor || !sameTenant(vendor.tenantId, owner)) {\n throw new ExpenseError(\n 'EXPENSE_VENDOR_MISMATCH',\n `Expense ${this.id || '<new>'}: vendor ${this.vendorId} was not ` +\n \"found in this expense's tenant.\",\n );\n }\n }\n\n /**\n * Source edits and refund writes contend on the same source row. Acquire\n * rows in deterministic order before either relationship check, and hold\n * them through commit. SQLite's write transaction supplies serialization.\n */\n private async lockRefundRows(): Promise<void> {\n if (!isPostgresDatabase(this.db)) return;\n const ids = [\n ...new Set(\n [this.id, this.refundOfId].filter((id): id is string => Boolean(id)),\n ),\n ].sort();\n if (ids.length === 0) return;\n const table = `\"${this.tableName.replace(/\"/g, '\"\"')}\"`;\n await this.db.query(\n `SELECT id FROM ${table} WHERE id IN (${ids.map(() => '?').join(', ')}) ` +\n 'AND tenant_id IS NOT DISTINCT FROM ? ORDER BY id FOR UPDATE',\n ...ids,\n effectiveTenant(this.tenantId),\n );\n }\n\n /** Refuse source corrections that invalidate any same-owner refund. */\n private async assertInboundRefunds(\n persisted: PersistedRow | null,\n ): Promise<void> {\n if (\n !persisted ||\n (Number(persisted.amount) === this.amount &&\n String(persisted.currency ?? '') === this.currency &&\n asNullableString(persisted.refund_of_id) === this.refundOfId)\n )\n return;\n const { ExpenseCollection } = await import(\n '../collections/ExpenseCollection.js'\n );\n const expenses = await ExpenseCollection.create({ db: this.db });\n const invalidated = await expenses.list({\n where: {\n refundOfId: this.id,\n tenantId: effectiveTenant(this.tenantId),\n ...(this.amount > 0 && !this.refundOfId\n ? { 'currency !=': this.currency }\n : {}),\n },\n select: ['id'],\n limit: 1,\n cache: false,\n });\n if (invalidated.length > 0) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'A referenced refund source must remain positive, non-refund, and in the currency of its refunds.',\n );\n }\n }\n\n private async assertRefund(persisted: PersistedRow | null): Promise<void> {\n if (!this.refundOfId) return;\n if (this.refundOfId === this.id || this.amount >= 0) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'A refund must be negative and name a different positive source expense.',\n );\n }\n const { ExpenseCollection } = await import(\n '../collections/ExpenseCollection.js'\n );\n const expenses = await ExpenseCollection.create({ db: this.db });\n const source = await expenses.get(\n { id: this.refundOfId },\n { cache: false },\n );\n if (\n !source ||\n !sameTenant(source.tenantId, effectiveTenant(this.tenantId)) ||\n source.currency !== this.currency ||\n source.amount <= 0 ||\n source.refundOfId\n ) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'Refund source must be a positive non-refund expense in the same tenant and currency.',\n );\n }\n if (!persisted || persisted.refund_of_id !== this.refundOfId) {\n // A row lock alone cannot refresh an existing REPEATABLE READ snapshot.\n // Advance the source through public CAS so stale source writers abort.\n if (!source.updated_at) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'Refund source has no revision.',\n );\n }\n await source.claimRevision(source.updated_at);\n }\n }\n\n private async transition(\n action: keyof typeof REVIEW_TRANSITIONS,\n apply: () => void,\n ): Promise<this> {\n const rule = REVIEW_TRANSITIONS[action];\n const persisted = await this.readPersisted();\n if (!persisted) {\n throw new ExpenseError(\n 'EXPENSE_REVIEW_TRANSITION',\n `Expense ${this.id || '<new>'}: save the expense before ${action}().`,\n );\n }\n const from = String(persisted.review_status) as ExpenseReviewStatus;\n if (!rule.from.includes(from)) {\n throw new ExpenseError(\n 'EXPENSE_REVIEW_TRANSITION',\n `Expense ${this.id}: cannot ${action}() an expense that is ${from}.`,\n );\n }\n if (action === 'review' && persisted.duplicate_of_id) {\n throw new ExpenseError(\n 'EXPENSE_REVIEW_TRANSITION',\n `Expense ${this.id}: a duplicate cannot be reviewed; reopen() it first.`,\n );\n }\n const snapshot = {\n reviewStatus: this.reviewStatus,\n reviewedByProfileId: this.reviewedByProfileId,\n reviewedAt: this.reviewedAt,\n reviewNote: this.reviewNote,\n duplicateOfId: this.duplicateOfId,\n };\n // Every transition assigns every review field, so the token below\n // describes the whole resulting review state.\n apply();\n authorizedTransition.set(this, { from, expected: this.reviewState() });\n try {\n return await this.save();\n } catch (error) {\n Object.assign(this, snapshot);\n throw error;\n } finally {\n authorizedTransition.delete(this);\n }\n }\n}\n\nfunction requireReviewer(reviewerProfileId: string | undefined): string {\n const reviewer = String(reviewerProfileId ?? '').trim();\n if (!reviewer) {\n throw new ExpenseError(\n 'EXPENSE_INVALID',\n 'A review decision names its reviewer (reviewerProfileId).',\n );\n }\n return reviewer;\n}\n\nfunction sameTenant(\n a: string | null | undefined,\n b: string | null | undefined,\n): boolean {\n return (a ?? null) === (b ?? null);\n}\n"],"mappings":"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"}
|