@happyvertical/smrt-expenses 0.60.13 → 0.60.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.
Files changed (39) hide show
  1. package/AGENTS.md +15 -2
  2. package/README.md +8 -0
  3. package/agents/allocation-test-matrix.md +82 -0
  4. package/agents/reviewed-allocations.md +138 -0
  5. package/dist/allocation-internal.d.ts +6 -0
  6. package/dist/allocation-internal.d.ts.map +1 -0
  7. package/dist/chunks/{Expense-CimA4lxE.js → Expense-Deq19w2r.js} +28 -3
  8. package/dist/chunks/Expense-Deq19w2r.js.map +1 -0
  9. package/dist/chunks/{ExpenseReceiptCollection-CSBrNDzv.js → ExpenseReceiptCollection-D4Na93BF.js} +37 -3
  10. package/dist/chunks/ExpenseReceiptCollection-D4Na93BF.js.map +1 -0
  11. package/dist/chunks/{expense-actions-DiIhw0xO.js → expense-actions-DszuHacx.js} +2 -2
  12. package/dist/chunks/{expense-actions-DiIhw0xO.js.map → expense-actions-DszuHacx.js.map} +1 -1
  13. package/dist/collections/ExpenseCollection.d.ts +5 -5
  14. package/dist/collections/ExpenseCollection.d.ts.map +1 -1
  15. package/dist/expense-allocation.d.ts +133 -0
  16. package/dist/expense-allocation.d.ts.map +1 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1001 -21
  20. package/dist/index.js.map +1 -1
  21. package/dist/manifest.json +2359 -627
  22. package/dist/models/Expense.d.ts +2 -6
  23. package/dist/models/Expense.d.ts.map +1 -1
  24. package/dist/models/ExpenseAllocation.d.ts +117 -0
  25. package/dist/models/ExpenseAllocation.d.ts.map +1 -0
  26. package/dist/models/ExpenseReceipt.d.ts +7 -0
  27. package/dist/models/ExpenseReceipt.d.ts.map +1 -1
  28. package/dist/permissions.d.ts +5 -0
  29. package/dist/permissions.d.ts.map +1 -0
  30. package/dist/recipes.d.ts +3 -0
  31. package/dist/recipes.d.ts.map +1 -1
  32. package/dist/smrt-knowledge.json +746 -108
  33. package/dist/smrt-knowledge.okf.md +17 -3
  34. package/dist/types.d.ts +2 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js.map +1 -1
  37. package/package.json +9 -9
  38. package/dist/chunks/Expense-CimA4lxE.js.map +0 -1
  39. package/dist/chunks/ExpenseReceiptCollection-CSBrNDzv.js.map +0 -1
package/AGENTS.md CHANGED
@@ -119,8 +119,9 @@ they are (review is method-only; see below). Help lives in
119
119
  state: a recorded expense is `unreviewed`, i.e. awaiting review.
120
120
  - **Cost object is not dereferenced on save.** `getCostObject()` resolves it
121
121
  through the registry and returns `null` for a foreign-tenant row. One cost
122
- object per expense (columns, not a `SmrtPolymorphicAssociation` join) so a
123
- cost can never roll up into two jobs.
122
+ original cost object per expense. Reviewed relational allocation revisions
123
+ can attribute that source to multiple targets; target totals count portions
124
+ instead of the original attribution. See [reviewed allocations](agents/reviewed-allocations.md).
124
125
 
125
126
  ## Optional financial facts
126
127
 
@@ -230,3 +231,15 @@ methods. Unknown basis or unreconciled refund remains unknown and blocks new
230
231
  settlement. A settlement records evidence; it executes no bank/provider/journal
231
232
  operation. Preserve explicit chronological prefixes and same-day ordering.
232
233
  See README and `agents/supplier-payable-test-matrix.md` for the full contract.
