@happyvertical/smrt-expenses 0.55.13 → 0.55.15

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 CHANGED
@@ -10,7 +10,7 @@ review, reimbursement recording, and smrt-commerce commitment drawdown (#3289).
10
10
  in `SUGGESTED_EXPENSE_CATEGORIES`), `amount` integer minor units + ISO 4217
11
11
  `currency`, `incurredOn` (`YYYY-MM-DD` text: a calendar date, not an
12
12
  instant), `recordedAt`, `recordedByProfileId`, `paidBy` (`company` |
13
- `person`) + `paidByProfileId`, `vendorId`, `commitmentId` /
13
+ `person` | `third-party`) + `paidByProfileId` / `paidByName`, `vendorId`, `commitmentId` /
14
14
  `commitmentLineId`, review fields, `reimbursable` / `reimbursedAt` /
15
15
  `reimbursementReference`.
16
16
  - **ExpenseReceipt** (`expense_receipts`): noun-specific asset join
@@ -98,6 +98,33 @@ review, reimbursement recording, and smrt-commerce commitment drawdown (#3289).
98
98
  object per expense (columns, not a `SmrtPolymorphicAssociation` join) so a
99
99
  cost can never roll up into two jobs.
100
100
 
101
+ ## Optional financial facts
102
+
103
+ `financialBreakdown` is guarded JSON text (`get/setFinancialBreakdown`), absent
104
+ for legacy rows. Exact `getCostBasis()` returns null without facts. Complete facts
105
+ reconcile subtotal + signed tax components to amount/currency; recoverable amounts
106
+ are explicit. No tax law or customer pricing is inferred. `third-party` payer
107
+ requires `paidByName` and is not reimbursable. Negative amounts require `refundOfId`
108
+ (same-owner/currency positive source, checked through the bound executor).
109
+ Source edits also check inbound same-owner refunds: a referenced source stays
110
+ positive, non-refund, and in their currency. `save()` encloses guards and write
111
+ in the public `withTransaction()` boundary; PostgreSQL locks its row and proposed
112
+ source in id order before either refund check, while SQLite serializes writes.
113
+ The inbound projected lookup uses the existing `refund_of_id` index and an
114
+ explicit owner filter. Keep caller transaction affinity and per-row revision
115
+ checks; neither an unlocked lookup nor revision CAS alone protects this pair.
116
+ All three fields participate in the reviewed-money freeze. See the README and
117
+ [financial facts test matrix](agents/financial-facts-test-matrix.md).
118
+
119
+ Linking a new refund (or changing its source) also claims the source revision in
120
+ that same transaction through `claimRevision(source.updated_at)`. This makes a
121
+ source edit under an older PostgreSQL `REPEATABLE READ` snapshot fail with a
122
+ serialization error instead of missing the new refund. Reload a source after
123
+ linking a refund before editing/reviewing it; on a serialization failure, retry
124
+ the entire caller transaction with fresh instances. Failed outer transactions
125
+ roll back both the refund and the source revision claim. Existing-link saves do
126
+ not advance the source revision.
127
+
101
128
  ## Cross-package references
102
129
 
103
130
  - `vendorId` → `@crossPackageRef('@happyvertical/smrt-commerce:Vendor')`
package/README.md CHANGED
@@ -6,7 +6,7 @@ review, reimbursement recording, and drawdown of a purchase-order commitment.
6
6
 
7
7
  - **Expense** — polymorphic cost object, category, amount in integer minor
8
8
  units with currency, incurred date apart from recorded time, who recorded
9
- it, who paid (the company or a named person out of pocket), optional
9
+ it, who paid (the company, a named person, or a named third party), optional
10
10
  smrt-commerce vendor and commitment, and a review state.
11
11
  - **ExpenseReceipt** — expense ↔ smrt-assets `Asset` join with uploader,
12
12
  filename, MIME type, byte count and `contentSha256`. The same file twice on
@@ -197,3 +197,66 @@ capture, and `ExpenseReviewQueue` for native approval/rejection with retained
197
197
  reasons. `ExpenseForm` includes an optional amount keypad and editable
198
198
  cost-object references. See [the UI contract](agents/expense-ui.md) for public
199
199
  Props, request identity, no-JavaScript behavior and server responsibilities.
200
+
201
+ ## Optional supplier financial facts
202
+
203
+ Existing expenses retain only their recorded amount: `getFinancialBreakdown()`
204
+ and `getCostBasis()` return `null` until a caller supplies explicit facts. An
205
+ empty tax array asserts no tax; absent facts never imply tax-free treatment.
206
+
207
+ `setFinancialBreakdown({ currency, subtotal, taxes, total })` accepts integer
208
+ minor units. Each tax component is `{ code, amount, recoverableAmount }`: its
209
+ unique lower-case kebab-case code is caller-defined and its recoverable portion
210
+ is explicit, including zero. Subtotal plus taxes must exactly equal both total
211
+ and `Expense.amount`, in `Expense.currency`. Components share the total's sign,
212
+ recovery cannot exceed charged tax, and all values must be safe integers.
213
+ Unknown fields and malformed persisted JSON fail closed. The `financialBreakdown`
214
+ text column stores these facts; use the helpers rather than editing JSON.
215
+
216
+ `getCostBasis()` returns `{ currency, subtotal, taxTotal, recoverableTax,
217
+ nonRecoverableTax, total, costBasis }`. Cost basis equals subtotal plus
218
+ non-recoverable tax (equivalently total minus recoverable tax), with no rounding.
219
+ For example, subtotal 10000, tax 1000 and recoverable tax 400 yield basis 10600.
220
+ This is a purchase-cost fact, not a customer markup or sales-tax decision.
221
+ Callers select their tax treatment; the package supplies no jurisdiction defaults.
222
+
223
+ `paidBy: 'third-party'` requires `paidByName`. It excludes `paidByProfileId` and
224
+ `reimbursable`; company and person behavior remains unchanged. No reimbursement
225
+ or settlement is inferred from the named payer.
226
+
227
+ Negative amounts require `refundOfId`, naming a positive non-refund expense
228
+ visible in the same tenant and currency. Refund breakdown components are negative
229
+ (or zero), including recovered tax. Self, missing, foreign-owner, currency-mismatched,
230
+ and refund-of-refund sources fail closed. Source lookup uses the caller's database
231
+ executor. This link retains provenance only: it does not allocate settlements,
232
+ cap cumulative refunds, issue credits, or determine entitlement. A source may
233
+ be explicitly reopened and corrected, but existing same-owner refunds prevent
234
+ changes that would make it zero/nonpositive, turn it into a refund, or change
235
+ its currency away from theirs. Unrelated edits and positive same-currency amount
236
+ corrections remain allowed; there is no cumulative refund limit.
237
+
238
+ Expense saves validate and persist in one transaction, including nested caller
239
+ transactions. On PostgreSQL, source edits and refund writes acquire ordered
240
+ row locks on the expense and proposed source before relationship validation,
241
+ keeping the source/refund checks valid through the write and commit. SQLite uses
242
+ the framework's serialized write transactions. The existing per-row revision
243
+ check still rejects stale object writes; protecting the relationship additionally
244
+ requires these locks and the source revision claim below. Callers must not concurrently reuse the same mutable
245
+ model instance while it is temporarily transaction-bound. Direct SQL writes
246
+ remain outside these model-level guarantees.
247
+
248
+ Linking a new refund (or changing its source) also claims the source revision in
249
+ that same transaction through `claimRevision(source.updated_at)`. This makes a
250
+ source edit under an older PostgreSQL `REPEATABLE READ` snapshot fail with a
251
+ serialization error instead of missing the new refund. Reload a source after
252
+ linking a refund before editing/reviewing it; on a serialization failure, retry
253
+ the entire caller transaction with fresh instances. Failed outer transactions
254
+ roll back both the refund and the source revision claim. Existing-link saves do
255
+ not advance the source revision.
256
+
257
+ Financial breakdown, third-party payer name, and refund provenance join the
258
+ existing reviewed-money lock. Save `reopen()` first, then correct facts and review
259
+ again; corrections cannot ride alongside the reopen operation. New columns are
260
+ additive via `smrt db:migrate`; legacy amounts survive without invented facts.
261
+ The SQLite and PostgreSQL suites execute upgrades from the preceding schema and
262
+ check that repeat migration is a no-op.
@@ -0,0 +1,96 @@
1
+ # Expense financial facts test design (#3792)
2
+
3
+ Risk: high — persisted financial mutation, tenancy, reviewed immutability, and
4
+ schema migration. Synthetic data only. Scope is source facts, not tax law or
5
+ settlement. Existing generated mutation surfaces remain closed.
6
+
7
+ All integration rows run in the shared expense behavior suite on SQLite and
8
+ PostgreSQL, using the caller-bound database executor and active tenant. Commands:
9
+ `pnpm --filter @happyvertical/smrt-expenses test` (S),
10
+ `pnpm --filter @happyvertical/smrt-expenses test:postgres` (P).
11
+
12
+ | Behavior / reachable trigger | Positive | Negative / failure | Actor / context | Executor / transaction | Runtime / dialect | External edge | Level / command |
13
+ | --- | --- | --- | --- | --- | --- | --- | --- |
14
+ | Optional source facts through create/save/load | Legacy amount roundtrip has no breakdown or inferred basis; explicit facts survive reload | Unknown keys, malformed JSON/types/currency, mismatched total/sign, duplicate component, overflow rejected | Consumer with same active tenant | Bound collection database, no multirow mutation | Node SQLite/PostgreSQL | Optional missing facts; unknown values fail closed | Integration S/P |
15
+ | Exact basis through getCostBasis | Partial recovery and negative refund exact minor units | Unsafe numbers and inconsistent recovery rejected | Consumer reading source facts | Pure synchronous calculation; N/A transaction (no writes) | Node plus persisted roundtrip both dialects | Structural typed result usable without dependency | Unit/integration S/P |
16
+ | Refund zero-tax recovery boundary | Zero charged and zero recovered accepted | Negative recovery against zero tax rejected by setter and save; persisted facts unchanged | Same tenant author | Bound collection database; retry after correction | SQLite/PostgreSQL | Exact magnitude bound includes zero | Regression integration S/P |
17
+ | Named third-party payer save | Name persists without employee reimbursement | Missing name, incompatible profile/name, reimbursable rejected; company/person unchanged | Same tenant expense author | Bound database | SQLite/PostgreSQL | New enum explicit, unknown rejected | Integration S/P |
18
+ | Refund source save | Negative refund references positive same-owner/currency source | Self, missing, cross-tenant/global, currency mismatch, refund-of-refund rejected | Active tenant × same/foreign/global resource | Lookup uses same database executor; transaction rollback and retry | SQLite/PostgreSQL | No settlement or cumulative refund entitlement inferred | Integration S/P |
19
+ | Reviewed facts save/reopen/review | Reopen, correct, review permitted | Direct financial/payer/refund mutation and mutation alongside reopen rejected; failed save preserves persisted row | Consumer review method; existing authorization contract unchanged | Revision-guarded single-row save; transaction rollback | SQLite/PostgreSQL | Existing closed generated surface retained | Integration S/P |
20
+ | Reviewed refund roll-up | Signed gross totals and commitment drawdown count source/refund once, retained basis does not replace legacy amount | Existing cross-currency/tenant commitment denials remain active | Same tenant reviewer | Bound collections and revision-guarded saves | SQLite/PostgreSQL | Existing aggregate semantics preserved | Integration S/P |
21
+ | Additive schema migration | Existing row retains amount and absent financial facts; repeat migration no-op | Live parity and column types asserted | Schema operator, no end-user authorization | Public dbMigrate migration path | SQLite/PostgreSQL | Existing deployed schema upgraded | Migration S/P |
22
+
23
+ Additional gates: package typecheck, build, browser harness; root combined release
24
+ validation and exact-head review belong to the coordinator. Original additive
25
+ feature rows do not require a bug-fix comparison; the zero-tax correction has
26
+ explicit failing-base evidence below. No network provider is
27
+ introduced: upstream provider failure is N/A. Refund lookup failure is tested.
28
+
29
+ ## Executed release evidence
30
+
31
+ Executed on Node 26.10.0 / pnpm 11.25.0, with SQLite and PostgreSQL. All
32
+ behavior rows above run in the shared suite; PostgreSQL additionally checks
33
+ native column types and live parity. Both migration fixtures start from the
34
+ previous expense schema, preserve a seeded legacy row, exercise the migrated
35
+ refund FK's RESTRICT behavior, and prove repeat migration is a no-op.
36
+
37
+ - `test`: 61 backend tests and 15 UI tests passed. The optional PostgreSQL file
38
+ is deliberately skipped here and executed by its dedicated command.
39
+ - `test:postgres`: 55 passed; no skipped tests.
40
+ - `test:e2e`: 11 passed using the supported system Chromium, including native
41
+ submissions without JavaScript.
42
+ - Package build, typecheck (including Svelte), and scoped Biome checks passed.
43
+ - SQLite migration requires the generic new nullable FK column fix in #3795;
44
+ the initial executable failure and passing dependency-head run are retained.
45
+
46
+ Full output is retained outside the checkout under
47
+ `/home/will/.local/state/happyvertical/tradeschief-277-release1/expenses/`:
48
+ `test-final.log`, `postgres-final-with-core.log`, `e2e.log`,
49
+ `package-build.log`, `typecheck-delivery.log`, `lint-final.log`, and
50
+ `core-build.log`. Initial diagnostic runs remain there for failure provenance.
51
+ The redundant whole-workspace build was stopped to reduce memory contention;
52
+ the coordinator owns the final combined root gates and exact-head review.
53
+
54
+
55
+ ## Zero-tax refund correction
56
+
57
+ The added regression fails against feature commit `08a791a93`: a refund with
58
+ subtotal/total -100, tax 0, and recoverable tax -1 was accepted. Comparing exact
59
+ BigInt magnitudes fixes the bound without changing the sign requirements.
60
+ The shared regression also rejects raw-save bypass, verifies stored facts are
61
+ unchanged after failure, and permits retry with zero recovery. Evidence is in
62
+ `zero-tax-base.log` (expected failure), `zero-tax-test.log`,
63
+ `zero-tax-postgres.log`, `zero-tax-typecheck.log`, and `zero-tax-build.log`
64
+ under the evidence directory above.
65
+ Final correction validation: 62 backend + 15 UI tests and 56 PostgreSQL tests
66
+ passed, including both migration lanes. The unchanged browser contract retains
67
+ the 11-pass evidence above.
68
+
69
+
70
+ ## Inbound refund source invariant — review thread 4243223180
71
+
72
+ Risk remains high (financial relationship mutation). Baseline is release
73
+ `d372e2df68f1000bbfccb9dbe88afcd7d78ece9b`. Execute before/after evidence for
74
+ the new regressions; the existing per-row revision guard alone cannot protect a
75
+ source/refund pair from concurrent edits.
76
+
77
+ | Behavior / reachable trigger | Positive | Negative / failure | Actor / context | Executor / transaction | Runtime / dialect | External edge | Level / command |
78
+ | --- | --- | --- | --- | --- | --- | --- | --- |
79
+ | Source save with inbound refunds | Unrelated edits and positive same-currency corrections remain valid; unreferenced sources remain editable | Zero amount, changed currency, or conversion to refund refused; source and reviewed refund unchanged | Same owner, source unreviewed or reopened, refund reviewed | Bound transaction; existing revision CAS retained | SQLite/PostgreSQL | Refund source eligibility preserved in both directions | Shared integration S/P |
80
+ | Inbound lookup ownership | NULL owner and tenant-owned references enforced outside context too | Malformed foreign-owner inbound fixture must not block another tenant; ordinary cross-tenant creation remains refused | Active tenant / no context × own / foreign / global | Public projected list with owner predicate, existing refund FK index | SQLite/PostgreSQL | No cross-tenant lookup bypass or existence disclosure | Shared integration S/P |
81
+ | Caller transaction affinity | Guard sees uncommitted inbound refund; valid retry after rollback | Invalid source edit rolls back sibling refund mutation; original rows remain valid | Same tenant author | Existing public nested transaction and withDatabase APIs | SQLite/PostgreSQL | No independent root transaction or partial commit | Shared integration S/P |
82
+ | Source/refund concurrent save | Winner preserves eligible persisted relationship | Refund-first blocks source edit; source-first makes later refund fail on current source | Same tenant, distinct real connections | Ordered PostgreSQL row locks held from validation through commit; SQLite write transaction serialization | PostgreSQL interleaving plus shared SQLite atomicity | No blanket source freeze or settlement limits | Real-PG integration P |
83
+ | Stale repeatable-read source snapshot | Fresh same-currency positive edit succeeds | Snapshot pinned before independent refund commit; stale currency edit aborts serialization; fresh invalid edit rejects | Same tenant | Public source revision claim in refund transaction | PostgreSQL REPEATABLE READ | No missed committed refund under stale snapshot | Real-PG integration P |
84
+
85
+ The source revision advances atomically on a new/changed refund link through the
86
+ public revision API, including rollback with a caller transaction. Source
87
+ instances loaded before linking must be reloaded for subsequent valid edits.
88
+
89
+ Reproduction evidence in the external `inbound-refunds/` directory: baseline
90
+ SQLite fails all four new shared regressions, baseline PostgreSQL fails those
91
+ four plus both writer-order cases. Row locks without a source revision claim
92
+ also fail the repeatable-read regression (`base-rr.log`). The corrected focused
93
+ runs pass four SQLite and seven PostgreSQL cases (`fixed-sqlite.log`,
94
+ `fixed-postgres.log`). Final committed-head package build, full test, PostgreSQL,
95
+ typecheck, and browser commands are recorded with HEAD and exit status in
96
+ `validation-records.json` and their named logs in that same evidence directory.
@@ -1,5 +1,5 @@
1
1
  import { ExpenseError } from "../types.js";
2
- import { ObjectRegistry, SmrtObject, crossPackageRef, field, foreignKey, isUniqueViolationError, smrt } from "@happyvertical/smrt-core";
2
+ import { ObjectRegistry, SmrtObject, crossPackageRef, field, foreignKey, isPostgresDatabase, isUniqueViolationError, smrt } from "@happyvertical/smrt-core";
3
3
  import { ContractCollection, ContractLineItemCollection, VendorCollection } from "@happyvertical/smrt-commerce";
4
4
  import { TenantScoped, getTenantId, tenantId } from "@happyvertical/smrt-tenancy";
5
5
  import { createHash } from "node:crypto";
@@ -59,6 +59,83 @@ function normalizeTenantId(tenantId) {
59
59
  return tenantId || null;
60
60
  }
61
61
  //#endregion
62
+ //#region src/financial-facts.ts
63
+ function invalid(message) {
64
+ throw new ExpenseError("EXPENSE_INVALID", `Expense financial breakdown: ${message}`);
65
+ }
66
+ function record(value, keys) {
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid("expected an object.");
68
+ const object = value;
69
+ if (Object.keys(object).length !== keys.length || keys.some((key) => !Object.hasOwn(object, key))) return invalid("missing or unknown fields.");
70
+ return object;
71
+ }
72
+ function minor(value) {
73
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) return invalid("amounts must be safe integer minor units.");
74
+ return BigInt(value);
75
+ }
76
+ function safe(value) {
77
+ const result = Number(value);
78
+ if (!Number.isSafeInteger(result)) return invalid("unsafe calculated amount.");
79
+ return result;
80
+ }
81
+ function expenseCostBasis(value, amount, currency) {
82
+ const facts = record(value, [
83
+ "currency",
84
+ "subtotal",
85
+ "taxes",
86
+ "total"
87
+ ]);
88
+ if (typeof facts.currency !== "string" || facts.currency !== currency) return invalid("currency must match the expense.");
89
+ assertIsoCurrency("Expense", "<financial-breakdown>", facts.currency);
90
+ const total = minor(facts.total);
91
+ const subtotal = minor(facts.subtotal);
92
+ if (total !== minor(amount)) return invalid("total must match the expense amount.");
93
+ const consistent = (part) => part === 0n || (total > 0n ? part > 0n : total < 0n && part < 0n);
94
+ if (!consistent(subtotal)) return invalid("subtotal sign must match total.");
95
+ if (!Array.isArray(facts.taxes)) return invalid("taxes must be an explicit array.");
96
+ let tax = 0n;
97
+ let recoverable = 0n;
98
+ const codes = /* @__PURE__ */ new Set();
99
+ for (const item of facts.taxes) {
100
+ const component = record(item, [
101
+ "code",
102
+ "amount",
103
+ "recoverableAmount"
104
+ ]);
105
+ if (typeof component.code !== "string" || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(component.code) || component.code.length > 64 || codes.has(component.code)) return invalid("tax codes must be unique lower-case kebab-case identifiers.");
106
+ codes.add(component.code);
107
+ const charged = minor(component.amount);
108
+ const recovered = minor(component.recoverableAmount);
109
+ const chargedMagnitude = charged < 0n ? -charged : charged;
110
+ const recoveredMagnitude = recovered < 0n ? -recovered : recovered;
111
+ if (!consistent(charged) || !consistent(recovered) || recoveredMagnitude > chargedMagnitude) return invalid("tax and recovery must share the total sign, with recovery bounded by tax.");
112
+ tax += charged;
113
+ recoverable += recovered;
114
+ }
115
+ if (subtotal + tax !== total) return invalid("subtotal plus taxes must equal total.");
116
+ return {
117
+ currency,
118
+ subtotal: safe(subtotal),
119
+ taxTotal: safe(tax),
120
+ recoverableTax: safe(recoverable),
121
+ nonRecoverableTax: safe(tax - recoverable),
122
+ total: safe(total),
123
+ costBasis: safe(total - recoverable)
124
+ };
125
+ }
126
+ function parseExpenseFinancialBreakdown(stored, amount, currency) {
127
+ if (stored === "" || stored === null || stored === void 0) return null;
128
+ if (typeof stored !== "string") return invalid("storage must be JSON text.");
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(stored);
132
+ } catch {
133
+ return invalid("invalid JSON.");
134
+ }
135
+ expenseCostBasis(parsed, amount, currency);
136
+ return parsed;
137
+ }
138
+ //#endregion
62
139
  //#region src/models/Expense.ts
