@happyvertical/smrt-expenses 0.57.3 → 0.57.4

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 (37) hide show
  1. package/AGENTS.md +42 -3
  2. package/README.md +129 -9
  3. package/agents/financial-facts-test-matrix.md +57 -0
  4. package/agents/supplier-payable-test-matrix.md +24 -0
  5. package/dist/actions/expense-actions.d.ts +2 -0
  6. package/dist/actions/expense-actions.d.ts.map +1 -1
  7. package/dist/chunks/Expense-C_WC5VLF.js +1364 -0
  8. package/dist/chunks/Expense-C_WC5VLF.js.map +1 -0
  9. package/dist/chunks/{ExpenseReceiptCollection-y1SlmnBS.js → ExpenseReceiptCollection-BO5yvJLm.js} +2 -2
  10. package/dist/chunks/{ExpenseReceiptCollection-y1SlmnBS.js.map → ExpenseReceiptCollection-BO5yvJLm.js.map} +1 -1
  11. package/dist/chunks/{expense-actions-Ce5CBaBE.js → expense-actions-Bxzeb4s2.js} +8 -3
  12. package/dist/chunks/expense-actions-Bxzeb4s2.js.map +1 -0
  13. package/dist/collections/ExpenseCollection.d.ts +17 -1
  14. package/dist/collections/ExpenseCollection.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +43 -7
  18. package/dist/index.js.map +1 -1
  19. package/dist/manifest.json +304 -7
  20. package/dist/models/Expense.d.ts +35 -1
  21. package/dist/models/Expense.d.ts.map +1 -1
  22. package/dist/recipes.d.ts +9 -0
  23. package/dist/recipes.d.ts.map +1 -1
  24. package/dist/refund-position.d.ts +11 -0
  25. package/dist/refund-position.d.ts.map +1 -0
  26. package/dist/reimbursement-position.d.ts +20 -0
  27. package/dist/reimbursement-position.d.ts.map +1 -0
  28. package/dist/smrt-knowledge.json +267 -10
  29. package/dist/supplier-payable.d.ts +128 -0
  30. package/dist/supplier-payable.d.ts.map +1 -0
  31. package/dist/types.d.ts +46 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/package.json +9 -9
  35. package/dist/chunks/Expense-7afLcUTn.js +0 -681
  36. package/dist/chunks/Expense-7afLcUTn.js.map +0 -1
  37. package/dist/chunks/expense-actions-Ce5CBaBE.js.map +0 -1