234
+
235
+ ## Reviewed allocations (#4746)
236
+
237
+ See [reviewed allocations](agents/reviewed-allocations.md) and its
238
+ [behavior matrix](agents/allocation-test-matrix.md). Source accounting remains
239
+ singular; immutable portions own attribution and canonical billing identities.
240
+
241
+ Unbilled `withdrawAllocation` audits a full return to the original unsplit target,
242
+ requires source/all-old/restored-target authority, refuses any active billing
243
+ claim, and permits later explicit source correction without losing history.
244
+ Revision kind/predecessor + typed source snapshots preserve old portion facts
245
+ when the active head is null; receipt provenance stays frozen after withdrawal.
package/README.md CHANGED
@@ -394,3 +394,11 @@ source and current completion/event evidence cannot be replaced or deleted;
394
394
  managed source money/payer/context and linked refund facts cannot be edited
395
395
  around the position. Caller rollback includes both evidence and source revision.
396
396
  Expenses with no supplier-payable authority keep their existing behavior.
397
+
398
+ ## Reviewed expense allocations
399
+
400
+ A reviewed original may be attributed through immutable, fully reconciled portions
401
+ to multiple authorized same-tenant cost objects, without cloning its spend or
402
+ receipt evidence. See [the allocation contract](agents/reviewed-allocations.md)
403
+ for current actor/target authority, retries, corrections, reporting and canonical
404
+ draft-to-issued billing reservations. Existing unsplit expense APIs remain valid.
@@ -0,0 +1,82 @@
1
+ # Reviewed expense allocations (#4746)
2
+
3
+ Risk: **high** — public financial contract, tenant authority, concurrent monetary
4
+ reservations and issued invoice lineage. The integration coordinator owns full
5
+ release validation and independent review; worker evidence is narrow only.
6
+
7
+ All rows exercise real persistence. Commands: `pnpm --filter
8
+ @happyvertical/smrt-expenses exec vitest run allocation` (SQLite), then the
9
+ package PostgreSQL lane at integration. DuckDB is not a declared expense release
10
+ lane; its status must be explicitly assessed before release. No provider writes.
11
+ New feature: base-fail comparison is N/A (no allocation API exists on base).
12
+
13
+ | Behavior / reachable trigger | Positive | Negative / failure | Actor/context | Executor / transaction | Runtime/dialect | Contract edge / level |
14
+ | --- | --- | --- | --- | --- | --- | --- |
15
+ | Review split | 100 → 60/40, reload, one receipt graph | zero/fraction/unsafe/duplicate keys/unknown fields, wrong sum | tenant reviewer | source row lock + revision CAS, all children same tx | SQLite/PostgreSQL | strict public input; integration |
16
+ | Current authority | every old and new target authorized | foreign tenant, revoked actor/grants, denied second target | ambient authenticated actor × owner × active tenant | callback receives bound executor | SQLite/PostgreSQL | required host callback; integration |
17
+ | Retry/concurrency | identical key returns same UUIDs | changed payload conflict; stale source; concurrent competing revisions | same tenant reviewer | lock then CAS; caller retry whole tx | SQLite/PostgreSQL | idempotency contract; integration |
18
+ | Atomicity | caller commit persists all | throw after first portion; outer rollback leaves no head/children | authorized reviewer | caller-owned executor | SQLite/PostgreSQL | injected failure; integration |
19
+ | Immutable revisions/source | unbilled supersession preserves history | direct save/delete, source review/money/tenant rewrite | owner and foreign actor | revision/head atomic | SQLite/PostgreSQL | guarded models; integration |
20
+ | Totals and accounting | global 100, targets 60/40; commitment100 once | source never added to portions, no partial remainder | current tenant | same bound executor reads | SQLite/PostgreSQL | legacy unsplit APIs; integration |
21
+ | Refund/reimbursement/reporting | source cash/tax facts count100 once, separate refund follows existing model | no allocated source review/money rewrite; refund capacity unchanged | tenant owner | source locks/CAS preserved | SQLite/PostgreSQL | original source remains accounting authority; integration |
22
+ | Canonical billable identity | portion resolves target/source/fingerprint/currency; reserve once | foreign/missing line, wrong tenant/target, reused portion | current tenant billing actor | source+invoice lock/CAS; reservation atomic | SQLite/PostgreSQL | public invoice/line contract; integration |
23
+ | Billed lineage | draft release then supersede; issued reservation retained | issued release/rewrite and supersede blocked | current tenant billing actor | bound transaction | SQLite/PostgreSQL | immutable issued snapshot; integration |
24
+ | Generated surface / migration | new tables/FKs/indexes; old rows unsplit | direct generated CRUD absent | no actor transport surface | real generated schema | SQLite/PostgreSQL | closed API/MCP/CLI; integration |
25
+
26
+ UI/browser N/A: no new user interface. External provider failure N/A: allocation
27
+ records local evidence and performs no external execution.
28
+
29
+ ## Invoice binding extension (captured before its implementation)
30
+
31
+ Commerce owns only a nullable guarded `InvoiceLineItem.sourceReservationId`
32
+ pointer. The expense reservation remains the single capacity authority. A host
33
+ may reserve against its own draft, then bind the same reservation UUID to one
34
+ commerce line. Trusted package registration allowlists the `billable:` qualified
35
+ source type; client strings never dispatch arbitrary model methods.
36
+
37
+ | Behavior / trigger | Positive | Negative / failure | Actor/context | Executor / transaction | Runtime/dialect | Contract edge / level |
38
+ | --- | --- | --- | --- | --- | --- | --- |
39
+ | Host-draft bridge | reserve once, later bind same UUID | second capacity claim, two concurrent bind targets | ambient reviewer + host draft/invoice authority | source lock/CAS, unique `(relatedReservationId,kind)`, same executor | SQLite/PostgreSQL | public compose API; integration |
40
+ | Commerce pointer guards | authorized bind then issue | direct pointer assignment, old-pointer clearing with source namespace change, clone/duplicate, model and collection delete, cascading invoice deletion | active tenant | document lock, owning reservation checks | SQLite/PostgreSQL | existing public mutations; integration |
41
+ | Missing capability | registered protected type resolves | unknown/missing handler; unbound protected line at issue | active tenant | same issue transaction | SQLite/PostgreSQL | fail-closed protocol; integration |
42
+ | Withdrawal | release + pointer removal then supersede | issued release, rollback after release before unbind | host current authority | one source/invoice transaction, immutable event history | SQLite/PostgreSQL | no second ledger; integration |
43
+
44
+ Initial SQLite evidence: allocation-fourth.log (6/6); extended billing first run
45
+ allocation-billing-first.log (8/8). These are worker evidence, not release gates.
46
+
47
+ ## Dialect assessment evidence
48
+
49
+ Existing package AGENTS validation defines SQLite and migrated PostgreSQL lanes;
50
+ its supplier-payable matrix explicitly uses Node26 SQLite+PG. No explicit
51
+ DuckDB compatibility promise was found in the owning README/AGENTS. A real
52
+ DuckDB graph bootstrap was attempted: `allocation-dialects.log` records failure
53
+ before allocation behavior on the existing
54
+ `_smrt_billing_accounts.customer_id ON UPDATE CASCADE` relationship. Core
55
+ `schema/foreign-key-planner.ts` deliberately refuses that unsupported DuckDB
56
+ contract and directs callers to PostgreSQL/SQLite. This diagnostic is preserved;
57
+ no FK/schema weakening or alternate toolchain was used. It does not constitute
58
+ DuckDB execution evidence for allocations, and release review must reconcile the
59
+ supported-dialect contract rather than claim universal compatibility.
60
+
61
+ | Additional bounded contract | Positive | Negative/failure | Context/actor | Executor | Dialect/runtime | Validation |
62
+ | --- | --- | --- | --- | --- | --- | --- |
63
+ | Source privacy by action | full review source grant; bounded billing context | denied original source even when destinations allowed | real ambient review actor; callback action review/reserve/bind/release | caller bound | SQLite/PG Node26 | allocation shared suite |
64
+ | Read authority | actual expenses.read + host target grant | read-only principal cannot reserve/review; source privacy denial does not hide own bounded portion | read actor × own target | same scoped DB | SQLite/PG Node26 | allocation shared suite |
65
+ | Exact economic attestation | known zero-tax gross basis60, company category only | unknown/recoverable/refunded basis stays null; refunded new bill denied | target-authorized reader/biller | source lock/CAS for mutation | SQLite/PG Node26 | allocation shared suite |
66
+
67
+ ## Explicit unbilled withdrawal (before implementation)
68
+
69
+ High-risk financial/tenancy/contract continuation. Withdraw is not a billed
70
+ reversal and never releases claims implicitly. Public input includes exact source
71
+ revision, expected active allocation revision, request key and audit evidence.
72
+
73
+ | Behavior / trigger | Positive | Negative/failure | Actor/context | Executor/atomicity | Dialect | Evidence |
74
+ | --- | --- | --- | --- | --- | --- | --- |
75
+ | Unbilled withdrawal | released draft claim → audited withdrawal → reopen/amount correction or duplicate rejection | active reserved/bound/issued capacity refuses; no auto-release | source-private reviewer + ALL old target grants + mandatory restored-unsplit destination grant | source lock/CAS, same caller executor, immutable event + null head in one transaction | SQLite/PG shared suite | narrow allocation command |
76
+ | Retry/concurrency | identical replay reauthorizes and returns same audit UUID | changed payload/key collision, stale active/source revisions, competing withdrawals | current actor/source/old targets/restored destination | unique request, unique predecessor successor; source CAS | SQLite/PG | shared feature proof |
77
+ | Atomic reporting | source100 returns once to unsplit target; commitments/global totals unchanged | injected failure after audit insert leaves old head/targets, caller rollback restores all | same tenant | no transient committed source+portion cost | SQLite/PG | shared real persistence |
78
+ | History after correction | next review links latest withdrawal deterministically; old portion facts remain immutable across source edits/reject/reopen | foreign/revoked old/restored target denies, ambiguous chain fails closed | bounded old-target reader vs full source editor | immutable relational predecessor chain; typed revision source snapshot | SQLite/PG | shared lineage/reload proof |
79
+
80
+ New API absent on prior source is feature baseline N/A for existing API
81
+ regression, but executable absent-command/blocked-correction evidence is captured.
82
+ No provider writes, browser/UI N/A; root owns PG/fullvalidation/review.
@@ -0,0 +1,138 @@
1
+ # Reviewed expense attribution
2
+
3
+ `ExpenseCollection.reviewAllocation(input, authority)` reviews a **full** split
4
+ of a positive, reviewed original. The source keeps its original amount, payer,
5
+ commitment, reimbursement, refund capacity, supplier tax facts and receipt graph.
6
+ Children are attribution, never additional expenses or another cash authority.
7
+
8
+ The input names `sourceExpenseId`, the loaded `updated_at` as
9
+ `expectedSourceRevision`, `requestId`, `reviewEvidence`, and 1–1000 portions.
10
+ Each portion has an exact positive safe-integer `amountMinor`, unique
11
+ `portionKey`, registered same-tenant `costObjectType`/`costObjectId`, and explicit
12
+ `costCodeRef`. Portions must sum exactly to source gross money in source currency.
13
+ No remainder, tax allocation, customer markup or tax policy is inferred.
14
+
15
+ Ambient `expenses.recordReview` authorizes a person in the active tenant. The
16
+ mandatory host `authorizeSource(principal,sourceExpenseId,executor,context)`
17
+ callback checks original source privacy for full reviews. Its context names the
18
+ explicit review/reserve/bind/release action; billing contexts include only the
19
+ bounded authorized portion so target finance users need no unrelated original
20
+ project or full receipt disclosure. The mandatory
21
+ `authorizeTarget(principal,target,executor)` callback must enforce
22
+ current project grants for **every old and proposed target**. The supplied
23
+ executor is the command's caller-affine transaction. Callback denial rolls back
24
+ all children/head/revision claims. Never perform provider writes in the callback.
25
+
26
+ A source lock and revision compare-and-swap serialize allocations and billing
27
+ reservations. Same request/payload returns the same immutable IDs after current
28
+ authority checks; changed payload conflicts. On serialization/revision failure,
29
+ retry the whole caller transaction with fresh instances. Unreserved corrections
30
+ append a revision linked by `previousRevisionId`; prior children remain readable.
31
+ While an allocation is active, source review cannot be reopened/rejected.
32
+ Use explicit audited unbilled withdrawal before source correction; immutable
33
+ allocation provenance prevents physical source deletion.
34
+
35
+ `totalsByCurrency()` counts each source once. Target filters project the current
36
+ portions instead of counting the source's original attribution. Commitment
37
+ positions, supplier-tax reports and reimbursement/refund positions retain the
38
+ original accounting source exactly once. `forCostObject()` remains an original
39
+ source/evidence query; use target-filtered totals for allocated money. Do not sum
40
+ source amounts obtained as evidence to calculate target costs.
41
+
42
+ `resolveExpenseAllocationPortion(db,portionId,authority)` returns only that
43
+ portion's immutable source/revision/fingerprint/target/code/amount/currency and
44
+ current `active` flag after project authority; it does not disclose receipt URLs
45
+ or other projects. Receipt access still needs the original source privacy check.
46
+ `getExpenseAllocationPortionPosition` adds current reservation UUID, host draft
47
+ references and bound invoice references/status for that same target portion. It
48
+ is advisory; reservation mutations always recheck capacity atomically. Historical
49
+ or issued claims never become available implicitly.
50
+
51
+ ## Billing
52
+
53
+ `reserveExpenseAllocationPortion` reserves a whole portion once against a host
54
+ `draftType`/`draftId`, requiring `authority.authorizeDraft`; it can alternatively
55
+ reserve and bind directly to an existing invoice/line. Customer price remains
56
+ independent of source capacity. `bindExpenseAllocationPortion` assigns that same
57
+ reservation UUID to one same-tenant, same-currency commerce draft line after
58
+ mandatory `authorizeInvoice` current-grant/project checks. The line must use
59
+ `EXPENSE_ALLOCATION_INVOICE_SOURCE` and `sourceId = portionId`.
60
+
61
+ Commerce stores only the reservation pointer, with trusted source-handler checks
62
+ on line mutations and invoice issue. Missing handlers, unbound protected lines,
63
+ changed/cleared bound identities, copied pointers and duplicate line claims fail.
64
+ All reservation and binding events are immutable relational rows; source locking,
65
+ CAS and unique event keys prevent concurrent double assignment. There is no
66
+ commerce-side source availability balance.
67
+
68
+ `releaseExpenseAllocationPortion` records withdrawal and removes a bound draft
69
+ pointer in the same transaction. It retains claim/binding history. Active claims
70
+ block allocation supersession. Once issued, a claim remains consumed: ordinary
71
+ release/reallocation is refused and corrections require an explicit credit or
72
+ reversal policy; the API never rewrites issued invoices or infers such policy.
73
+
74
+ Migrate schemas before use, including the three new allocation tables, nullable
75
+ Expense head and nullable commerce line pointer. Existing unsplit rows retain
76
+ NULL heads/pointers and unchanged accounting semantics. See the
77
+ [behavior matrix](allocation-test-matrix.md) for release evidence requirements.
78
+
79
+ Bounded readers use the explicitly published `expenses.read` catalog permission
80
+ (registered through public `registerPermissionDefinitions`; `get`/`list`
81
+ normalize to `.read`) and the host's mandatory current target-cost
82
+ visibility callback. They never require review authority or original-source
83
+ privacy access. Mutations retain `expenses.recordReview`. Consumers must grant
84
+ these real permissions through their role configuration; never fabricate a
85
+ permission snapshot to call the library.
86
+
87
+ Bounded identities include source date, payer **category** (no person/name),
88
+ reimbursement eligibility, nullable supplier-tax presence, refund presence and
89
+ nullable exact `costBasisMinor`. Basis equals portion gross only when the
90
+ original's complete known basis equals gross and no linked refunds exist. It is
91
+ otherwise unknown; tax is never apportioned implicitly. New reserve/bind commands
92
+ refuse a source with any linked refunds pending explicit reversal policy.
93
+ Reimbursement eligibility freezes with allocation provenance. Existing issued
94
+ reservations remain consumed and immutable when later refund evidence arrives.
95
+
96
+ The source fingerprint includes a private random per-source nonce retained only
97
+ in full source-authorized revision evidence. Bounded readers receive its digest,
98
+ not the nonce, preventing offline guesses of another project's source total.
99
+ Unbilled supersession reuses the nonce and retains the same source fingerprint.
100
+
101
+ ## Explicit unbilled withdrawal before source correction
102
+
103
+ `ExpenseCollection.withdrawAllocation` (root `withdrawExpenseAllocation`) accepts
104
+ `sourceExpenseId`, `expectedActiveRevisionId`, `expectedSourceRevision`,
105
+ `requestId` and `reviewEvidence`. It appends an immutable revision with
106
+ `kind: 'withdrawal'` and clears the active head atomically on the caller executor.
107
+ The result is `{revision, withdrawnRevisionId}`; reload the original source for
108
+ its current state/revision. No portion row is changed or deleted.
109
+
110
+ Withdrawal requires current source privacy/edit authority with action
111
+ `withdrawAllocation`, authority for **every old portion target**, and a separate
112
+ mandatory `authorizeUnsplitTarget(principal,{sourceExpenseId,costObjectType,
113
+ costObjectId},executor)` callback for the restored original destination. The
114
+ latter has no invented cost code. Assigned destinations must exist in the active
115
+ tenant; an explicitly unassigned original has both destination strings empty and
116
+ still requires the callback. Replays reauthorize all of these paths; changed
117
+ payload, stale source/head or a conflicting request is refused.
118
+
119
+ Any active reservation, bound claim or issued claim refuses withdrawal. First
120
+ abandon/release an authorized unissued draft explicitly; withdrawal never
121
+ silently releases capacity or rewrites issued invoices. **This is not billed
122
+ reversal support.** Existing settlement/refund/reimbursement guards also remain.
123
+
124
+ Global and commitment totals still count the original source once. Its monetary
125
+ attribution switches atomically back to the original unsplit destination. Source
126
+ review status is unchanged: no financial approval is implied. The caller may
127
+ then explicitly reopen and correct the original amount/date/payer or reject a
128
+ duplicate using existing guarded source methods. Corrected unreviewed money stays
129
+ excluded until separately reviewed. Original receipt evidence remains immutable
130
+ once allocation history exists, including after withdrawal.
131
+
132
+ The immutable predecessor chain has one successor per revision. Its unique leaf
133
+ retains the latest withdrawal independently of a null active head; the next
134
+ review links to that withdrawal deterministically. Typed source snapshot facts
135
+ on each revision preserve historical portion date/payer/basis attestation across
136
+ later source correction. Inactive historical portions remain bounded-readable
137
+ with `active:false` when the source is reopened/rejected; they are never available
138
+ for fresh billing. Active portions still require a usable reviewed source.
@@ -0,0 +1,6 @@
1
+ /** Package-private command capabilities; never exported from the package root. */
2
+ import type { SmrtObject } from '@happyvertical/smrt-core';
3
+ export declare const allocationWrites: WeakSet<SmrtObject>;
4
+ export declare const allocationHeads: WeakMap<SmrtObject, string | null>;
5
+ export declare function insertAllocation<T extends SmrtObject>(row: T): Promise<T>;
6
+ //# sourceMappingURL=allocation-internal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"allocation-internal.d.ts","sourceRoot":"","sources":["../src/allocation-internal.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAC3D,eAAO,MAAM,gBAAgB,qBAA4B,CAAC;AAC1D,eAAO,MAAM,eAAe,oCAA2C,CAAC;AACxE,wBAAsB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EACzD,GAAG,EAAE,CAAC,GACL,OAAO,CAAC,CAAC,CAAC,CASZ"}
@@ -1,8 +1,22 @@
1
1
  import { ExpenseError } from "../types.js";
