@happyvertical/smrt-expenses 0.57.3 → 0.57.5

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
@@ -1,681 +0,0 @@
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 } 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(message) {
64
- throw new ExpenseError("EXPENSE_INVALID", `Expense financial breakdown: ${message}`);
65
- }
66
- function record(value, keys) {
67
- if (!value || typeof value !== "object" || Array.isArray(value)) return invalid("expected an object.");
68
- const object = value;
69
- if (Object.keys(object).length !== keys.length || keys.some((key) => !Object.hasOwn(object, key))) return invalid("missing or unknown fields.");
70
- return object;
71
- }
72
- function minor(value) {
73
- if (typeof value !== "number" || !Number.isSafeInteger(value)) return invalid("amounts must be safe integer minor units.");
74
- return BigInt(value);
75
- }
76
- function safe(value) {
77
- const result = Number(value);
78
- if (!Number.isSafeInteger(result)) return invalid("unsafe calculated amount.");
79
- return result;
80
- }
81
- function expenseCostBasis(value, amount, currency) {
82
- const facts = record(value, [
83
- "currency",
84
- "subtotal",
85
- "taxes",
86
- "total"
87
- ]);
88
- if (typeof facts.currency !== "string" || facts.currency !== currency) return invalid("currency must match the expense.");
89
- assertIsoCurrency("Expense", "<financial-breakdown>", facts.currency);
90
- const total = minor(facts.total);
91
- const subtotal = minor(facts.subtotal);
92
- if (total !== minor(amount)) return invalid("total must match the expense amount.");
93
- const consistent = (part) => part === 0n || (total > 0n ? part > 0n : total < 0n && part < 0n);
94
- if (!consistent(subtotal)) return invalid("subtotal sign must match total.");
95
- if (!Array.isArray(facts.taxes)) return invalid("taxes must be an explicit array.");
96
- let tax = 0n;
97
- let recoverable = 0n;
98
- const codes = /* @__PURE__ */ new Set();
99
- for (const item of facts.taxes) {
100
- const component = record(item, [
101
- "code",
102
- "amount",
103
- "recoverableAmount"
104
- ]);
105
- if (typeof component.code !== "string" || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(component.code) || component.code.length > 64 || codes.has(component.code)) return invalid("tax codes must be unique lower-case kebab-case identifiers.");
106
- codes.add(component.code);
107
- const charged = minor(component.amount);
108
- const recovered = minor(component.recoverableAmount);
109
- const chargedMagnitude = charged < 0n ? -charged : charged;
110
- const recoveredMagnitude = recovered < 0n ? -recovered : recovered;
111
- if (!consistent(charged) || !consistent(recovered) || recoveredMagnitude > chargedMagnitude) return invalid("tax and recovery must share the total sign, with recovery bounded by tax.");
112
- tax += charged;
113
- recoverable += recovered;
114
- }
115
- if (subtotal + tax !== total) return invalid("subtotal plus taxes must equal total.");
116
- return {
117
- currency,
118
- subtotal: safe(subtotal),
119
- taxTotal: safe(tax),
120
- recoverableTax: safe(recoverable),
121
- nonRecoverableTax: safe(tax - recoverable),
122
- total: safe(total),
123
- costBasis: safe(total - recoverable)
124
- };
125
- }
126
- function parseExpenseFinancialBreakdown(stored, amount, currency) {
127
- if (stored === "" || stored === null || stored === void 0) return null;
128
- if (typeof stored !== "string") return invalid("storage must be JSON text.");
129
- let parsed;
130
- try {
131
- parsed = JSON.parse(stored);
132
- } catch {
133
- return invalid("invalid JSON.");
134
- }
135
- expenseCostBasis(parsed, amount, currency);
136
- return parsed;
137
- }
138
- //#endregion
139
- //#region src/models/Expense.ts
140
- var __defProp = Object.defineProperty;
141
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
142
- var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
143
- enumerable: true,
144
- configurable: true,
145
- writable: true,
146
- value
147
- }) : obj[key] = value;
148
- var __decorateClass = (decorators, target, key, kind) => {
149
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
150
- for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
151
- if (kind && result) __defProp(target, key, result);
152
- return result;
153
- };
154
- var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
155
- var PROFILE = "@happyvertical/smrt-profiles:Profile";
156
- var REVIEW_TRANSITIONS = {
157
- review: {
158
- from: ["unreviewed"],
159
- to: "reviewed"
160
- },
161
- reject: {
162
- from: ["unreviewed", "reviewed"],
163
- to: "rejected"
164
- },
165
- markDuplicate: {
166
- from: ["unreviewed", "reviewed"],
167
- to: "rejected"
168
- },
169
- reopen: {
170
- from: ["reviewed", "rejected"],
171
- to: "unreviewed"
172
- }
173
- };
174
- var LOCKED_WHEN_REVIEWED = [
175
- ["amount", "amount"],
176
- ["financialBreakdown", "financial_breakdown"],
177
- ["paidByName", "paid_by_name"],
178
- ["refundOfId", "refund_of_id"],
179
- ["currency", "currency"],
180
- ["incurredOn", "incurred_on"],
181
- ["recordedAt", "recorded_at"],
182
- ["category", "category"],
183
- ["costObjectType", "cost_object_type"],
184
- ["costObjectId", "cost_object_id"],
185
- ["vendorId", "vendor_id"],
186
- ["commitmentId", "commitment_id"],
187
- ["commitmentLineId", "commitment_line_id"],
188
- ["paidBy", "paid_by"],
189
- ["paidByProfileId", "paid_by_profile_id"]
190
- ];
191
- var authorizedTransition = /* @__PURE__ */ new WeakMap();
192
- function sameReviewState(a, b) {
193
- return a.status === b.status && a.reviewer === b.reviewer && a.reviewedAtMs === b.reviewedAtMs && a.note === b.note && a.duplicateOf === b.duplicateOf;
194
- }
195
- function asNullableString(value) {
196
- return value === null || value === void 0 || value === "" ? null : String(value);
197
- }
198
- var Expense = class extends SmrtObject {
199
- tenantId = null;
200
- costObjectType = "";
201
- costObjectId = "";
202
- category = "other";
203
- description = "";
204
- amount = 0;
205
- currency = "";
206
- financialBreakdown = null;
207
- paidByName = null;
208
- refundOfId = null;
209
- incurredOn = "";
210
- recordedAt = null;
211
- recordedByProfileId = null;
212
- paidBy = "company";
213
- paidByProfileId = null;
214
- vendorId = null;
215
- commitmentId = null;
216
- commitmentLineId = null;
217
- reviewStatus = "unreviewed";
218
- reviewedByProfileId = null;
219
- reviewedAt = null;
220
- reviewNote = "";
221
- duplicateOfId = null;
222
- reimbursable = false;
223
- reimbursedAt = null;
224
- reimbursementReference = "";
225
- constructor(options = {}) {
226
- super(options);
227
- if (options.tenantId !== void 0) this.tenantId = options.tenantId;
228
- if (options.costObjectType !== void 0) this.costObjectType = options.costObjectType;
229
- if (options.costObjectId !== void 0) this.costObjectId = options.costObjectId;
230
- if (options.category !== void 0) this.category = options.category;
231
- if (options.description !== void 0) this.description = options.description;
232
- if (options.amount !== void 0) this.amount = options.amount;
233
- if (options.currency !== void 0) this.currency = options.currency;
234
- if (options.financialBreakdown !== void 0) this.financialBreakdown = options.financialBreakdown;
235
- if (options.paidByName !== void 0) this.paidByName = options.paidByName;
236
- if (options.refundOfId !== void 0) this.refundOfId = options.refundOfId;
237
- if (options.incurredOn !== void 0) this.incurredOn = options.incurredOn;
238
- if (options.recordedAt !== void 0) this.recordedAt = options.recordedAt;
239
- if (options.recordedByProfileId !== void 0) this.recordedByProfileId = options.recordedByProfileId;
240
- if (options.paidBy !== void 0) this.paidBy = options.paidBy;
241
- if (options.paidByProfileId !== void 0) this.paidByProfileId = options.paidByProfileId;
242
- if (options.vendorId !== void 0) this.vendorId = options.vendorId;
243
- if (options.commitmentId !== void 0) this.commitmentId = options.commitmentId;
244
- if (options.commitmentLineId !== void 0) this.commitmentLineId = options.commitmentLineId;
245
- if (options.reimbursable !== void 0) this.reimbursable = options.reimbursable;
246
- if (options.reimbursedAt !== void 0) this.reimbursedAt = options.reimbursedAt;
247
- if (options.reimbursementReference !== void 0) this.reimbursementReference = options.reimbursementReference;
248
- }
249
- /** Read validated supplier facts, or null when none were recorded. */
250
- getFinancialBreakdown() {
251
- return parseExpenseFinancialBreakdown(this.financialBreakdown, this.amount, this.currency);
252
- }
253
- /** Set complete supplier facts; null explicitly clears facts on an unreviewed row. */
254
- setFinancialBreakdown(value) {
255
- if (value === null) {
256
- this.financialBreakdown = null;
257
- return;
258
- }
259
- expenseCostBasis(value, this.amount, this.currency);
260
- this.financialBreakdown = JSON.stringify(value);
261
- }
262
- /** Return exact retained purchase cost; legacy unknown tax facts return null. */
263
- getCostBasis() {
264
- const facts = this.getFinancialBreakdown();
265
- return facts === null ? null : expenseCostBasis(facts, this.amount, this.currency);
266
- }
267
- /** True when the person who paid has been paid back. */
268
- isReimbursed() {
269
- return this.reimbursedAt !== null && this.reimbursedAt !== void 0;
270
- }
271
- /** True when this expense counts toward reviewed totals and drawdown. */
272
- isCountable() {
273
- return this.reviewStatus === "reviewed" && !this.duplicateOfId;
274
- }
275
- /**
276
- * Accept the expense as a real cost: `unreviewed → reviewed`.
277
- *
278
- * This is the separately authorizable review action: gate it in your route
279
- * or service (for example on `expenses.review`). The expense must already
280
- * be saved and must not be marked a duplicate.
281
- *
282
- * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from any other state.
283
- */
284
- async review(input) {
285
- const reviewer = requireReviewer(input?.reviewerProfileId);
286
- return this.transition("review", () => {
287
- this.reviewStatus = "reviewed";
288
- this.reviewedByProfileId = reviewer;
289
- this.reviewedAt = input.at ?? /* @__PURE__ */ new Date();
290
- this.reviewNote = input.note ?? "";
291
- this.duplicateOfId = null;
292
- });
293
- }
294
- /**
295
- * Refuse the expense: `unreviewed | reviewed → rejected`. A rejected
296
- * expense is kept and shown, and never counted.
297
- *
298
- * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from `rejected`, or
299
- * `EXPENSE_INVALID` without a reason.
300
- */
301
- async reject(input) {
302
- const reviewer = requireReviewer(input?.reviewerProfileId);
303
- const reason = (input.reason ?? "").trim();
304
- if (!reason) throw new ExpenseError("EXPENSE_INVALID", "A rejection needs a reason.");
305
- return this.transition("reject", () => {
306
- this.reviewStatus = "rejected";
307
- this.reviewedByProfileId = reviewer;
308
- this.reviewedAt = input.at ?? /* @__PURE__ */ new Date();
309
- this.reviewNote = reason;
310
- this.duplicateOfId = null;
311
- });
312
- }
313
- /**
314
- * Mark this expense a duplicate of another expense in the same tenant:
315
- * `unreviewed | reviewed → rejected` with {@link Expense.duplicateOfId}
316
- * set. A duplicate never counts toward any total.
317
- *
318
- * @throws {ExpenseError} `EXPENSE_INVALID` when the target is this expense
319
- * or is not visible in this expense's tenant.
320
- */
321
- async markDuplicate(input) {
322
- const reviewer = requireReviewer(input?.reviewerProfileId);
323
- const targetId = String(input.duplicateOfId ?? "").trim();
324
- if (!targetId || targetId === this.id) throw new ExpenseError("EXPENSE_INVALID", "An expense can only be a duplicate of a different expense.");
325
- const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
326
- const target = await (await ExpenseCollection.create({ db: this.db })).get({ id: targetId });
327
- if (!target || (target.tenantId ?? null) !== (this.tenantId ?? null)) throw new ExpenseError("EXPENSE_INVALID", `Expense ${targetId} was not found in this expense's tenant.`);
328
- return this.transition("markDuplicate", () => {
329
- this.reviewStatus = "rejected";
330
- this.reviewedByProfileId = reviewer;
331
- this.reviewedAt = input.at ?? /* @__PURE__ */ new Date();
332
- this.reviewNote = `Duplicate of expense ${targetId}`;
333
- this.duplicateOfId = targetId;
334
- });
335
- }
336
- /**
337
- * Send the expense back for review: `reviewed | rejected → unreviewed`.
338
- * Clears the reviewer, the review time and any duplicate marker.
339
- *
340
- * @throws {ExpenseError} `EXPENSE_REVIEW_TRANSITION` from `unreviewed`.
341
- */
342
- async reopen(input) {
343
- requireReviewer(input?.reviewerProfileId);
344
- return this.transition("reopen", () => {
345
- this.reviewStatus = "unreviewed";
346
- this.reviewedByProfileId = null;
347
- this.reviewedAt = null;
348
- this.reviewNote = input.note ?? "";
349
- this.duplicateOfId = null;
350
- });
351
- }
352
- /**
353
- * Record that the person who paid has been paid back. Recording only:
354
- * paying happens in payroll or payables, and this never changes the review
355
- * state.
356
- *
357
- * @throws {ExpenseError} `EXPENSE_INVALID` unless the expense is
358
- * `reimbursable`.
359
- */
360
- async markReimbursed(input = {}) {
361
- if (!this.reimbursable) throw new ExpenseError("EXPENSE_INVALID", `Expense ${this.id} is not owed back to anyone.`);
362
- this.reimbursedAt = input.at ?? /* @__PURE__ */ new Date();
363
- if (input.reference !== void 0) this.reimbursementReference = input.reference;
364
- return this.save();
365
- }
366
- /**
367
- * Load the cost object this expense is charged to, through its registered
368
- * collection (so tenant interceptors apply). Returns `null` when unset,
369
- * when the type is not registered, when the row is gone, or when the row
370
- * belongs to a different tenant than this expense.
371
- */
372
- async getCostObject() {
373
- if (!this.costObjectType || !this.costObjectId) return null;
374
- let registered = ObjectRegistry.getClassByQualifiedName(this.costObjectType);
375
- if (!registered) {
376
- if (!await ObjectRegistry.tryLoadFromExternalPackage(this.costObjectType)) return null;
377
- registered = ObjectRegistry.getClassByQualifiedName(this.costObjectType);
378
- }
379
- if (!registered) return null;
380
- const target = await (await ObjectRegistry.getCollection(registered.qualifiedName ?? this.costObjectType, { db: this.db })).get({ id: this.costObjectId });
381
- if (!target) return null;
382
- const targetTenant = target.tenantId;
383
- if (targetTenant !== void 0 && targetTenant !== null && this.tenantId !== null && targetTenant !== this.tenantId) return null;
384
- return target;
385
- }
386
- /**
387
- * Validate, enforce the review and reviewed-field locks, check the
388
- * commitment, then persist.
389
- */
390
- async save() {
391
- this.normalize();
392
- this.validateShape();
393
- return this.withTransaction(() => this.saveValidated());
394
- }
395
- /** Keep both directions of refund validation and the write on one executor. */
396
- async saveValidated() {
397
- await this.lockRefundRows();
398
- const persisted = await this.readPersisted();
399
- const inserting = await pinNaturalKey("Expense", this, persisted);
400
- 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.`);
401
- this.assertReviewFields(persisted);
402
- this.assertReviewedLock(persisted);
403
- await this.assertCommitment(persisted);
404
- await this.assertVendor(persisted);
405
- await this.assertInboundRefunds(persisted);
406
- await this.assertRefund(persisted);
407
- if (!this.recordedAt) this.recordedAt = /* @__PURE__ */ new Date();
408
- try {
409
- return await super.save();
410
- } catch (error) {
411
- if (inserting && isUniqueViolationError(error)) throw identityConflict("Expense", this, error);
412
- throw error;
413
- }
414
- }
415
- normalize() {
416
- this.tenantId = normalizeTenantId(this.tenantId);
417
- this.currency = String(this.currency ?? "").trim().toUpperCase();
418
- this.category = String(this.category ?? "").trim();
419
- this.costObjectType = String(this.costObjectType ?? "").trim();
420
- this.costObjectId = String(this.costObjectId ?? "").trim();
421
- this.incurredOn = String(this.incurredOn ?? "").trim();
422
- this.vendorId = asNullableString(this.vendorId);
423
- this.commitmentId = asNullableString(this.commitmentId);
424
- this.commitmentLineId = asNullableString(this.commitmentLineId);
425
- this.paidByProfileId = asNullableString(this.paidByProfileId);
426
- this.recordedByProfileId = asNullableString(this.recordedByProfileId);
427
- }
428
- validateShape() {
429
- const label = this.id || "<new>";
430
- assertMinorUnits("Expense", label, "amount", this.refundOfId && typeof this.amount === "number" ? Math.abs(this.amount) : this.amount);
431
- assertIsoCurrency("Expense", label, this.currency);
432
- assertIsoDate("Expense", label, "incurredOn", this.incurredOn);
433
- 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}'.`);
434
- if (Boolean(this.costObjectType) !== Boolean(this.costObjectId)) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: costObjectType and costObjectId are set together or not at all.`);
435
- if (this.costObjectType) assertQualifiedClassName("Expense", label, this.costObjectType);
436
- this.getFinancialBreakdown();
437
- 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.");
438
- 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.");
439
- 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'.`);
440
- if (this.paidBy === "person" && !this.paidByProfileId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: an expense paid by a person names that person (paidByProfileId).`);
441
- if (this.paidBy !== "person" && this.paidByProfileId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: paidByProfileId is only set when paidBy is 'person'.`);
442
- if (this.reimbursable && this.paidBy !== "person") throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: only an expense a person paid can be owed back.`);
443
- if (this.reimbursedAt && !this.reimbursable) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: reimbursedAt needs reimbursable.`);
444
- if (this.reviewStatus === "reviewed" && this.duplicateOfId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: a reviewed expense cannot be marked a duplicate.`);
445
- if (this.commitmentLineId && !this.commitmentId) throw new ExpenseError("EXPENSE_INVALID", `Expense ${label}: a commitment line needs its commitment (commitmentId).`);
446
- }
447
- /**
448
- * Read the stored row directly. The authoritative prior state for the
449
- * review and lock guards: the in-memory copy may have been mass-assigned.
450
- */
451
- async readPersisted() {
452
- if (!this.id) return null;
453
- return await this.db.get(this.tableName, { id: this.id }) ?? null;
454
- }
455
- reviewState() {
456
- return {
457
- status: this.reviewStatus,
458
- reviewer: asNullableString(this.reviewedByProfileId),
459
- reviewedAtMs: instantMs(this.reviewedAt),
460
- note: this.reviewNote ?? "",
461
- duplicateOf: asNullableString(this.duplicateOfId)
462
- };
463
- }
464
- /**
465
- * Refuse any review-field change a transition did not authorize. The token
466
- * binds the transition's complete resulting state — status, reviewer,
467
- * time, note and duplicate marker — so a field set directly beside a
468
- * legitimate `review()` / `reject()` cannot ride along with it.
469
- */
470
- assertReviewFields(persisted) {
471
- const token = authorizedTransition.get(this);
472
- const prior = {
473
- status: persisted?.review_status ?? "unreviewed",
474
- reviewer: asNullableString(persisted?.reviewed_by_profile_id),
475
- reviewedAtMs: instantMs(persisted?.reviewed_at),
476
- note: String(persisted?.review_note ?? ""),
477
- duplicateOf: asNullableString(persisted?.duplicate_of_id)
478
- };
479
- const current = this.reviewState();
480
- if (sameReviewState(current, prior)) return;
481
- 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().`);
482
- }
483
- assertReviewedLock(persisted) {
484
- if (persisted?.review_status !== "reviewed") return;
485
- const self = this;
486
- for (const [fieldName, column] of LOCKED_WHEN_REVIEWED) {
487
- const before = persisted[column];
488
- const after = self[fieldName];
489
- 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.`);
490
- }
491
- }
492
- /**
493
- * A matched commitment must be visible in this expense's tenant, owned by
494
- * the same tenant, and in the same currency; a matched line must belong to
495
- * it. Mixed currencies are refused here rather than summed later.
496
- *
497
- * Checked when the match is made or changed, not on every save: a later
498
- * change to the commitment (its currency, or its deletion) must not stop
499
- * an unrelated save such as recording a reimbursement.
500
- */
501
- async assertCommitment(persisted) {
502
- if (!this.commitmentId) return;
503
- if (persisted && asNullableString(persisted.commitment_id) === this.commitmentId && asNullableString(persisted.commitment_line_id) === this.commitmentLineId && String(persisted.currency ?? "") === this.currency) return;
504
- const commitment = await (await PurchaseContractCollection.create({ db: this.db })).get({ id: this.commitmentId });
505
- const owner = effectiveTenant(this.tenantId);
506
- 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.`);
507
- 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.`);
508
- if (this.commitmentLineId) {
509
- const line = await (await ContractLineItemCollection.create({ db: this.db })).get({ id: this.commitmentLineId });
510
- 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}.`);
511
- }
512
- }
513
- /**
514
- * A vendor must be visible in, and owned by, the expense's tenant. A
515
- * `crossPackageRef` neither validates its target nor creates a foreign
516
- * key, so without this an expense in one tenant could name another
517
- * tenant's vendor. Checked when the vendor is set or changed.
518
- */
519
- async assertVendor(persisted) {
520
- if (!this.vendorId) return;
521
- if (persisted && asNullableString(persisted.vendor_id) === this.vendorId) return;
522
- const vendor = await (await VendorCollection.create({ db: this.db })).get({ id: this.vendorId });
523
- const owner = effectiveTenant(this.tenantId);
524
- 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.`);
525
- }
526
- /**
527
- * Source edits and refund writes contend on the same source row. Acquire
528
- * rows in deterministic order before either relationship check, and hold
529
- * them through commit. SQLite's write transaction supplies serialization.
530
- */
531
- async lockRefundRows() {
532
- if (!isPostgresDatabase(this.db)) return;
533
- const ids = [...new Set([this.id, this.refundOfId].filter((id) => Boolean(id)))].sort();
534
- if (ids.length === 0) return;
535
- const table = `"${this.tableName.replace(/"/g, "\"\"")}"`;
536
- 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));
537
- }
538
- /** Refuse source corrections that invalidate any same-owner refund. */
539
- async assertInboundRefunds(persisted) {
540
- if (!persisted || Number(persisted.amount) === this.amount && String(persisted.currency ?? "") === this.currency && asNullableString(persisted.refund_of_id) === this.refundOfId) return;
541
- const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
542
- if ((await (await ExpenseCollection.create({ db: this.db })).list({
543
- where: {
544
- refundOfId: this.id,
545
- tenantId: effectiveTenant(this.tenantId),
546
- ...this.amount > 0 && !this.refundOfId ? { "currency !=": this.currency } : {}
547
- },
548
- select: ["id"],
549
- limit: 1,
550
- cache: false
551
- })).length > 0) throw new ExpenseError("EXPENSE_INVALID", "A referenced refund source must remain positive, non-refund, and in the currency of its refunds.");
552
- }
553
- async assertRefund(persisted) {
554
- if (!this.refundOfId) return;
555
- 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.");
556
- const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
557
- const source = await (await ExpenseCollection.create({ db: this.db })).get({ id: this.refundOfId }, { cache: false });
558
- 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.");
559
- if (!persisted || persisted.refund_of_id !== this.refundOfId) {
560
- if (!source.updated_at) throw new ExpenseError("EXPENSE_INVALID", "Refund source has no revision.");
561
- await source.claimRevision(source.updated_at);
562
- }
563
- }
564
- async transition(action, apply) {
565
- const rule = REVIEW_TRANSITIONS[action];
566
- const persisted = await this.readPersisted();
567
- if (!persisted) throw new ExpenseError("EXPENSE_REVIEW_TRANSITION", `Expense ${this.id || "<new>"}: save the expense before ${action}().`);
568
- const from = String(persisted.review_status);
569
- if (!rule.from.includes(from)) throw new ExpenseError("EXPENSE_REVIEW_TRANSITION", `Expense ${this.id}: cannot ${action}() an expense that is ${from}.`);
570
- if (action === "review" && persisted.duplicate_of_id) throw new ExpenseError("EXPENSE_REVIEW_TRANSITION", `Expense ${this.id}: a duplicate cannot be reviewed; reopen() it first.`);
571
- const snapshot = {
572
- reviewStatus: this.reviewStatus,
573
- reviewedByProfileId: this.reviewedByProfileId,
574
- reviewedAt: this.reviewedAt,
575
- reviewNote: this.reviewNote,
576
- duplicateOfId: this.duplicateOfId
577
- };
578
- apply();
579
- authorizedTransition.set(this, {
580
- from,
581
- expected: this.reviewState()
582
- });
583
- try {
584
- return await this.save();
585
- } catch (error) {
586
- Object.assign(this, snapshot);
587
- throw error;
588
- } finally {
589
- authorizedTransition.delete(this);
590
- }
591
- }
592
- };
593
- __publicField(Expense, "__smrtPackage__", "@happyvertical/smrt-expenses");
594
- __decorateClass([tenantId({ nullable: true })], Expense.prototype, "tenantId", 2);
595
- __decorateClass([field({ type: "text" })], Expense.prototype, "costObjectType", 2);
596
- __decorateClass([field({ type: "text" })], Expense.prototype, "costObjectId", 2);
597
- __decorateClass([field({ type: "text" })], Expense.prototype, "category", 2);
598
- __decorateClass([field({ type: "text" })], Expense.prototype, "description", 2);
599
- __decorateClass([field({ type: "integer" })], Expense.prototype, "amount", 2);
600
- __decorateClass([field({ type: "text" })], Expense.prototype, "currency", 2);
601
- __decorateClass([field({ type: "text" })], Expense.prototype, "financialBreakdown", 2);
602
- __decorateClass([field({ type: "text" })], Expense.prototype, "paidByName", 2);
603
- __decorateClass([foreignKey("Expense", { onDelete: "RESTRICT" })], Expense.prototype, "refundOfId", 2);
604
- __decorateClass([field({ type: "text" })], Expense.prototype, "incurredOn", 2);
605
- __decorateClass([field({ type: "datetime" })], Expense.prototype, "recordedAt", 2);
606
- __decorateClass([crossPackageRef(PROFILE)], Expense.prototype, "recordedByProfileId", 2);
607
- __decorateClass([field({ type: "text" })], Expense.prototype, "paidBy", 2);
608
- __decorateClass([crossPackageRef(PROFILE)], Expense.prototype, "paidByProfileId", 2);
609
- __decorateClass([crossPackageRef("@happyvertical/smrt-commerce:Vendor")], Expense.prototype, "vendorId", 2);
610
- __decorateClass([crossPackageRef("@happyvertical/smrt-commerce:PurchaseContract", { validateSubtype: true })], Expense.prototype, "commitmentId", 2);
611
- __decorateClass([crossPackageRef("@happyvertical/smrt-commerce:ContractLineItem")], Expense.prototype, "commitmentLineId", 2);
612
- __decorateClass([field({
613
- type: "text",
614
- readonly: true
615
- })], Expense.prototype, "reviewStatus", 2);
616
- __decorateClass([crossPackageRef(PROFILE, { readonly: true })], Expense.prototype, "reviewedByProfileId", 2);
617
- __decorateClass([field({
618
- type: "datetime",
619
- readonly: true
620
- })], Expense.prototype, "reviewedAt", 2);
621
- __decorateClass([field({
622
- type: "text",
623
- readonly: true
624
- })], Expense.prototype, "reviewNote", 2);
625
- __decorateClass([foreignKey("Expense", {
626
- readonly: true,
627
- onDelete: "SET NULL"
628
- })], Expense.prototype, "duplicateOfId", 2);
629
- __decorateClass([field({ type: "boolean" })], Expense.prototype, "reimbursable", 2);
630
- __decorateClass([field({ type: "datetime" })], Expense.prototype, "reimbursedAt", 2);
631
- __decorateClass([field({ type: "text" })], Expense.prototype, "reimbursementReference", 2);
632
- Expense = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
633
- tableName: "expenses",
634
- api: { include: [
635
- "record",
636
- "attachReceipt",
637
- "recordReview",
638
- "recordReimbursement"
639
- ] },
640
- mcp: { include: [] },
641
- cli: false,
642
- indexes: [
643
- {
644
- name: "expenses_cost_object_idx",
645
- columns: [
646
- "tenantId",
647
- "costObjectType",
648
- "costObjectId",
649
- "incurredOn"
650
- ]
651
- },
652
- {
653
- name: "expenses_commitment_review_idx",
654
- columns: [
655
- "tenantId",
656
- "commitmentId",
657
- "reviewStatus"
658
- ]
659
- },
660
- {
661
- name: "expenses_review_status_idx",
662
- columns: [
663
- "tenantId",
664
- "reviewStatus",
665
- "incurredOn"
666
- ]
667
- }
668
- ]
669
- })], Expense);
670
- function requireReviewer(reviewerProfileId) {
671
- const reviewer = String(reviewerProfileId ?? "").trim();
672
- if (!reviewer) throw new ExpenseError("EXPENSE_INVALID", "A review decision names its reviewer (reviewerProfileId).");
673
- return reviewer;
674
- }
675
- function sameTenant(a, b) {
676
- return (a ?? null) === (b ?? null);
677
- }
678
- //#endregion
679
- 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 };
680
-
681
- //# sourceMappingURL=Expense-7afLcUTn.js.map