63
140
  var __defProp = Object.defineProperty;
64
141
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -96,6 +173,9 @@ var REVIEW_TRANSITIONS = {
96
173
  };
97
174
  var LOCKED_WHEN_REVIEWED = [
98
175
  ["amount", "amount"],
176
+ ["financialBreakdown", "financial_breakdown"],
177
+ ["paidByName", "paid_by_name"],
178
+ ["refundOfId", "refund_of_id"],
99
179
  ["currency", "currency"],
100
180
  ["incurredOn", "incurred_on"],
101
181
  ["recordedAt", "recorded_at"],
@@ -123,6 +203,9 @@ var Expense = class extends SmrtObject {
123
203
  description = "";
124
204
  amount = 0;
125
205
  currency = "";
206
+ financialBreakdown = null;
207
+ paidByName = null;
208
+ refundOfId = null;
126
209
  incurredOn = "";
127
210
  /** When the expense was recorded; set on first save when empty. */
128
211
  recordedAt = null;
@@ -150,6 +233,9 @@ var Expense = class extends SmrtObject {
150
233
  if (options.description !== void 0) this.description = options.description;
151
234
  if (options.amount !== void 0) this.amount = options.amount;
152
235
  if (options.currency !== void 0) this.currency = options.currency;
236
+ if (options.financialBreakdown !== void 0) this.financialBreakdown = options.financialBreakdown;
237
+ if (options.paidByName !== void 0) this.paidByName = options.paidByName;
238
+ if (options.refundOfId !== void 0) this.refundOfId = options.refundOfId;
153
239
  if (options.incurredOn !== void 0) this.incurredOn = options.incurredOn;
154
240
  if (options.recordedAt !== void 0) this.recordedAt = options.recordedAt;
155
241
  if (options.recordedByProfileId !== void 0) this.recordedByProfileId = options.recordedByProfileId;
@@ -162,6 +248,24 @@ var Expense = class extends SmrtObject {
162
248
  if (options.reimbursedAt !== void 0) this.reimbursedAt = options.reimbursedAt;
163
249
  if (options.reimbursementReference !== void 0) this.reimbursementReference = options.reimbursementReference;
164
250
  }
251
+ /** Read validated supplier facts, or null when none were recorded. */
252
+ getFinancialBreakdown() {
253
+ return parseExpenseFinancialBreakdown(this.financialBreakdown, this.amount, this.currency);
254
+ }
255
+ /** Set complete supplier facts; null explicitly clears facts on an unreviewed row. */
256
+ setFinancialBreakdown(value) {
257
+ if (value === null) {
258
+ this.financialBreakdown = null;
259
+ return;
260
+ }
261
+ expenseCostBasis(value, this.amount, this.currency);
262
+ this.financialBreakdown = JSON.stringify(value);
263
+ }
264
+ /** Return exact retained purchase cost; legacy unknown tax facts return null. */
265
+ getCostBasis() {
266
+ const facts = this.getFinancialBreakdown();
267
+ return facts === null ? null : expenseCostBasis(facts, this.amount, this.currency);
268
+ }
165
269
  /** True when the person who paid has been paid back. */
166
270
  isReimbursed() {
167
271
  return this.reimbursedAt !== null && this.reimbursedAt !== void 0;
@@ -288,6 +392,11 @@ var Expense = class extends SmrtObject {
288
392
  async save() {
289
393
  this.normalize();
290
394
  this.validateShape();
395
+ return this.withTransaction(() => this.saveValidated());
396
+ }
397
+ /** Keep both directions of refund validation and the write on one executor. */
398
+ async saveValidated() {
399
+ await this.lockRefundRows();
291
400
  const persisted = await this.readPersisted();
292
401
  const inserting = await pinNaturalKey("Expense", this, persisted);
293
402
  if (persisted && String(persisted.tenant_id ?? "") !== String(effectiveTenant(this.tenantId) ?? "")) throw new ExpenseError("EXPENSE_INVALID", `Expense ${this.id}: its tenant cannot change after it is recorded.`);
@@ -295,6 +404,8 @@ var Expense = class extends SmrtObject {
295
404
  this.assertReviewedLock(persisted);
296
405
  await this.assertCommitment(persisted);
297
406
  await this.assertVendor(persisted);
407
+ await this.assertInboundRefunds(persisted);
408
+ await this.assertRefund(persisted);
298
409
  if (!this.recordedAt) this.recordedAt = /* @__PURE__ */ new Date();
299
410
  try {
300
411
  return await super.save();
@@ -318,15 +429,18 @@ var Expense = class extends SmrtObject {
318
429
  }
319
430
  validateShape() {
320
431
  const label = this.id || "<new>";
321
- assertMinorUnits("Expense", label, "amount", this.amount);
432
+ assertMinorUnits("Expense", label, "amount", this.refundOfId && typeof this.amount === "number" ? Math.abs(this.amount) : this.amount);
322
433
  assertIsoCurrency("Expense", label, this.currency);
323
434
  assertIsoDate("Expense", label, "incurredOn", this.incurredOn);
324
435
  if (!CATEGORY_PATTERN.test(this.category) || this.category.length > 64) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: category must be lowercase kebab-case (for example 'outside-service'), got '${this.category}'.`);
325
436
  if (Boolean(this.costObjectType) !== Boolean(this.costObjectId)) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: costObjectType and costObjectId are set together or not at all.`);
326
437
  if (this.costObjectType) assertQualifiedClassName("Expense", label, this.costObjectType);
327
- if (this.paidBy !== "company" && this.paidBy !== "person") throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: paidBy must be 'company' or 'person'.`);
438
+ this.getFinancialBreakdown();
439
+ if (this.paidBy === "third-party" ? typeof this.paidByName !== "string" || !this.paidByName.trim() : this.paidByName !== null && this.paidByName !== "") throw new ExpenseError("EXPENSE_INVALID", "Only a third-party payer requires paidByName.");
440
+ if (this.refundOfId !== null && (typeof this.refundOfId !== "string" || !this.refundOfId.trim())) throw new ExpenseError("EXPENSE_INVALID", "refundOfId must name a source expense or be null.");
441
+ if (this.paidBy !== "company" && this.paidBy !== "person" && this.paidBy !== "third-party") throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: paidBy must be 'company', 'person' or 'third-party'.`);
328
442
  if (this.paidBy === "person" && !this.paidByProfileId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: an expense paid by a person names that person (paidByProfileId).`);
329
- if (this.paidBy === "company" && this.paidByProfileId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: paidByProfileId is only set when paidBy is 'person'.`);
443
+ if (this.paidBy !== "person" && this.paidByProfileId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: paidByProfileId is only set when paidBy is 'person'.`);
330
444
  if (this.reimbursable && this.paidBy !== "person") throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: only an expense a person paid can be owed back.`);
331
445
  if (this.reimbursedAt && !this.reimbursable) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: reimbursedAt needs reimbursable.`);
332
446
  if (this.reviewStatus === "reviewed" && this.duplicateOfId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: a reviewed expense cannot be marked a duplicate.`);
@@ -411,6 +525,44 @@ var Expense = class extends SmrtObject {
411
525
  const owner = effectiveTenant(this.tenantId);
412
526
  if (!vendor || !sameTenant(vendor.tenantId, owner)) throw new ExpenseError("EXPENSE_VENDOR_MISMATCH", `Expense ${this.id || "<new>"}: vendor ${this.vendorId} was not found in this expense's tenant.`);
413
527
  }
528
+ /**
529
+ * Source edits and refund writes contend on the same source row. Acquire
530
+ * rows in deterministic order before either relationship check, and hold
531
+ * them through commit. SQLite's write transaction supplies serialization.
532
+ */
533
+ async lockRefundRows() {
534
+ if (!isPostgresDatabase(this.db)) return;
535
+ const ids = [...new Set([this.id, this.refundOfId].filter((id) => Boolean(id)))].sort();
536
+ if (ids.length === 0) return;
537
+ const table = `"${this.tableName.replace(/"/g, "\"\"")}"`;
538
+ await this.db.query(`SELECT id FROM ${table} WHERE id IN (${ids.map(() => "?").join(", ")}) AND tenant_id IS NOT DISTINCT FROM ? ORDER BY id FOR UPDATE`, ...ids, effectiveTenant(this.tenantId));
539
+ }
540
+ /** Refuse source corrections that invalidate any same-owner refund. */
541
+ async assertInboundRefunds(persisted) {
542
+ if (!persisted || Number(persisted.amount) === this.amount && String(persisted.currency ?? "") === this.currency && asNullableString(persisted.refund_of_id) === this.refundOfId) return;
543
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
544
+ if ((await (await ExpenseCollection.create({ db: this.db })).list({
545
+ where: {
546
+ refundOfId: this.id,
547
+ tenantId: effectiveTenant(this.tenantId),
548
+ ...this.amount > 0 && !this.refundOfId ? { "currency !=": this.currency } : {}
549
+ },
550
+ select: ["id"],
551
+ limit: 1,
552
+ cache: false
553
+ })).length > 0) throw new ExpenseError("EXPENSE_INVALID", "A referenced refund source must remain positive, non-refund, and in the currency of its refunds.");
554
+ }
555
+ async assertRefund(persisted) {
556
+ if (!this.refundOfId) return;
557
+ if (this.refundOfId === this.id || this.amount >= 0) throw new ExpenseError("EXPENSE_INVALID", "A refund must be negative and name a different positive source expense.");
558
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
559
+ const source = await (await ExpenseCollection.create({ db: this.db })).get({ id: this.refundOfId }, { cache: false });
560
+ if (!source || !sameTenant(source.tenantId, effectiveTenant(this.tenantId)) || source.currency !== this.currency || source.amount <= 0 || source.refundOfId) throw new ExpenseError("EXPENSE_INVALID", "Refund source must be a positive non-refund expense in the same tenant and currency.");
561
+ if (!persisted || persisted.refund_of_id !== this.refundOfId) {
562
+ if (!source.updated_at) throw new ExpenseError("EXPENSE_INVALID", "Refund source has no revision.");
563
+ await source.claimRevision(source.updated_at);
564
+ }
565
+ }
414
566
  async transition(action, apply) {
415
567
  const rule = REVIEW_TRANSITIONS[action];
416
568
  const persisted = await this.readPersisted();
@@ -448,6 +600,9 @@ __decorateClass([field({ type: "text" })], Expense.prototype, "category", 2);
448
600
  __decorateClass([field({ type: "text" })], Expense.prototype, "description", 2);
449
601
  __decorateClass([field({ type: "integer" })], Expense.prototype, "amount", 2);
450
602
  __decorateClass([field({ type: "text" })], Expense.prototype, "currency", 2);
603
+ __decorateClass([field({ type: "text" })], Expense.prototype, "financialBreakdown", 2);
604
+ __decorateClass([field({ type: "text" })], Expense.prototype, "paidByName", 2);
605
+ __decorateClass([foreignKey("Expense", { onDelete: "RESTRICT" })], Expense.prototype, "refundOfId", 2);
451
606
  __decorateClass([field({ type: "text" })], Expense.prototype, "incurredOn", 2);
452
607
  __decorateClass([crossPackageRef(PROFILE)], Expense.prototype, "recordedByProfileId", 2);
453
608
  __decorateClass([field({ type: "text" })], Expense.prototype, "paidBy", 2);
@@ -518,4 +673,4 @@ function sameTenant(a, b) {
518
673
  //#endregion
519
674
  export { identityConflict as a, pinNaturalKey as c, effectiveTenant as i, assertMinorUnits as n, normalizeSha256 as o, computeContentSha256 as r, normalizeTenantId as s, Expense as t };
520
675
 
521
- //# sourceMappingURL=Expense-C8HXtsJw.js.map
676
+ //# sourceMappingURL=Expense-CY5jw_80.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Expense-CY5jw_80.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 ContractCollection,\n ContractLineItemCollection,\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 commitment this actual draws down: a commerce `Contract`\n * (normally a `PurchaseOrder`). Must be in the same currency and tenant.\n */\n @crossPackageRef('@happyvertical/smrt-commerce:Contract')\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 ContractCollection.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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"}