2
2
  import { ObjectRegistry, SmrtObject, crossPackageRef, field, foreignKey, isPostgresDatabase, isUniqueViolationError, smrt } from "@happyvertical/smrt-core";
3
3
  import { TenantScoped, getTenantId, tenantId } from "@happyvertical/smrt-tenancy";
4
- import { ContractLineItemCollection, PurchaseContractCollection, VendorCollection } from "@happyvertical/smrt-commerce";
5
4
  import { createHash, randomUUID } from "node:crypto";
5
+ import { ContractLineItemCollection, PurchaseContractCollection, VendorCollection } from "@happyvertical/smrt-commerce";
6
+ //#region src/allocation-internal.ts
7
+ var allocationWrites = /* @__PURE__ */ new WeakSet();
8
+ var allocationHeads = /* @__PURE__ */ new WeakMap();
9
+ async function insertAllocation(row) {
10
+ await row.initialize();
11
+ allocationWrites.add(row);
12
+ try {
13
+ await row.save();
14
+ return row;
15
+ } finally {
16
+ allocationWrites.delete(row);
17
+ }
18
+ }
19
+ //#endregion
6
20
  //#region src/validation.ts
7
21
  var CATEGORY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
22
  var ISO_CURRENCY = /^[A-Z]{3}$/;