@@ -0,0 +1,1364 @@
1
+ import { ExpenseError } from "../types.js";
2
+ import { ObjectRegistry, SmrtObject, crossPackageRef, field, foreignKey, isPostgresDatabase, isUniqueViolationError, smrt } from "@happyvertical/smrt-core";
3
+ import { TenantScoped, getTenantId, tenantId } from "@happyvertical/smrt-tenancy";
4
+ import { ContractLineItemCollection, PurchaseContractCollection, VendorCollection } from "@happyvertical/smrt-commerce";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ //#region src/validation.ts
7
+ var CATEGORY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ var ISO_CURRENCY = /^[A-Z]{3}$/;
9
+ var ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
10
+ var QUALIFIED_CLASS = /^[^\s:]+:[A-Za-z_$][\w$]*$/;
11
+ var SHA256_HEX = /^[0-9a-f]{64}$/;
12
+ function assertMinorUnits(model, label, fieldName, value, code = "EXPENSE_INVALID") {
13
+ if (!Number.isSafeInteger(value) || value < 0) throw new ExpenseError(code, `${model} ${label}: ${fieldName} must be a non-negative integer number of minor units (cents) \u2014 got ${value}. $19.99 is 1999.`);
14
+ }
15
+ function assertIsoCurrency(model, label, value) {
16
+ if (!ISO_CURRENCY.test(value)) throw new ExpenseError("EXPENSE_INVALID", `${model} ${label}: currency must be a three-letter ISO 4217 code (for example 'USD'), got '${value}'.`);
17
+ }
18
+ function assertIsoDate(model, label, fieldName, value) {
19
+ const match = ISO_DATE.exec(value);
20
+ if (!(match !== null && (() => {
21
+ const [year, month, day] = match.slice(1).map(Number);
22
+ const date = new Date(Date.UTC(year, month - 1, day));
23
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
24
+ })())) throw new ExpenseError("EXPENSE_INVALID", `${model} ${label}: ${fieldName} must be a calendar date written YYYY-MM-DD, got '${value}'.`);
25
+ }
26
+ function assertQualifiedClassName(model, label, value) {
27
+ if (!QUALIFIED_CLASS.test(value)) throw new ExpenseError("EXPENSE_INVALID", `${model} ${label}: costObjectType must be a qualified class name such as '@happyvertical/smrt-projects:Project', got '${value}'.`);
28
+ }
29
+ function normalizeSha256(model, label, value) {
30
+ const digest = String(value ?? "").trim().toLowerCase();
31
+ if (!SHA256_HEX.test(digest)) throw new ExpenseError("EXPENSE_RECEIPT_INVALID", `${model} ${label}: contentSha256 must be a 64-character hex SHA-256 digest of the file bytes.`);
32
+ return digest;
33
+ }
34
+ function computeContentSha256(data) {
35
+ return createHash("sha256").update(data).digest("hex");
36
+ }
37
+ function instantMs(value) {
38
+ if (value === null || value === void 0 || value === "") return null;
39
+ const ms = value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
40
+ return Number.isNaN(ms) ? null : ms;
41
+ }
42
+ async function pinNaturalKey(model, object, persisted) {
43
+ await object.getSlug();
44
+ if (!object.isPersisted) {
45
+ if (persisted) throw new ExpenseError("EXPENSE_IDENTITY_CONFLICT", `${model} ${object.id} already exists; load it and save the loaded instance instead of creating one with its id.`);
46
+ object.requireInsertOnSave();
47
+ return true;
48
+ }
49
+ if (persisted && (String(persisted.slug ?? "") !== String(object.slug ?? "") || String(persisted.context ?? "") !== String(object.context ?? ""))) throw new ExpenseError("EXPENSE_IDENTITY_CONFLICT", `${model} ${object.id}: slug and context cannot change; they would point the save at a different row.`);
50
+ return false;
51
+ }
52
+ function identityConflict(model, object, cause) {
53
+ return new ExpenseError("EXPENSE_IDENTITY_CONFLICT", `${model} ${object.id}: another row already uses slug '${object.slug}' in this tenant; a new ${model} cannot take it over.`, { cause });
54
+ }
55
+ function effectiveTenant(tenantId) {
56
+ return tenantId || getTenantId() || null;
57
+ }
58
+ function normalizeTenantId(tenantId) {
59
+ return tenantId || null;
60
+ }
61
+ //#endregion
62
+ //#region src/financial-facts.ts
63
+ function invalid$2(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$2("expected an object.");
68
+ const object = value;
69
+ if (Object.keys(object).length !== keys.length || keys.some((key) => !Object.hasOwn(object, key))) return invalid$2("missing or unknown fields.");
70
+ return object;
71
+ }
72
+ function minor$1(value) {
73
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) return invalid$2("amounts must be safe integer minor units.");
74
+ return BigInt(value);
75
+ }
76
+ function safe$1(value) {
77
+ const result = Number(value);
78
+ if (!Number.isSafeInteger(result)) return invalid$2("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$2("currency must match the expense.");
89
+ assertIsoCurrency("Expense", "<financial-breakdown>", facts.currency);
90
+ const total = minor$1(facts.total);
91
+ const subtotal = minor$1(facts.subtotal);
92
+ if (total !== minor$1(amount)) return invalid$2("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$2("subtotal sign must match total.");
95
+ if (!Array.isArray(facts.taxes)) return invalid$2("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$2("tax codes must be unique lower-case kebab-case identifiers.");
106
+ codes.add(component.code);
107
+ const charged = minor$1(component.amount);
108
+ const recovered = minor$1(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$2("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$2("subtotal plus taxes must equal total.");
116
+ return {
117
+ currency,
118
+ subtotal: safe$1(subtotal),
119
+ taxTotal: safe$1(tax),
120
+ recoverableTax: safe$1(recoverable),
121
+ nonRecoverableTax: safe$1(tax - recoverable),
122
+ total: safe$1(total),
123
+ costBasis: safe$1(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$2("storage must be JSON text.");
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(stored);
132
+ } catch {
133
+ return invalid$2("invalid JSON.");
134
+ }
135
+ expenseCostBasis(parsed, amount, currency);
136
+ return parsed;
137
+ }
138
+ //#endregion
139
+ //#region src/refund-position.ts
140
+ function invalid$1(message) {
141
+ throw new ExpenseError("EXPENSE_INVALID", `Expense refund: ${message}`);
142
+ }
143
+ function safe(value) {
144
+ const result = Number(value);
145
+ if (!Number.isSafeInteger(result)) invalid$1("unsafe calculated amount.");
146
+ return result;
147
+ }
148
+ function calculateRefundPosition(source, refunds) {
149
+ if (!Number.isSafeInteger(source.amount) || source.amount <= 0) invalid$1("source must remain positive.");
150
+ const sourceFacts = source.getFinancialBreakdown();
151
+ let refunded = 0n;
152
+ let subtotal = 0n;
153
+ let complete = sourceFacts !== null;
154
+ const components = new Map(sourceFacts?.taxes.map((tax) => [tax.code, {
155
+ charged: 0n,
156
+ recovered: 0n
157
+ }]));
158
+ const ids = /* @__PURE__ */ new Set();
159
+ for (const refund of refunds) {
160
+ if (ids.has(refund.id)) invalid$1("duplicate refund identity.");
161
+ ids.add(refund.id);
162
+ if (!Number.isSafeInteger(refund.amount) || refund.amount >= 0 || refund.currency !== source.currency) invalid$1("linked refunds must be negative and share source currency.");
163
+ refunded -= BigInt(refund.amount);
164
+ const facts = refund.getFinancialBreakdown();
165
+ if (!facts) {
166
+ complete = false;
167
+ continue;
168
+ }
169
+ expenseCostBasis(facts, refund.amount, source.currency);
170
+ if (!sourceFacts) continue;
171
+ subtotal -= BigInt(facts.subtotal);
172
+ for (const tax of facts.taxes) {
173
+ const sum = components.get(tax.code);
174
+ if (!sum) invalid$1(`tax component ${tax.code} does not identify a source component.`);
175
+ sum.charged -= BigInt(tax.amount);
176
+ sum.recovered -= BigInt(tax.recoverableAmount);
177
+ }
178
+ }
179
+ if (refunded > BigInt(source.amount)) invalid$1("cumulative cash exceeds source gross.");
180
+ if (sourceFacts) {
181
+ expenseCostBasis(sourceFacts, source.amount, source.currency);
182
+ if (subtotal > BigInt(sourceFacts.subtotal)) invalid$1("subtotal reversal exceeds source subtotal.");
183
+ for (const tax of sourceFacts.taxes) {
184
+ const sum = components.get(tax.code);
185
+ if (!sum) invalid$1("missing source component.");
186
+ if (sum.charged > BigInt(tax.amount) || sum.recovered > BigInt(tax.recoverableAmount) || sum.charged - sum.recovered > BigInt(tax.amount - tax.recoverableAmount)) invalid$1(`tax component ${tax.code} reversal exceeds its source component.`);
187
+ }
188
+ }
189
+ const position = {
190
+ sourceId: source.id,
191
+ currency: source.currency,
192
+ sourceAmount: source.amount,
193
+ refundedAmount: safe(refunded),
194
+ remainingAmount: safe(BigInt(source.amount) - refunded),
195
+ refundIds: [...ids].sort(),
196
+ financialPosition: null
197
+ };
198
+ if (complete && sourceFacts) {
199
+ const reversal = {
200
+ currency: source.currency,
201
+ subtotal: safe(-subtotal),
202
+ taxes: sourceFacts.taxes.map((tax) => {
203
+ const sum = components.get(tax.code);
204
+ if (!sum) invalid$1("missing source component.");
205
+ return {
206
+ code: tax.code,
207
+ amount: safe(-sum.charged),
208
+ recoverableAmount: safe(-sum.recovered)
209
+ };
210
+ }),
211
+ total: safe(-refunded)
212
+ };
213
+ const remaining = {
214
+ currency: source.currency,
215
+ subtotal: safe(BigInt(sourceFacts.subtotal) - subtotal),
216
+ taxes: sourceFacts.taxes.map((tax, index) => ({
217
+ code: tax.code,
218
+ amount: tax.amount + reversal.taxes[index].amount,
219
+ recoverableAmount: tax.recoverableAmount + reversal.taxes[index].recoverableAmount
220
+ })),
221
+ total: position.remainingAmount
222
+ };
223
+ expenseCostBasis(remaining, remaining.total, source.currency);
224
+ position.financialPosition = {
225
+ source: sourceFacts,
226
+ refunded: reversal,
227
+ remaining
228
+ };
229
+ }
230
+ return position;
231
+ }
232
+ //#endregion
233
+ //#region src/reimbursement-position.ts
234
+ function invalid(message) {
235
+ throw new ExpenseError("EXPENSE_INVALID", `Expense reimbursement: ${message}`);
236
+ }
237
+ function parseRefundRecipient(stored) {
238
+ if (stored === null || stored === "") return null;
239
+ let value;
240
+ try {
241
+ value = JSON.parse(stored);
242
+ } catch {
243
+ return invalid("invalid recipient JSON.");
244
+ }
245
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid("recipient must be an object.");
246
+ const object = value;
247
+ if (object.kind === "company" && Object.keys(object).length === 1) return { kind: "company" };
248
+ if (object.kind === "person" && Object.keys(object).length === 2 && typeof object.profileId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(object.profileId)) return {
249
+ kind: "person",
250
+ profileId: object.profileId
251
+ };
252
+ if (object.kind === "third-party" && Object.keys(object).length === 2 && typeof object.name === "string" && object.name.trim().length > 0 && object.name.length <= 200) return {
253
+ kind: "third-party",
254
+ name: object.name
255
+ };
256
+ return invalid("recipient must explicitly identify company, person profile or a third-party name (1–200 characters).");
257
+ }
258
+ function calculateReimbursementPosition(source, refunds) {
259
+ if (!Number.isSafeInteger(source.amount) || source.amount <= 0 || source.paidBy !== "person" || !source.paidByProfileId || !source.reimbursable) invalid("requires a positive person-paid reimbursable source.");
260
+ let person = 0n;
261
+ let company = 0n;
262
+ let reason = null;
263
+ for (const refund of refunds) {
264
+ const recipient = refund.getRefundRecipient();
265
+ if (!recipient) reason ??= "unknown-refund-recipient";
266
+ else if (recipient.kind === "company") company -= BigInt(refund.amount);
267
+ else if (recipient.kind === "third-party") reason ??= "unknown-third-party-recipient";
268
+ else if (recipient.profileId === source.paidByProfileId) person -= BigInt(refund.amount);
269
+ else reason ??= "recipient-is-another-person";
270
+ }
271
+ if (source.reimbursedAmount !== null && (!source.reimbursedAt || !Number.isSafeInteger(source.reimbursedAmount) || source.reimbursedAmount <= 0 || source.reimbursedAmount > source.amount)) invalid("retained paid amount is inconsistent with original gross claim.");
272
+ const paid = source.reimbursedAt ? source.reimbursedAmount : 0;
273
+ if (paid === null) reason = "legacy-reimbursement-amount";
274
+ const net = reason || paid === null ? null : BigInt(source.amount) - person - BigInt(paid);
275
+ const safe = (amount) => {
276
+ const value = Number(amount);
277
+ if (!Number.isSafeInteger(value)) invalid("unsafe cash position.");
278
+ return value;
279
+ };
280
+ return {
281
+ sourceId: source.id,
282
+ currency: source.currency,
283
+ sourceAmount: source.amount,
284
+ personRefundAmount: reason ? null : safe(person),
285
+ companyRefundAmount: reason ? null : safe(company),
286
+ reimbursedAmount: paid,
287
+ remainingAmount: net === null ? null : safe(net > 0n ? net : 0n),
288
+ owedToCompany: net === null ? null : safe(net < 0n ? -net : 0n),
289
+ unknownReason: reason
290
+ };
291
+ }
292
+ //#endregion
293
+ //#region src/supplier-payable.ts
294
+ function payableInvalid(message) {
295
+ throw new ExpenseError("EXPENSE_INVALID", `Supplier payable: ${message}`);
296
+ }
297
+ function text(value, label) {
298
+ if (typeof value !== "string" || !value.trim() || value.length > 2e3) payableInvalid(`${label} must be explicit bounded evidence.`);
299
+ }
300
+ function minor(value) {
301
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) payableInvalid("amounts must be nonnegative safe minor units.");
302
+ }
303
+ function source(value) {
304
+ if (!value || typeof value !== "object") payableInvalid("original source is required.");
305
+ for (const key of [
306
+ "namespace",
307
+ "key",
308
+ "version"
309
+ ]) text(value[key], key);
310
+ if (value.author !== null) text(value.author, "original author");
311
+ }
312
+ function review(value, expense) {
313
+ text(value.requestId, "requestId");
314
+ text(value.evidenceRef, "review evidence");
315
+ if (!/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value.recordedByProfileId)) payableInvalid("current recorder must be an explicit profile UUID.");
316
+ if (value.currency !== expense.currency) payableInvalid("currency must equal the Expense currency.");
317
+ }
318
+ function payableDigest(value) {
319
+ const canonical = (item) => {
320
+ if (item === null || typeof item === "string" || typeof item === "boolean") return JSON.stringify(item);
321
+ if (typeof item === "number" && Number.isSafeInteger(item)) return JSON.stringify(item);
322
+ if (Array.isArray(item)) return `[${item.map(canonical).join(",")}]`;
323
+ if (item && typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype) return `{${Object.keys(item).sort().map((key) => `${JSON.stringify(key)}:${canonical(item[key])}`).join(",")}}`;
324
+ return payableInvalid("evidence must be exact JSON values.");
325
+ };
326
+ return createHash("sha256").update(canonical(value)).digest("hex");
327
+ }
328
+ function payableExpenseFingerprint(expense) {
329
+ return payableDigest({
330
+ id: expense.id,
331
+ tenantId: expense.tenantId,
332
+ amount: expense.amount,
333
+ currency: expense.currency,
334
+ incurredOn: expense.incurredOn,
335
+ refundOfId: expense.refundOfId,
336
+ financialBreakdown: expense.financialBreakdown,
337
+ refundRecipient: expense.refundRecipient,
338
+ vendorId: expense.vendorId,
339
+ costObjectType: expense.costObjectType,
340
+ costObjectId: expense.costObjectId,
341
+ paidBy: expense.paidBy,
342
+ paidByProfileId: expense.paidByProfileId,
343
+ paidByName: expense.paidByName
344
+ });
345
+ }
346
+ function parseSupplierPayable(stored) {
347
+ if (stored === null || stored === "") return null;
348
+ let value;
349
+ try {
350
+ value = JSON.parse(stored);
351
+ } catch {
352
+ return payableInvalid("invalid retained JSON.");
353
+ }
354
+ if (!value || value.version !== 1 || !value.original || !Array.isArray(value.events) || !value.expenseId || !value.expenseFingerprint) payableInvalid("invalid retained authority.");
355
+ return value;
356
+ }
357
+ function validateEvent(event, expense, original) {
358
+ if (!event || typeof event !== "object") payableInvalid("event is required.");
359
+ assertIsoDate("Expense", String(expense.id), "supplier payable event date", event.effectiveOn);
360
+ if (event.effectiveOn < expense.incurredOn) payableInvalid("event precedes the original expense date.");
361
+ if (!Number.isSafeInteger(event.sequence) || event.sequence < 0) payableInvalid("explicit event sequence is required.");
362
+ text(event.evidenceRef, "event evidence");
363
+ if (original || event.source) source(event.source);
364
+ if ([
365
+ "hold",
366
+ "release",
367
+ "settlement"
368
+ ].includes(event.kind)) {
369
+ minor(event.amountMinor);
370
+ if (!event.amountMinor) payableInvalid("event amount must be positive.");
371
+ } else if (event.kind === "refund") {
372
+ text(event.refundExpenseId, "refund Expense identity");
373
+ minor(event.payableReductionMinor);
374
+ minor(event.retainedReductionMinor);
375
+ minor(event.cashReturnedMinor);
376
+ if (event.retainedReductionMinor > event.payableReductionMinor) payableInvalid("retained reduction exceeds the explicit payable reduction.");
377
+ } else payableInvalid("unsupported event kind.");
378
+ }
379
+ function bindEvent(event, expense, refunds, original, input, fingerprint) {
380
+ validateEvent(event, expense, original);
381
+ let refundFingerprint;
382
+ if (event.kind === "refund") {
383
+ const refund = refunds.find((row) => row.id === event.refundExpenseId);
384
+ if (!refund || refund.refundOfId !== expense.id || refund.tenantId !== expense.tenantId || refund.currency !== expense.currency || refund.amount >= 0) payableInvalid("refund must be an actual same-owner linked negative Expense.");
385
+ if (event.effectiveOn !== refund.incurredOn) payableInvalid("refund interpretation must preserve its actual effective date.");
386
+ if (event.payableReductionMinor > -refund.amount || event.cashReturnedMinor > -refund.amount) payableInvalid("refund interpretation exceeds its actual gross magnitude.");
387
+ refundFingerprint = payableExpenseFingerprint(refund);
388
+ }
389
+ return {
390
+ ...event,
391
+ id: randomUUID(),
392
+ authority: original ? "original" : "current",
393
+ requestId: input.requestId,
394
+ requestFingerprint: fingerprint,
395
+ recordedByProfileId: input.recordedByProfileId,
396
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
397
+ ...refundFingerprint ? { refundFingerprint } : {}
398
+ };
399
+ }
400
+ function createSupplierPayable(expense, input, refunds) {
401
+ if (!expense.id || expense.amount <= 0 || expense.refundOfId) payableInvalid("requires a persisted positive non-refund Expense.");
402
+ review(input, expense);
403
+ source(input.source);
404
+ if (input.payableBasisMinor !== null) {
405
+ minor(input.payableBasisMinor);
406
+ if (input.payableBasisMinor > expense.amount) payableInvalid("basis exceeds original gross.");
407
+ }
408
+ if (!Array.isArray(input.events)) payableInvalid("original events must be explicit.");
409
+ const fingerprint = payableDigest(input);
410
+ const state = {
411
+ version: 1,
412
+ expenseId: expense.id,
413
+ currency: expense.currency,
414
+ expenseFingerprint: payableExpenseFingerprint(expense),
415
+ original: {
416
+ id: randomUUID(),
417
+ requestId: input.requestId,
418
+ requestFingerprint: fingerprint,
419
+ source: input.source,
420
+ payableBasisMinor: input.payableBasisMinor,
421
+ recordedByProfileId: input.recordedByProfileId,
422
+ evidenceRef: input.evidenceRef,
423
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
424
+ },
425
+ completion: null,
426
+ events: input.events.map((event) => bindEvent(event, expense, refunds, true, input, fingerprint))
427
+ };
428
+ supplierPayablePosition(expense, state, refunds);
429
+ return state;
430
+ }
431
+ function appendSupplierPayableEvent(expense, state, input, refunds) {
432
+ review(input, expense);
433
+ const fingerprint = payableDigest(input);
434
+ const existing = state.events.find((event) => event.requestId === input.requestId);
435
+ if (existing || state.original.requestId === input.requestId || state.completion?.requestId === input.requestId) {
436
+ if (existing?.authority === "current" && existing.requestFingerprint === fingerprint) return state;
437
+ return payableInvalid("request identity conflicts with retained authority.");
438
+ }
439
+ const before = supplierPayablePosition(expense, state, refunds);
440
+ if (input.event?.kind === "release" && before.unresolvedRefundExpenseIds.length) payableInvalid("release requires every linked refund interpretation.");
441
+ if (input.event?.kind === "settlement" && before.unknownReasons.length) payableInvalid("settlement requires known basis and every linked refund interpretation.");
442
+ const next = {
443
+ ...state,
444
+ events: [...state.events, bindEvent(input.event, expense, refunds, false, input, fingerprint)]
445
+ };
446
+ supplierPayablePosition(expense, next, refunds);
447
+ return next;
448
+ }
449
+ function completeSupplierPayable(expense, state, input, refunds) {
450
+ review(input, expense);
451
+ minor(input.payableBasisMinor);
452
+ const fingerprint = payableDigest(input);
453
+ if (state.completion?.requestId === input.requestId && state.completion.requestFingerprint === fingerprint) return state;
454
+ if (state.original.payableBasisMinor !== null || state.completion || state.original.requestId === input.requestId || state.events.some((event) => event.requestId === input.requestId)) payableInvalid("original basis or its explicit completion is immutable.");
455
+ if (input.payableBasisMinor > expense.amount) payableInvalid("basis exceeds original gross.");
456
+ const next = {
457
+ ...state,
458
+ completion: {
459
+ ...input,
460
+ id: randomUUID(),
461
+ requestFingerprint: fingerprint,
462
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
463
+ }
464
+ };
465
+ supplierPayablePosition(expense, next, refunds);
466
+ return next;
467
+ }
468
+ function supplierPayablePosition(expense, state, refunds, asOfDate) {
469
+ if (asOfDate) assertIsoDate("Expense", String(expense.id), "supplier payable as-of date", asOfDate);
470
+ if (state.expenseId !== expense.id || state.currency !== expense.currency || state.expenseFingerprint !== payableExpenseFingerprint(expense)) payableInvalid("canonical Expense authority changed.");
471
+ const basis = state.completion?.payableBasisMinor ?? state.original.payableBasisMinor;
472
+ if (basis !== null) {
473
+ minor(basis);
474
+ if (basis > expense.amount) payableInvalid("basis exceeds original gross.");
475
+ }
476
+ const tuples = /* @__PURE__ */ new Set(), originalKeys = /* @__PURE__ */ new Set(), refundIds = /* @__PURE__ */ new Set();
477
+ let held = 0n, released = 0n, retainedReduction = 0n, settled = 0n, cashReturned = 0n, reduction = 0n;
478
+ let projected = {
479
+ held,
480
+ released,
481
+ retainedReduction,
482
+ settled,
483
+ cashReturned,
484
+ reduction
485
+ };
486
+ for (const event of [...state.events].sort((a, b) => a.effectiveOn.localeCompare(b.effectiveOn) || a.sequence - b.sequence)) {
487
+ validateEvent(event, expense, event.authority === "original");
488
+ const tuple = `${event.effectiveOn}:${event.sequence}`;
489
+ if (tuples.has(tuple)) payableInvalid("same-day chronology requires unique explicit sequence.");
490
+ tuples.add(tuple);
491
+ if (event.source) {
492
+ const key = payableDigest([event.source.namespace, event.source.key]);
493
+ if (originalKeys.has(key)) payableInvalid("duplicate original event identity.");
494
+ originalKeys.add(key);
495
+ }
496
+ if (event.kind === "hold") held += BigInt(event.amountMinor);
497
+ else if (event.kind === "release") released += BigInt(event.amountMinor);
498
+ else if (event.kind === "settlement") settled += BigInt(event.amountMinor);
499
+ else if (event.kind === "refund") {
500
+ if (refundIds.has(event.refundExpenseId)) payableInvalid("refund already has a retained interpretation.");
501
+ refundIds.add(event.refundExpenseId);
502
+ const refund = refunds.find((row) => row.id === event.refundExpenseId);
503
+ if (!refund || event.refundFingerprint !== payableExpenseFingerprint(refund)) payableInvalid("interpreted refund authority changed or disappeared.");
504
+ if (event.payableReductionMinor > -refund.amount || event.cashReturnedMinor > -refund.amount) payableInvalid("refund interpretation exceeds source magnitude.");
505
+ reduction += BigInt(event.payableReductionMinor);
506
+ retainedReduction += BigInt(event.retainedReductionMinor);
507
+ cashReturned += BigInt(event.cashReturnedMinor);
508
+ }
509
+ const retained2 = held - released - retainedReduction, paid2 = settled - cashReturned, obligation = BigInt(basis ?? expense.amount) - reduction;
510
+ if (retained2 < 0n || paid2 < 0n || obligation < 0n || obligation - paid2 - retained2 < 0n) payableInvalid("dated event exceeds retained, paid or payable capacity.");
511
+ for (const value of [
512
+ held,
513
+ released,
514
+ retainedReduction,
515
+ settled,
516
+ cashReturned,
517
+ reduction
518
+ ]) if (value > BigInt(Number.MAX_SAFE_INTEGER)) payableInvalid("unsafe aggregate money.");
519
+ if (!asOfDate || event.effectiveOn <= asOfDate) projected = {
520
+ held,
521
+ released,
522
+ retainedReduction,
523
+ settled,
524
+ cashReturned,
525
+ reduction
526
+ };
527
+ }
528
+ const unresolved = refunds.filter((row) => (!asOfDate || row.incurredOn <= asOfDate) && !refundIds.has(String(row.id))).map((row) => String(row.id));
529
+ const unknownReasons = [];
530
+ if (basis === null) unknownReasons.push("payable-basis-unknown");
531
+ if (unresolved.length) unknownReasons.push("refund-interpretation-required");
532
+ const p = projected, retained = p.held - p.released - p.retainedReduction, paid = p.settled - p.cashReturned;
533
+ return {
534
+ expenseId: state.expenseId,
535
+ currency: state.currency,
536
+ asOfDate: asOfDate ?? null,
537
+ payableBasisMinor: basis,
538
+ heldMinor: Number(p.held),
539
+ releasedMinor: Number(p.released),
540
+ retainedReductionMinor: Number(p.retainedReduction),
541
+ recordedRetainedMinor: Number(retained),
542
+ retainedMinor: unresolved.length ? null : Number(retained),
543
+ settledMinor: Number(p.settled),
544
+ cashReturnedMinor: Number(p.cashReturned),
545
+ netPaidMinor: unresolved.length ? null : Number(paid),
546
+ payableReductionMinor: Number(p.reduction),
547
+ remainingPayableMinor: unknownReasons.length ? null : Number(BigInt(basis) - p.reduction - paid - retained),
548
+ unknownReasons,
549
+ unresolvedRefundExpenseIds: unresolved,
550
+ authority: state
551
+ };
552
+ }
553
+ //#endregion
554
+ //#region src/models/Expense.ts
555
+ var __defProp = Object.defineProperty;
556
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
557
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
558
+ enumerable: true,
559
+ configurable: true,
560
+ writable: true,
561
+ value
562
+ }) : obj[key] = value;
563
+ var __decorateClass = (decorators, target, key, kind) => {
564
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
565
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
566
+ if (kind && result) __defProp(target, key, result);
567
+ return result;
568
+ };
569
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
570
+ var authorizedPayable = /* @__PURE__ */ new WeakMap();
571
+ var PROFILE = "@happyvertical/smrt-profiles:Profile";
572
+ var REVIEW_TRANSITIONS = {
573
+ review: {
574
+ from: ["unreviewed"],
575
+ to: "reviewed"
576
+ },
577
+ reject: {
578
+ from: ["unreviewed", "reviewed"],
579
+ to: "rejected"
580
+ },
581
+ markDuplicate: {
582
+ from: ["unreviewed", "reviewed"],
583
+ to: "rejected"
584
+ },
585
+ reopen: {
586
+ from: ["reviewed", "rejected"],
587
+ to: "unreviewed"
588
+ }
589
+ };
590
+ var LOCKED_WHEN_REVIEWED = [
591
+ ["amount", "amount"],
592
+ ["financialBreakdown", "financial_breakdown"],
593
+ ["paidByName", "paid_by_name"],
594
+ ["refundOfId", "refund_of_id"],
595
+ ["refundRecipient", "refund_recipient"],
596
+ ["currency", "currency"],
597
+ ["incurredOn", "incurred_on"],
598
+ ["recordedAt", "recorded_at"],
599
+ ["category", "category"],
600
+ ["costObjectType", "cost_object_type"],
601
+ ["costObjectId", "cost_object_id"],
602
+ ["vendorId", "vendor_id"],
603
+ ["commitmentId", "commitment_id"],
604
+ ["commitmentLineId", "commitment_line_id"],
605
+ ["paidBy", "paid_by"],
606
+ ["paidByProfileId", "paid_by_profile_id"]
607
+ ];
608
+ var authorizedTransition = /* @__PURE__ */ new WeakMap();
609
+ var authorizedReimbursement = /* @__PURE__ */ new WeakMap();
610
+ function sameReviewState(a, b) {
611
+ return a.status === b.status && a.reviewer === b.reviewer && a.reviewedAtMs === b.reviewedAtMs && a.note === b.note && a.duplicateOf === b.duplicateOf;
612
+ }
613
+ function asNullableString(value) {
614
+ return value === null || value === void 0 || value === "" ? null : String(value);
615
+ }
616
+ var Expense = class extends SmrtObject {
617
+ tenantId = null;
618
+ costObjectType = "";
619
+ costObjectId = "";
620
+ category = "other";
621
+ description = "";
622
+ amount = 0;
623
+ currency = "";
624
+ financialBreakdown = null;
625
+ supplierPayableJson = null;
626
+ paidByName = null;
627
+ refundOfId = null;
628
+ refundRecipient = null;
629
+ incurredOn = "";
630
+ recordedAt = null;
631
+ recordedByProfileId = null;
632
+ paidBy = "company";
633
+ paidByProfileId = null;
634
+ vendorId = null;
635
+ commitmentId = null;
636
+ commitmentLineId = null;
637
+ reviewStatus = "unreviewed";
638
+ reviewedByProfileId = null;
639
+ reviewedAt = null;
640
+ reviewNote = "";
641
+ duplicateOfId = null;
642
+ reimbursable = false;
643
+ reimbursedAmount = null;
644
+ reimbursedAt = null;
645
+ reimbursementReference = "";
646
+ constructor(options = {}) {
647
+ super(options);
648
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
649
+ if (options.costObjectType !== void 0) this.costObjectType = options.costObjectType;
650
+ if (options.costObjectId !== void 0) this.costObjectId = options.costObjectId;
651
+ if (options.category !== void 0) this.category = options.category;
652
+ if (options.description !== void 0) this.description = options.description;
653
+ if (options.amount !== void 0) this.amount = options.amount;
654
+ if (options.currency !== void 0) this.currency = options.currency;
655
+ if (options.financialBreakdown !== void 0) this.financialBreakdown = options.financialBreakdown;
656
+ if (options.paidByName !== void 0) this.paidByName = options.paidByName;
657
+ if (options.supplierPayableJson !== void 0) this.supplierPayableJson = options.supplierPayableJson;
658
+ if (options.refundOfId !== void 0) this.refundOfId = options.refundOfId;
659
+ if (options.incurredOn !== void 0) this.incurredOn = options.incurredOn;
660
+ if (options.recordedAt !== void 0) this.recordedAt = options.recordedAt;
661
+ if (options.recordedByProfileId !== void 0) this.recordedByProfileId = options.recordedByProfileId;
662
+ if (options.paidBy !== void 0) this.paidBy = options.paidBy;
663
+ if (options.paidByProfileId !== void 0) this.paidByProfileId = options.paidByProfileId;
664
+ if (options.vendorId !== void 0) this.vendorId = options.vendorId;
665
+ if (options.commitmentId !== void 0) this.commitmentId = options.commitmentId;
666
+ if (options.commitmentLineId !== void 0) this.commitmentLineId = options.commitmentLineId;
667
+ if (options.reimbursable !== void 0) this.reimbursable = options.reimbursable;
668
+ if (options.refundRecipient !== void 0) this.refundRecipient = options.refundRecipient;
669
+ if (options.reimbursedAmount !== void 0) this.reimbursedAmount = options.reimbursedAmount;
670
+ if (options.reimbursedAt !== void 0) this.reimbursedAt = options.reimbursedAt;
671
+ if (options.reimbursementReference !== void 0) this.reimbursementReference = options.reimbursementReference;
672
+ }
673
+ /** Read validated supplier facts, or null when none were recorded. */
674
+ getFinancialBreakdown() {
675
+ return parseExpenseFinancialBreakdown(this.financialBreakdown, this.amount, this.currency);
676
+ }
677
+ /** Set complete supplier facts; null explicitly clears facts on an unreviewed row. */
678
+ setFinancialBreakdown(value) {
679
+ if (value === null) {
680
+ this.financialBreakdown = null;
681
+ return;
682
+ }
683
+ expenseCostBasis(value, this.amount, this.currency);
684
+ this.financialBreakdown = JSON.stringify(value);
685
+ }
686
+ /** Explicit recipient, independent of this expense's payer facts. */
687
+ getRefundRecipient() {
688
+ return parseRefundRecipient(this.refundRecipient);
689
+ }
690
+ setRefundRecipient(value) {
691
+ const stored = value === null ? null : JSON.stringify(value);
692
+ parseRefundRecipient(stored);
693
+ this.refundRecipient = stored;
694
+ }
695
+ /** Return exact retained purchase cost; legacy unknown tax facts return null. */
696
+ getCostBasis() {
697
+ const facts = this.getFinancialBreakdown();
698
+ return facts === null ? null : expenseCostBasis(facts, this.amount, this.currency);
699
+ }
700
+ /** True when the person who paid has been paid back. */
701
+ isReimbursed() {
702
+ return this.reimbursedAt !== null && this.reimbursedAt !== void 0;
703
+ }
704
+ /** True when this expense counts toward reviewed totals and drawdown. */
705
+ isCountable() {
706
+ return this.reviewStatus === "reviewed" && !this.duplicateOfId;
707
+ }
708
+ /**
709
+ * Accept the expense as a real cost: `unreviewed → reviewed`.
710
+ *
711
+ * This is the separately authorizable review action: gate it in your route
712
+ * or service (for example on `expenses.review`). The expense must already
713
+ * be saved and must not be marked a duplicate.
714
+ *
715
+ * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from any other state.
716
+ */
717
+ async review(input) {
718
+ const reviewer = requireReviewer(input?.reviewerProfileId);
719
+ return this.transition("review", () => {
720
+ this.reviewStatus = "reviewed";
721
+ this.reviewedByProfileId = reviewer;
722
+ this.reviewedAt = input.at ?? /* @__PURE__ */ new Date();
723
+ this.reviewNote = input.note ?? "";
724
+ this.duplicateOfId = null;
725
+ });
726
+ }
727
+ /**
728
+ * Refuse the expense: `unreviewed | reviewed → rejected`. A rejected
729
+ * expense is kept and shown, and never counted.
730
+ *
731
+ * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from `rejected`, or
732
+ * `EXPENSE_INVALID` without a reason.
733
+ */
734
+ async reject(input) {
735
+ const reviewer = requireReviewer(input?.reviewerProfileId);
736
+ const reason = (input.reason ?? "").trim();
737
+ if (!reason) throw new ExpenseError("EXPENSE_INVALID", "A rejection needs a reason.");
738
+ return this.transition("reject", () => {
739
+ this.reviewStatus = "rejected";
740
+ this.reviewedByProfileId = reviewer;
741
+ this.reviewedAt = input.at ?? /* @__PURE__ */ new Date();
742
+ this.reviewNote = reason;
743
+ this.duplicateOfId = null;
744
+ });
745
+ }
746
+ /**
747
+ * Mark this expense a duplicate of another expense in the same tenant:
748
+ * `unreviewed | reviewed → rejected` with {@link Expense.duplicateOfId}
749
+ * set. A duplicate never counts toward any total.
750
+ *
751
+ * @throws {ExpenseError} `EXPENSE_INVALID` when the target is this expense
752
+ * or is not visible in this expense's tenant.
753
+ */
754
+ async markDuplicate(input) {
755
+ const reviewer = requireReviewer(input?.reviewerProfileId);
756
+ const targetId = String(input.duplicateOfId ?? "").trim();
757
+ if (!targetId || targetId === this.id) throw new ExpenseError("EXPENSE_INVALID", "An expense can only be a duplicate of a different expense.");
758
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
759
+ const target = await (await ExpenseCollection.create({ db: this.db })).get({ id: targetId });
760
+ if (!target || (target.tenantId ?? null) !== (this.tenantId ?? null)) throw new ExpenseError("EXPENSE_INVALID", `Expense ${targetId} was not found in this expense's tenant.`);
761
+ return this.transition("markDuplicate", () => {
762
+ this.reviewStatus = "rejected";
763
+ this.reviewedByProfileId = reviewer;
764
+ this.reviewedAt = input.at ?? /* @__PURE__ */ new Date();
765
+ this.reviewNote = `Duplicate of expense ${targetId}`;
766
+ this.duplicateOfId = targetId;
767
+ });
768
+ }
769
+ /**
770
+ * Send the expense back for review: `reviewed | rejected → unreviewed`.
771
+ * Clears the reviewer, the review time and any duplicate marker.
772
+ *
773
+ * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from `unreviewed`.
774
+ */
775
+ async reopen(input) {
776
+ requireReviewer(input?.reviewerProfileId);
777
+ return this.transition("reopen", () => {
778
+ this.reviewStatus = "unreviewed";
779
+ this.reviewedByProfileId = null;
780
+ this.reviewedAt = null;
781
+ this.reviewNote = input.note ?? "";
782
+ this.duplicateOfId = null;
783
+ });
784
+ }
785
+ /**
786
+ * Record that the person who paid has been paid back. Recording only:
787
+ * paying happens in payroll or payables, and this never changes the review
788
+ * state.
789
+ *
790
+ * @throws {ExpenseError} `EXPENSE_INVALID` unless the expense is
791
+ * `reimbursable`.
792
+ */
793
+ async markReimbursed(input = {}) {
794
+ return this.withTransaction(async () => {
795
+ await this.lockRefundRows();
796
+ const persisted = await this.readPersisted();
797
+ if (!persisted || persisted.reimbursed_at || this.reimbursedAt) throw new ExpenseError("EXPENSE_INVALID", "Reimbursement requires a persisted source without an existing payment marker.");
798
+ for (const [field2, column] of [
799
+ ["amount", "amount"],
800
+ ["currency", "currency"],
801
+ ["paidBy", "paid_by"],
802
+ ["paidByProfileId", "paid_by_profile_id"],
803
+ ["reimbursable", "reimbursable"],
804
+ ["refundOfId", "refund_of_id"]
805
+ ]) if (field2 === "reimbursable" ? Boolean(this[field2]) !== Boolean(persisted[column]) : String(this[field2] ?? "") !== String(persisted[column] ?? "")) throw new ExpenseError("EXPENSE_INVALID", "Save source corrections separately before recording reimbursement.");
806
+ const position = await this.getReimbursementPosition();
807
+ if (position.remainingAmount === null || position.remainingAmount <= 0 || position.owedToCompany !== 0 || input.amount !== void 0 && input.amount !== position.remainingAmount) throw new ExpenseError("EXPENSE_INVALID", "Record only the exact full known remaining reimbursement; unknown, partial, or settled claims require explicit resolution.");
808
+ const at = input.at ?? /* @__PURE__ */ new Date();
809
+ if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new ExpenseError("EXPENSE_INVALID", "Reimbursement date must be valid.");
810
+ const previous = {
811
+ at: this.reimbursedAt,
812
+ amount: this.reimbursedAmount,
813
+ reference: this.reimbursementReference
814
+ };
815
+ this.reimbursedAt = at;
816
+ this.reimbursedAmount = position.remainingAmount;
817
+ this.reimbursementReference = input.reference ?? "";
818
+ authorizedReimbursement.set(this, {
819
+ at: at.getTime(),
820
+ amount: position.remainingAmount,
821
+ reference: this.reimbursementReference
822
+ });
823
+ try {
824
+ return await this.save();
825
+ } catch (error) {
826
+ this.reimbursedAt = previous.at;
827
+ this.reimbursedAmount = previous.amount;
828
+ this.reimbursementReference = previous.reference;
829
+ throw error;
830
+ } finally {
831
+ authorizedReimbursement.delete(this);
832
+ }
833
+ });
834
+ }
835
+ /**
836
+ * Load the cost object this expense is charged to, through its registered
837
+ * collection (so tenant interceptors apply). Returns `null` when unset,
838
+ * when the type is not registered, when the row is gone, or when the row
839
+ * belongs to a different tenant than this expense.
840
+ */
841
+ async getCostObject() {
842
+ if (!this.costObjectType || !this.costObjectId) return null;
843
+ let registered = ObjectRegistry.getClassByQualifiedName(this.costObjectType);
844
+ if (!registered) {
845
+ if (!await ObjectRegistry.tryLoadFromExternalPackage(this.costObjectType)) return null;
846
+ registered = ObjectRegistry.getClassByQualifiedName(this.costObjectType);
847
+ }
848
+ if (!registered) return null;
849
+ const target = await (await ObjectRegistry.getCollection(registered.qualifiedName ?? this.costObjectType, { db: this.db })).get({ id: this.costObjectId });
850
+ if (!target) return null;
851
+ const targetTenant = target.tenantId;
852
+ if (targetTenant !== void 0 && targetTenant !== null && this.tenantId !== null && targetTenant !== this.tenantId) return null;
853
+ return target;
854
+ }
855
+ /**
856
+ * Validate, enforce the review and reviewed-field locks, check the
857
+ * commitment, then persist.
858
+ */
859
+ async save() {
860
+ this.normalize();
861
+ this.validateShape();
862
+ return this.withTransaction(() => this.saveValidated());
863
+ }
864
+ /** Keep both directions of refund validation and the write on one executor. */
865
+ async saveValidated() {
866
+ await this.lockRefundRows();
867
+ const persisted = await this.readPersisted();
868
+ const inserting = await pinNaturalKey("Expense", this, persisted);
869
+ 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.`);
870
+ await this.assertSupplierPayable(persisted);
871
+ this.assertReviewFields(persisted);
872
+ this.assertReviewedLock(persisted);
873
+ await this.assertCommitment(persisted);
874
+ await this.assertVendor(persisted);
875
+ this.assertReimbursementFields(persisted);
876
+ await this.assertInboundRefunds(persisted);
877
+ await this.assertRefund(persisted);
878
+ if (!this.recordedAt) this.recordedAt = /* @__PURE__ */ new Date();
879
+ try {
880
+ return await super.save();
881
+ } catch (error) {
882
+ if (inserting && isUniqueViolationError(error)) throw identityConflict("Expense", this, error);
883
+ throw error;
884
+ }
885
+ }
886
+ /** Record original supplier authority without creating approval, cash or journals. */
887
+ async recordSupplierPayable(input) {
888
+ return this.mutateSupplierPayable((source, state, refunds) => {
889
+ if (state) {
890
+ if (state.original.requestId === input.requestId && state.original.requestFingerprint === payableDigest(input)) return state;
891
+ return payableInvalid("original supplier authority is immutable.");
892
+ }
893
+ return createSupplierPayable(source, input, refunds);
894
+ });
895
+ }
896
+ /** Append explicit dated hold, release, supplier settlement or refund interpretation. */
897
+ async recordSupplierPayableEvent(input) {
898
+ return this.mutateSupplierPayable((source, state, refunds) => {
899
+ if (!state) return payableInvalid("original supplier authority is required.");
900
+ return appendSupplierPayableEvent(source, state, input, refunds);
901
+ });
902
+ }
903
+ /** Complete a previously unknown basis once with independent current review evidence. */
904
+ async completeSupplierPayableBasis(input) {
905
+ return this.mutateSupplierPayable((source, state, refunds) => {
906
+ if (!state) return payableInvalid("original supplier authority is required.");
907
+ return completeSupplierPayable(source, state, input, refunds);
908
+ });
909
+ }
910
+ /** Read exact dated supplier position; null means no authority exists or the source is not yet effective. */
911
+ async getSupplierPayablePosition(input = {}) {
912
+ return this.withTransaction(async () => {
913
+ this.assertSupplierPayableContext();
914
+ await this.lockRefundRows();
915
+ const source = await this.loadSupplierPayableSource();
916
+ const state = parseSupplierPayable(source.supplierPayableJson);
917
+ if (!state) return null;
918
+ const position = supplierPayablePosition(source, state, await source.supplierRefunds(), input.asOfDate);
919
+ return input.asOfDate && input.asOfDate < source.incurredOn ? null : position;
920
+ });
921
+ }
922
+ assertSupplierPayableContext() {
923
+ const active = getTenantId();
924
+ if (active && this.tenantId && active !== this.tenantId) payableInvalid("active tenant must own the supplier source.");
925
+ }
926
+ async loadSupplierPayableSource() {
927
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
928
+ const source = await (await ExpenseCollection.create({ db: this.db })).get({
929
+ id: this.id,
930
+ tenantId: effectiveTenant(this.tenantId)
931
+ }, { cache: false });
932
+ if (!source || source.amount <= 0 || source.refundOfId) return payableInvalid("requires an owned persisted positive source.");
933
+ return source;
934
+ }
935
+ async supplierRefunds() {
936
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
937
+ const collection = await ExpenseCollection.create({ db: this.db });
938
+ const all = [];
939
+ for (let offset = 0;; offset += 200) {
940
+ const rows = await collection.list({
941
+ where: {
942
+ refundOfId: this.id,
943
+ tenantId: effectiveTenant(this.tenantId)
944
+ },
945
+ orderBy: ["id ASC"],
946
+ limit: 200,
947
+ offset,
948
+ cache: false
949
+ });
950
+ all.push(...rows);
951
+ if (rows.length < 200) return all;
952
+ }
953
+ }
954
+ async mutateSupplierPayable(change) {
955
+ return this.withTransaction(async () => {
956
+ this.assertSupplierPayableContext();
957
+ await this.lockRefundRows();
958
+ const source = await this.loadSupplierPayableSource(), refunds = await source.supplierRefunds();
959
+ const state = change(source, parseSupplierPayable(source.supplierPayableJson), refunds);
960
+ const position = supplierPayablePosition(source, state, refunds), stored = JSON.stringify(state);
961
+ if (stored !== source.supplierPayableJson) {
962
+ source.supplierPayableJson = stored;
963
+ authorizedPayable.set(source, stored);
964
+ try {
965
+ await source.save();
966
+ } finally {
967
+ authorizedPayable.delete(source);
968
+ }
969
+ }
970
+ return position;
971
+ });
972
+ }
973
+ async assertSupplierPayable(persisted) {
974
+ const prior = asNullableString(persisted?.supplier_payable_json), next = asNullableString(this.supplierPayableJson);
975
+ if (prior !== next && (!next || authorizedPayable.get(this) !== next)) payableInvalid("supplier authority is command-owned.");
976
+ const state = parseSupplierPayable(next);
977
+ if (state) supplierPayablePosition(this, state, await this.supplierRefunds());
978
+ const oldParent = asNullableString(persisted?.refund_of_id);
979
+ if (oldParent) {
980
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
981
+ if ((await (await ExpenseCollection.create({ db: this.db })).get({
982
+ id: oldParent,
983
+ tenantId: effectiveTenant(this.tenantId)
984
+ }, { cache: false }))?.supplierPayableJson) for (const [field2, column] of LOCKED_WHEN_REVIEWED) {
985
+ const old = persisted?.[column], current = this[field2];
986
+ if (!(field2 === "recordedAt" ? instantMs(old) === instantMs(current) : String(old ?? "") === String(current ?? ""))) payableInvalid("linked supplier refund source facts are immutable; reconcile with new evidence.");
987
+ }
988
+ }
989
+ }
990
+ /** Managed supplier evidence cannot be erased through ordinary model/collection deletion. */
991
+ async delete(options = {}) {
992
+ return this.withTransaction(async () => {
993
+ this.assertSupplierPayableContext();
994
+ await this.lockRefundRows();
995
+ const row = await this.readPersisted();
996
+ if (row && String(row.tenant_id ?? "") !== String(effectiveTenant(this.tenantId) ?? "")) payableInvalid("owned source is required.");
997
+ if (row?.supplier_payable_json) payableInvalid("supplier authority cannot be deleted.");
998
+ const parentId = asNullableString(row?.refund_of_id);
999
+ if (parentId) {
1000
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
1001
+ if ((await (await ExpenseCollection.create({ db: this.db })).get({
1002
+ id: parentId,
1003
+ tenantId: effectiveTenant(this.tenantId)
1004
+ }, { cache: false }))?.supplierPayableJson) payableInvalid("supplier refund evidence cannot be deleted.");
1005
+ }
1006
+ await super.delete(options);
1007
+ });
1008
+ }
1009
+ normalize() {
1010
+ this.tenantId = normalizeTenantId(this.tenantId);
1011
+ this.currency = String(this.currency ?? "").trim().toUpperCase();
1012
+ this.category = String(this.category ?? "").trim();
1013
+ this.costObjectType = String(this.costObjectType ?? "").trim();
1014
+ this.costObjectId = String(this.costObjectId ?? "").trim();
1015
+ this.incurredOn = String(this.incurredOn ?? "").trim();
1016
+ this.vendorId = asNullableString(this.vendorId);
1017
+ this.commitmentId = asNullableString(this.commitmentId);
1018
+ this.commitmentLineId = asNullableString(this.commitmentLineId);
1019
+ this.paidByProfileId = asNullableString(this.paidByProfileId);
1020
+ this.recordedByProfileId = asNullableString(this.recordedByProfileId);
1021
+ }
1022
+ validateShape() {
1023
+ const label = this.id || "<new>";
1024
+ assertMinorUnits("Expense", label, "amount", this.refundOfId && typeof this.amount === "number" ? Math.abs(this.amount) : this.amount);
1025
+ assertIsoCurrency("Expense", label, this.currency);
1026
+ assertIsoDate("Expense", label, "incurredOn", this.incurredOn);
1027
+ 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}'.`);
1028
+ if (Boolean(this.costObjectType) !== Boolean(this.costObjectId)) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: costObjectType and costObjectId are set together or not at all.`);
1029
+ if (this.costObjectType) assertQualifiedClassName("Expense", label, this.costObjectType);
1030
+ this.getFinancialBreakdown();
1031
+ if (this.getRefundRecipient() && !this.refundOfId) throw new ExpenseError("EXPENSE_INVALID", "Only linked refunds may name a cash recipient.");
1032
+ if (this.reimbursedAmount !== null && (!Number.isSafeInteger(this.reimbursedAmount) || this.reimbursedAmount <= 0 || !this.reimbursedAt)) throw new ExpenseError("EXPENSE_INVALID", "Retained reimbursement needs a positive exact amount and payment date.");
1033
+ 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.");
1034
+ 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.");
1035
+ 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'.`);
1036
+ if (this.paidBy === "person" && !this.paidByProfileId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: an expense paid by a person names that person (paidByProfileId).`);
1037
+ if (this.paidBy !== "person" && this.paidByProfileId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: paidByProfileId is only set when paidBy is 'person'.`);
1038
+ if (this.reimbursable && this.paidBy !== "person") throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: only an expense a person paid can be owed back.`);
1039
+ if (this.reimbursedAt && instantMs(this.reimbursedAt) === null) throw new ExpenseError("EXPENSE_INVALID", "Retained reimbursement date must be valid.");
1040
+ if (this.reimbursedAt && !this.reimbursable) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: reimbursedAt needs reimbursable.`);
1041
+ if (this.reviewStatus === "reviewed" && this.duplicateOfId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: a reviewed expense cannot be marked a duplicate.`);
1042
+ if (this.commitmentLineId && !this.commitmentId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: a commitment line needs its commitment (commitmentId).`);
1043
+ }
1044
+ /**
1045
+ * Read the stored row directly. The authoritative prior state for the
1046
+ * review and lock guards: the in-memory copy may have been mass-assigned.
1047
+ */
1048
+ async readPersisted() {
1049
+ if (!this.id) return null;
1050
+ return await this.db.get(this.tableName, { id: this.id }) ?? null;
1051
+ }
1052
+ reviewState() {
1053
+ return {
1054
+ status: this.reviewStatus,
1055
+ reviewer: asNullableString(this.reviewedByProfileId),
1056
+ reviewedAtMs: instantMs(this.reviewedAt),
1057
+ note: this.reviewNote ?? "",
1058
+ duplicateOf: asNullableString(this.duplicateOfId)
1059
+ };
1060
+ }
1061
+ /**
1062
+ * Refuse any review-field change a transition did not authorize. The token
1063
+ * binds the transition's complete resulting state — status, reviewer,
1064
+ * time, note and duplicate marker — so a field set directly beside a
1065
+ * legitimate `review()` / `reject()` cannot ride along with it.
1066
+ */
1067
+ assertReviewFields(persisted) {
1068
+ const token = authorizedTransition.get(this);
1069
+ const prior = {
1070
+ status: persisted?.review_status ?? "unreviewed",
1071
+ reviewer: asNullableString(persisted?.reviewed_by_profile_id),
1072
+ reviewedAtMs: instantMs(persisted?.reviewed_at),
1073
+ note: String(persisted?.review_note ?? ""),
1074
+ duplicateOf: asNullableString(persisted?.duplicate_of_id)
1075
+ };
1076
+ const current = this.reviewState();
1077
+ if (sameReviewState(current, prior)) return;
1078
+ if (!token || token.from !== prior.status || !sameReviewState(current, token.expected)) throw new ExpenseError("EXPENSE_REVIEW_FIELDS_LOCKED", `Expense ${this.id || "<new>"}: review state changes only through review(), reject(), markDuplicate() or reopen().`);
1079
+ }
1080
+ assertReviewedLock(persisted) {
1081
+ if (persisted?.review_status !== "reviewed") return;
1082
+ const self = this;
1083
+ for (const [fieldName, column] of LOCKED_WHEN_REVIEWED) {
1084
+ const before = persisted[column];
1085
+ const after = self[fieldName];
1086
+ if (!(fieldName === "amount" ? Number(before) === Number(after) : fieldName === "recordedAt" ? instantMs(before) === instantMs(after) : asNullableString(before) === asNullableString(after))) throw new ExpenseError("EXPENSE_REVIEW_FIELDS_LOCKED", `Expense ${this.id}: ${fieldName} of a reviewed expense cannot change; reopen() it first.`);
1087
+ }
1088
+ }
1089
+ /**
1090
+ * A matched commitment must be visible in this expense's tenant, owned by
1091
+ * the same tenant, and in the same currency; a matched line must belong to
1092
+ * it. Mixed currencies are refused here rather than summed later.
1093
+ *
1094
+ * Checked when the match is made or changed, not on every save: a later
1095
+ * change to the commitment (its currency, or its deletion) must not stop
1096
+ * an unrelated save such as recording a reimbursement.
1097
+ */
1098
+ async assertCommitment(persisted) {
1099
+ if (!this.commitmentId) return;
1100
+ if (persisted && asNullableString(persisted.commitment_id) === this.commitmentId && asNullableString(persisted.commitment_line_id) === this.commitmentLineId && String(persisted.currency ?? "") === this.currency) return;
1101
+ const commitment = await (await PurchaseContractCollection.create({ db: this.db })).get({ id: this.commitmentId });
1102
+ const owner = effectiveTenant(this.tenantId);
1103
+ if (!commitment || !sameTenant(commitment.tenantId, owner)) throw new ExpenseError("EXPENSE_COMMITMENT_MISMATCH", `Expense ${this.id || "<new>"}: commitment ${this.commitmentId} was not found in this expense's tenant.`);
1104
+ if (String(commitment.currency).toUpperCase() !== this.currency) throw new ExpenseError("EXPENSE_COMMITMENT_MISMATCH", `Expense ${this.id || "<new>"}: currency ${this.currency} does not match commitment ${this.commitmentId} (${commitment.currency}). Amounts in different currencies are never summed.`);
1105
+ if (this.commitmentLineId) {
1106
+ const line = await (await ContractLineItemCollection.create({ db: this.db })).get({ id: this.commitmentLineId });
1107
+ if (!line || line.contractId !== this.commitmentId) throw new ExpenseError("EXPENSE_COMMITMENT_MISMATCH", `Expense ${this.id || "<new>"}: line ${this.commitmentLineId} is not a line of commitment ${this.commitmentId}.`);
1108
+ }
1109
+ }
1110
+ /**
1111
+ * A vendor must be visible in, and owned by, the expense's tenant. A
1112
+ * `crossPackageRef` neither validates its target nor creates a foreign
1113
+ * key, so without this an expense in one tenant could name another
1114
+ * tenant's vendor. Checked when the vendor is set or changed.
1115
+ */
1116
+ async assertVendor(persisted) {
1117
+ if (!this.vendorId) return;
1118
+ if (persisted && asNullableString(persisted.vendor_id) === this.vendorId) return;
1119
+ const vendor = await (await VendorCollection.create({ db: this.db })).get({ id: this.vendorId });
1120
+ const owner = effectiveTenant(this.tenantId);
1121
+ 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.`);
1122
+ }
1123
+ /**
1124
+ * Source edits and refund writes contend on the same source row. Acquire
1125
+ * rows in deterministic order before either relationship check, and hold
1126
+ * them through commit. SQLite's write transaction supplies serialization.
1127
+ */
1128
+ async lockRefundRows() {
1129
+ if (!isPostgresDatabase(this.db)) return;
1130
+ const oldRow = await this.readPersisted();
1131
+ const oldParent = oldRow && String(oldRow.tenant_id ?? "") === String(effectiveTenant(this.tenantId) ?? "") ? asNullableString(oldRow.refund_of_id) : null;
1132
+ const ids = [...new Set([
1133
+ this.id,
1134
+ this.refundOfId,
1135
+ oldParent
1136
+ ].filter((id) => Boolean(id)))].sort();
1137
+ if (ids.length === 0) return;
1138
+ const table = `"${this.tableName.replace(/"/g, "\"\"")}"`;
1139
+ 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));
1140
+ }
1141
+ /** Refuse source corrections that invalidate any same-owner refund. */
1142
+ async assertInboundRefunds(persisted) {
1143
+ if (!persisted || Number(persisted.amount) === this.amount && String(persisted.currency ?? "") === this.currency && asNullableString(persisted.refund_of_id) === this.refundOfId && asNullableString(persisted.financial_breakdown) === this.financialBreakdown) return;
1144
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
1145
+ const refunds = await (await ExpenseCollection.create({ db: this.db })).list({
1146
+ where: {
1147
+ refundOfId: this.id,
1148
+ tenantId: effectiveTenant(this.tenantId)
1149
+ },
1150
+ cache: false
1151
+ });
1152
+ if (refunds.length === 0) return;
1153
+ if (this.amount <= 0 || this.refundOfId) throw new ExpenseError("EXPENSE_INVALID", "A referenced refund source must remain positive and non-refund.");
1154
+ if (persisted.financial_breakdown && !this.getFinancialBreakdown()) throw new ExpenseError("EXPENSE_INVALID", "Financial facts of a linked refund source cannot be cleared.");
1155
+ calculateRefundPosition(this, refunds);
1156
+ }
1157
+ async assertRefund(persisted) {
1158
+ if (!this.refundOfId) return;
1159
+ 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.");
1160
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
1161
+ const expenses = await ExpenseCollection.create({ db: this.db });
1162
+ const source = await expenses.get({ id: this.refundOfId }, { cache: false });
1163
+ 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.");
1164
+ if (persisted?.financial_breakdown && !this.getFinancialBreakdown()) throw new ExpenseError("EXPENSE_INVALID", "Financial facts of a linked refund cannot be cleared.");
1165
+ calculateRefundPosition(source, [...(await expenses.list({
1166
+ where: {
1167
+ refundOfId: source.id,
1168
+ tenantId: effectiveTenant(this.tenantId)
1169
+ },
1170
+ cache: false
1171
+ })).filter((refund) => refund.id !== this.id), this]);
1172
+ if (!persisted || persisted.refund_of_id !== this.refundOfId || Number(persisted.amount) !== this.amount || asNullableString(persisted.financial_breakdown) !== this.financialBreakdown || asNullableString(persisted.refund_recipient) !== this.refundRecipient) {
1173
+ if (!source.updated_at) throw new ExpenseError("EXPENSE_INVALID", "Refund source has no revision.");
1174
+ await source.claimRevision(source.updated_at);
1175
+ }
1176
+ }
1177
+ assertReimbursementFields(persisted) {
1178
+ const token = authorizedReimbursement.get(this);
1179
+ const at = instantMs(this.reimbursedAt);
1180
+ const priorAt = instantMs(persisted?.reimbursed_at);
1181
+ const priorAmount = persisted?.reimbursed_amount == null ? null : Number(persisted.reimbursed_amount);
1182
+ if ((at !== priorAt || this.reimbursedAmount !== priorAmount || String(this.reimbursementReference ?? "") !== String(persisted?.reimbursement_reference ?? "")) && (!token || priorAt !== null || at !== token.at || this.reimbursedAmount !== token.amount || this.reimbursementReference !== token.reference)) throw new ExpenseError("EXPENSE_INVALID", "Retained reimbursement payment fields change only through markReimbursed and cannot be overwritten.");
1183
+ if (priorAt !== null && persisted) {
1184
+ for (const [field2, column] of [
1185
+ ["amount", "amount"],
1186
+ ["currency", "currency"],
1187
+ ["paidBy", "paid_by"],
1188
+ ["paidByProfileId", "paid_by_profile_id"],
1189
+ ["reimbursable", "reimbursable"],
1190
+ ["refundOfId", "refund_of_id"]
1191
+ ]) if (field2 === "reimbursable" ? Boolean(this[field2]) !== Boolean(persisted[column]) : String(this[field2] ?? "") !== String(persisted[column] ?? "")) throw new ExpenseError("EXPENSE_INVALID", "A paid reimbursement preserves its original cash claim and payer.");
1192
+ }
1193
+ }
1194
+ /** Remaining person cash claim and separate company receivable; null is unknown. */
1195
+ async getReimbursementPosition() {
1196
+ return this.withTransaction(async () => {
1197
+ await this.lockRefundRows();
1198
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
1199
+ const expenses = await ExpenseCollection.create({ db: this.db });
1200
+ const source = this.id ? await expenses.get({ id: this.id }, { cache: false }) : null;
1201
+ if (!source || source.refundOfId) throw new ExpenseError("EXPENSE_INVALID", "Reimbursement position requires a persisted positive source.");
1202
+ const refunds = await expenses.list({
1203
+ where: {
1204
+ refundOfId: source.id,
1205
+ tenantId: effectiveTenant(source.tenantId)
1206
+ },
1207
+ cache: false
1208
+ });
1209
+ calculateRefundPosition(source, refunds);
1210
+ return calculateReimbursementPosition(source, refunds);
1211
+ });
1212
+ }
1213
+ /** Exact source cash capacity and optional complete signed financial position. */
1214
+ async getRefundPosition() {
1215
+ return this.withTransaction(async () => {
1216
+ await this.lockRefundRows();
1217
+ const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
1218
+ const expenses = await ExpenseCollection.create({ db: this.db });
1219
+ const source = this.id ? await expenses.get({ id: this.id }, { cache: false }) : null;
1220
+ if (!source || source.refundOfId) throw new ExpenseError("EXPENSE_INVALID", "Refund position requires a persisted positive non-refund source.");
1221
+ return calculateRefundPosition(source, await expenses.list({
1222
+ where: {
1223
+ refundOfId: source.id,
1224
+ tenantId: effectiveTenant(source.tenantId)
1225
+ },
1226
+ cache: false
1227
+ }));
1228
+ });
1229
+ }
1230
+ async transition(action, apply) {
1231
+ const rule = REVIEW_TRANSITIONS[action];
1232
+ const persisted = await this.readPersisted();
1233
+ if (!persisted) throw new ExpenseError("EXPENSE_REVIEW_TRANSITION", `Expense ${this.id || "<new>"}: save the expense before ${action}().`);
1234
+ const from = String(persisted.review_status);
1235
+ if (!rule.from.includes(from)) throw new ExpenseError("EXPENSE_REVIEW_TRANSITION", `Expense ${this.id}: cannot ${action}() an expense that is ${from}.`);
1236
+ if (action === "review" && persisted.duplicate_of_id) throw new ExpenseError("EXPENSE_REVIEW_TRANSITION", `Expense ${this.id}: a duplicate cannot be reviewed; reopen() it first.`);
1237
+ const snapshot = {
1238
+ reviewStatus: this.reviewStatus,
1239
+ reviewedByProfileId: this.reviewedByProfileId,
1240
+ reviewedAt: this.reviewedAt,
1241
+ reviewNote: this.reviewNote,
1242
+ duplicateOfId: this.duplicateOfId
1243
+ };
1244
+ apply();
1245
+ authorizedTransition.set(this, {
1246
+ from,
1247
+ expected: this.reviewState()
1248
+ });
1249
+ try {
1250
+ return await this.save();
1251
+ } catch (error) {
1252
+ Object.assign(this, snapshot);
1253
+ throw error;
1254
+ } finally {
1255
+ authorizedTransition.delete(this);
1256
+ }
1257
+ }
1258
+ };
1259
+ __publicField(Expense, "__smrtPackage__", "@happyvertical/smrt-expenses");
1260
+ __decorateClass([tenantId({ nullable: true })], Expense.prototype, "tenantId", 2);
1261
+ __decorateClass([field({ type: "text" })], Expense.prototype, "costObjectType", 2);
1262
+ __decorateClass([field({ type: "text" })], Expense.prototype, "costObjectId", 2);
1263
+ __decorateClass([field({ type: "text" })], Expense.prototype, "category", 2);
1264
+ __decorateClass([field({ type: "text" })], Expense.prototype, "description", 2);
1265
+ __decorateClass([field({ type: "integer" })], Expense.prototype, "amount", 2);
1266
+ __decorateClass([field({ type: "text" })], Expense.prototype, "currency", 2);
1267
+ __decorateClass([field({ type: "text" })], Expense.prototype, "financialBreakdown", 2);
1268
+ __decorateClass([field({
1269
+ type: "text",
1270
+ nullable: true,
1271
+ readonly: true
1272
+ })], Expense.prototype, "supplierPayableJson", 2);
1273
+ __decorateClass([field({ type: "text" })], Expense.prototype, "paidByName", 2);
1274
+ __decorateClass([foreignKey("Expense", { onDelete: "RESTRICT" })], Expense.prototype, "refundOfId", 2);
1275
+ __decorateClass([field({ type: "text" })], Expense.prototype, "refundRecipient", 2);
1276
+ __decorateClass([field({ type: "text" })], Expense.prototype, "incurredOn", 2);
1277
+ __decorateClass([field({ type: "datetime" })], Expense.prototype, "recordedAt", 2);
1278
+ __decorateClass([crossPackageRef(PROFILE)], Expense.prototype, "recordedByProfileId", 2);
1279
+ __decorateClass([field({ type: "text" })], Expense.prototype, "paidBy", 2);
1280
+ __decorateClass([crossPackageRef(PROFILE)], Expense.prototype, "paidByProfileId", 2);
1281
+ __decorateClass([crossPackageRef("@happyvertical/smrt-commerce:Vendor")], Expense.prototype, "vendorId", 2);
1282
+ __decorateClass([crossPackageRef("@happyvertical/smrt-commerce:PurchaseContract", { validateSubtype: true })], Expense.prototype, "commitmentId", 2);
1283
+ __decorateClass([crossPackageRef("@happyvertical/smrt-commerce:ContractLineItem")], Expense.prototype, "commitmentLineId", 2);
1284
+ __decorateClass([field({
1285
+ type: "text",
1286
+ readonly: true
1287
+ })], Expense.prototype, "reviewStatus", 2);
1288
+ __decorateClass([crossPackageRef(PROFILE, { readonly: true })], Expense.prototype, "reviewedByProfileId", 2);
1289
+ __decorateClass([field({
1290
+ type: "datetime",
1291
+ readonly: true
1292
+ })], Expense.prototype, "reviewedAt", 2);
1293
+ __decorateClass([field({
1294
+ type: "text",
1295
+ readonly: true
1296
+ })], Expense.prototype, "reviewNote", 2);
1297
+ __decorateClass([foreignKey("Expense", {
1298
+ readonly: true,
1299
+ onDelete: "SET NULL"
1300
+ })], Expense.prototype, "duplicateOfId", 2);
1301
+ __decorateClass([field({ type: "boolean" })], Expense.prototype, "reimbursable", 2);
1302
+ __decorateClass([field({
1303
+ type: "integer",
1304
+ nullable: true,
1305
+ readonly: true
1306
+ })], Expense.prototype, "reimbursedAmount", 2);
1307
+ __decorateClass([field({
1308
+ type: "datetime",
1309
+ readonly: true
1310
+ })], Expense.prototype, "reimbursedAt", 2);
1311
+ __decorateClass([field({
1312
+ type: "text",
1313
+ readonly: true
1314
+ })], Expense.prototype, "reimbursementReference", 2);
1315
+ Expense = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
1316
+ tableName: "expenses",
1317
+ api: { include: [
1318
+ "record",
1319
+ "attachReceipt",
1320
+ "recordReview",
1321
+ "recordReimbursement"
1322
+ ] },
1323
+ mcp: { include: [] },
1324
+ cli: false,
1325
+ indexes: [
1326
+ {
1327
+ name: "expenses_cost_object_idx",
1328
+ columns: [
1329
+ "tenantId",
1330
+ "costObjectType",
1331
+ "costObjectId",
1332
+ "incurredOn"
1333
+ ]
1334
+ },
1335
+ {
1336
+ name: "expenses_commitment_review_idx",
1337
+ columns: [
1338
+ "tenantId",
1339
+ "commitmentId",
1340
+ "reviewStatus"
1341
+ ]
1342
+ },
1343
+ {
1344
+ name: "expenses_review_status_idx",
1345
+ columns: [
1346
+ "tenantId",
1347
+ "reviewStatus",
1348
+ "incurredOn"
1349
+ ]
1350
+ }
1351
+ ]
1352
+ })], Expense);
1353
+ function requireReviewer(reviewerProfileId) {
1354
+ const reviewer = String(reviewerProfileId ?? "").trim();
1355
+ if (!reviewer) throw new ExpenseError("EXPENSE_INVALID", "A review decision names its reviewer (reviewerProfileId).");
1356
+ return reviewer;
1357
+ }
1358
+ function sameTenant(a, b) {
1359
+ return (a ?? null) === (b ?? null);
1360
+ }
1361
+ //#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 };
1363
+
1364
+ //# sourceMappingURL=Expense-C_WC5VLF.js.map