@@ -643,8 +657,10 @@ var Expense = class extends SmrtObject {
643
657
  reimbursedAmount = null;
644
658
  reimbursedAt = null;
645
659
  reimbursementReference = "";
660
+ activeAllocationRevisionId = null;
646
661
  constructor(options = {}) {
647
662
  super(options);
663
+ if (options.activeAllocationRevisionId !== void 0) this.activeAllocationRevisionId = options.activeAllocationRevisionId;
648
664
  if (options.tenantId !== void 0) this.tenantId = options.tenantId;
649
665
  if (options.costObjectType !== void 0) this.costObjectType = options.costObjectType;
650
666
  if (options.costObjectId !== void 0) this.costObjectId = options.costObjectId;
@@ -867,6 +883,10 @@ var Expense = class extends SmrtObject {
867
883
  const persisted = await this.readPersisted();
868
884
  const inserting = await pinNaturalKey("Expense", this, persisted);
869
885
  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.`);
886
+ const storedAllocation = asNullableString(persisted?.active_allocation_revision_id);
887
+ if (storedAllocation && persisted && Boolean(persisted.reimbursable) !== this.reimbursable) throw new ExpenseError("EXPENSE_INVALID", "Allocated source reimbursement eligibility is frozen.");
888
+ if (storedAllocation !== this.activeAllocationRevisionId && !(allocationHeads.has(this) && allocationHeads.get(this) === this.activeAllocationRevisionId)) throw new ExpenseError("EXPENSE_INVALID", "Allocation head is command-owned.");
889
+ if (storedAllocation && persisted && (String(persisted.review_status) !== this.reviewStatus || String(persisted.duplicate_of_id ?? "") !== String(this.duplicateOfId ?? ""))) throw new ExpenseError("EXPENSE_INVALID", "Allocated source review cannot change; supersede its allocation instead.");
870
890
  await this.assertSupplierPayable(persisted);
871
891
  this.assertReviewFields(persisted);
872
892
  this.assertReviewedLock(persisted);
@@ -994,6 +1014,7 @@ var Expense = class extends SmrtObject {
994
1014
  await this.lockRefundRows();
995
1015
  const row = await this.readPersisted();
996
1016
  if (row && String(row.tenant_id ?? "") !== String(effectiveTenant(this.tenantId) ?? "")) payableInvalid("owned source is required.");
1017
+ if (row?.active_allocation_revision_id) throw new ExpenseError("EXPENSE_INVALID", "Allocated source evidence cannot be deleted.");
997
1018
  if (row?.supplier_payable_json) payableInvalid("supplier authority cannot be deleted.");
998
1019
  const parentId = asNullableString(row?.refund_of_id);
999
1020
  if (parentId) {
@@ -1312,6 +1333,10 @@ __decorateClass([field({
1312
1333
  type: "text",
1313
1334
  readonly: true
1314
1335
  })], Expense.prototype, "reimbursementReference", 2);
1336
+ __decorateClass([foreignKey("ExpenseAllocationRevision", {
1337
+ nullable: true,
1338
+ onDelete: "RESTRICT"
1339
+ }), field({ readonly: true })], Expense.prototype, "activeAllocationRevisionId", 2);
1315
1340
  Expense = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
1316
1341
  tableName: "expenses",
1317
1342
  api: { include: [
@@ -1359,6 +1384,6 @@ function sameTenant(a, b) {
1359
1384
  return (a ?? null) === (b ?? null);
1360
1385
  }
1361
1386
  //#endregion
1362
- 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 };
1387
+ export { effectiveTenant as a, normalizeSha256 as c, allocationHeads as d, allocationWrites as f, computeContentSha256 as i, normalizeTenantId as l, assertMinorUnits as n, identityConflict as o, insertAllocation as p, assertQualifiedClassName as r, instantMs as s, Expense as t, pinNaturalKey as u };
1363
1388
 
1364
- //# sourceMappingURL=Expense-CimA4lxE.js.map
1389
+ //# sourceMappingURL=Expense-Deq19w2r.js.map