@happyvertical/smrt-expenses 0.55.24 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +36 -5
- package/README.md +18 -4
- package/dist/actions/expense-actions.d.ts +77 -0
- package/dist/actions/expense-actions.d.ts.map +1 -0
- package/dist/actions/principal.d.ts +54 -0
- package/dist/actions/principal.d.ts.map +1 -0
- package/dist/chunks/{Expense-CY5jw_80.js → Expense-7afLcUTn.js} +12 -7
- package/dist/chunks/Expense-7afLcUTn.js.map +1 -0
- package/dist/chunks/ExpenseReceiptCollection-y1SlmnBS.js +304 -0
- package/dist/chunks/ExpenseReceiptCollection-y1SlmnBS.js.map +1 -0
- package/dist/chunks/expense-actions-Ce5CBaBE.js +132 -0
- package/dist/chunks/expense-actions-Ce5CBaBE.js.map +1 -0
- package/dist/chunks/principal-_1CdcvvW.js +107 -0
- package/dist/chunks/principal-_1CdcvvW.js.map +1 -0
- package/dist/collections/ExpenseCollection.d.ts +55 -3
- package/dist/collections/ExpenseCollection.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +180 -312
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +273 -8
- package/dist/models/Expense.d.ts +9 -5
- package/dist/models/Expense.d.ts.map +1 -1
- package/dist/recipes.d.ts +118 -0
- package/dist/recipes.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +277 -10
- package/package.json +9 -8
- package/dist/chunks/Expense-CY5jw_80.js.map +0 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { DuplicateReceiptError, ExpenseError } from "../types.js";
|
|
2
|
+
import { a as identityConflict, c as pinNaturalKey, i as effectiveTenant, n as assertMinorUnits, o as normalizeSha256, s as normalizeTenantId, t as Expense } from "./Expense-7afLcUTn.js";
|
|
3
|
+
import { SmrtJunction, SmrtObject, classifyDatabaseError, crossPackageRef, field, foreignKey, isUniqueViolationError, smrt } from "@happyvertical/smrt-core";
|
|
4
|
+
import { TenantScoped, tenantId } from "@happyvertical/smrt-tenancy";
|
|
5
|
+
import { AssetCollection } from "@happyvertical/smrt-assets";
|
|
6
|
+
//#region \0rolldown/runtime.js
|
|
7
|
+
var __defProp$2 = Object.defineProperty;
|
|
8
|
+
var __exportAll = (all, no_symbols) => {
|
|
9
|
+
let target = {};
|
|
10
|
+
for (var name in all) __defProp$2(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true
|
|
13
|
+
});
|
|
14
|
+
if (!no_symbols) __defProp$2(target, Symbol.toStringTag, { value: "Module" });
|
|
15
|
+
return target;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/models/ExpenseReceipt.ts
|
|
19
|
+
var __defProp$1 = Object.defineProperty;
|
|
20
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
21
|
+
var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, {
|
|
22
|
+
enumerable: true,
|
|
23
|
+
configurable: true,
|
|
24
|
+
writable: true,
|
|
25
|
+
value
|
|
26
|
+
}) : obj[key] = value;
|
|
27
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
28
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
29
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
30
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
var __publicField$1 = (obj, key, value) => __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
34
|
+
var RECEIPT_FILE_KEY = "expense_receipts_expense_sha256_key";
|
|
35
|
+
var RECEIPT_FILE_COLUMNS = /* @__PURE__ */ new Set(["expense_id", "content_sha256"]);
|
|
36
|
+
function violatedColumns(error) {
|
|
37
|
+
const details = error?.details;
|
|
38
|
+
return (typeof details?.fieldName === "string" ? details.fieldName : "").split(",").map((column) => column.trim()).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
var IMMUTABLE = [
|
|
41
|
+
["tenantId", "tenant_id"],
|
|
42
|
+
["expenseId", "expense_id"],
|
|
43
|
+
["assetId", "asset_id"],
|
|
44
|
+
["contentSha256", "content_sha256"]
|
|
45
|
+
];
|
|
46
|
+
var ExpenseReceipt = class extends SmrtObject {
|
|
47
|
+
tenantId = null;
|
|
48
|
+
expenseId = "";
|
|
49
|
+
assetId = "";
|
|
50
|
+
uploadedByProfileId = null;
|
|
51
|
+
filename = "";
|
|
52
|
+
mimeType = "";
|
|
53
|
+
byteCount = 0;
|
|
54
|
+
contentSha256 = "";
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
super(options);
|
|
57
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
58
|
+
if (options.expenseId !== void 0) this.expenseId = options.expenseId;
|
|
59
|
+
if (options.assetId !== void 0) this.assetId = options.assetId;
|
|
60
|
+
if (options.uploadedByProfileId !== void 0) this.uploadedByProfileId = options.uploadedByProfileId;
|
|
61
|
+
if (options.filename !== void 0) this.filename = options.filename;
|
|
62
|
+
if (options.mimeType !== void 0) this.mimeType = options.mimeType;
|
|
63
|
+
if (options.byteCount !== void 0) this.byteCount = options.byteCount;
|
|
64
|
+
if (options.contentSha256 !== void 0) this.contentSha256 = options.contentSha256;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validate the link, refuse a second copy of the same file on the same
|
|
68
|
+
* expense, then persist. Never touches the expense.
|
|
69
|
+
*
|
|
70
|
+
* @throws {DuplicateReceiptError} when the expense already has this file.
|
|
71
|
+
* @throws {ExpenseError} `EXPENSE_RECEIPT_INVALID` for a bad hash, a
|
|
72
|
+
* missing or foreign-tenant expense or asset, or a change to an
|
|
73
|
+
* immutable column.
|
|
74
|
+
*/
|
|
75
|
+
async save() {
|
|
76
|
+
const label = this.id || "<new>";
|
|
77
|
+
this.tenantId = normalizeTenantId(this.tenantId);
|
|
78
|
+
this.contentSha256 = normalizeSha256("ExpenseReceipt", label, this.contentSha256);
|
|
79
|
+
assertMinorUnits("ExpenseReceipt", label, "byteCount", this.byteCount, "EXPENSE_RECEIPT_INVALID");
|
|
80
|
+
if (!this.expenseId || !this.assetId) throw new ExpenseError("EXPENSE_RECEIPT_INVALID", `ExpenseReceipt ${label}: expenseId and assetId are required.`);
|
|
81
|
+
const persisted = this.id ? await this.db.get(this.tableName, { id: this.id }) ?? null : null;
|
|
82
|
+
const inserting = await pinNaturalKey("ExpenseReceipt", this, persisted);
|
|
83
|
+
if (persisted) {
|
|
84
|
+
const self = this;
|
|
85
|
+
for (const [fieldName, column] of IMMUTABLE) {
|
|
86
|
+
const value = fieldName === "tenantId" ? effectiveTenant(this.tenantId) : self[fieldName];
|
|
87
|
+
if (String(persisted[column] ?? "") !== String(value ?? "")) throw new ExpenseError("EXPENSE_RECEIPT_INVALID", `ExpenseReceipt ${this.id}: ${fieldName} cannot change; attach a new receipt instead.`);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
await this.assertLinkTargets(label);
|
|
91
|
+
await this.assertNotDuplicate();
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return await super.save();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (inserting && isUniqueViolationError(error)) throw await this.classifyInsertConflict(error);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A refused INSERT is either the same file racing onto the same expense,
|
|
102
|
+
* or a natural-key collision with another receipt.
|
|
103
|
+
*
|
|
104
|
+
* Classified from the error first, by the violated key's name or columns
|
|
105
|
+
* (no other unique key on this table uses `expense_id` or
|
|
106
|
+
* `content_sha256`). That matters inside a caller's transaction,
|
|
107
|
+
* which the violation has already aborted on PostgreSQL, so nothing could
|
|
108
|
+
* be re-read there. Only an adapter that reports neither falls back to a
|
|
109
|
+
* re-read; if that re-read fails too, the original error is kept rather
|
|
110
|
+
* than guessed at.
|
|
111
|
+
*/
|
|
112
|
+
async classifyInsertConflict(error) {
|
|
113
|
+
const info = classifyDatabaseError(error);
|
|
114
|
+
const columns = violatedColumns(error);
|
|
115
|
+
if (info.constraint === RECEIPT_FILE_KEY || info.driverMessages.some((text) => text.includes(RECEIPT_FILE_KEY)) || columns.some((column) => RECEIPT_FILE_COLUMNS.has(column))) return new DuplicateReceiptError(this.expenseId, this.contentSha256, { cause: error });
|
|
116
|
+
if (info.constraint || columns.length > 0) return identityConflict("ExpenseReceipt", this, error);
|
|
117
|
+
try {
|
|
118
|
+
await this.assertNotDuplicate();
|
|
119
|
+
} catch (duplicate) {
|
|
120
|
+
if (duplicate instanceof DuplicateReceiptError) return new DuplicateReceiptError(this.expenseId, this.contentSha256, { cause: error });
|
|
121
|
+
return error;
|
|
122
|
+
}
|
|
123
|
+
return identityConflict("ExpenseReceipt", this, error);
|
|
124
|
+
}
|
|
125
|
+
async assertLinkTargets(label) {
|
|
126
|
+
const { ExpenseCollection } = await import("../index.js").then((n) => n.t);
|
|
127
|
+
const expense = await (await ExpenseCollection.create({ db: this.db })).get({ id: this.expenseId });
|
|
128
|
+
if (!expense) throw new ExpenseError("EXPENSE_RECEIPT_INVALID", `ExpenseReceipt ${label}: expense ${this.expenseId} was not found.`);
|
|
129
|
+
if (this.tenantId === null || this.tenantId === void 0) this.tenantId = expense.tenantId ?? null;
|
|
130
|
+
if ((this.tenantId ?? null) !== (expense.tenantId ?? null)) throw new ExpenseError("EXPENSE_RECEIPT_INVALID", `ExpenseReceipt ${label}: a receipt belongs to its expense's tenant.`);
|
|
131
|
+
const asset = await (await AssetCollection.create({ db: this.db })).get({ id: this.assetId });
|
|
132
|
+
const assetTenant = asset?.tenantId ?? null;
|
|
133
|
+
if (!asset || assetTenant !== null && assetTenant !== this.tenantId) throw new ExpenseError("EXPENSE_RECEIPT_INVALID", `ExpenseReceipt ${label}: asset ${this.assetId} was not found in the expense's tenant.`);
|
|
134
|
+
if (!this.filename) this.filename = asset.name ?? "";
|
|
135
|
+
if (!this.mimeType) this.mimeType = asset.mimeType ?? "";
|
|
136
|
+
}
|
|
137
|
+
async assertNotDuplicate() {
|
|
138
|
+
const { ExpenseReceiptCollection } = await Promise.resolve().then(() => ExpenseReceiptCollection_exports);
|
|
139
|
+
if ((await (await ExpenseReceiptCollection.create({ db: this.db })).list({
|
|
140
|
+
where: {
|
|
141
|
+
expenseId: this.expenseId,
|
|
142
|
+
contentSha256: this.contentSha256
|
|
143
|
+
},
|
|
144
|
+
limit: 1
|
|
145
|
+
})).length > 0) throw new DuplicateReceiptError(this.expenseId, this.contentSha256);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
__publicField$1(ExpenseReceipt, "__smrtPackage__", "@happyvertical/smrt-expenses");
|
|
149
|
+
__decorateClass$1([tenantId({ nullable: true })], ExpenseReceipt.prototype, "tenantId", 2);
|
|
150
|
+
__decorateClass$1([foreignKey(Expense, {
|
|
151
|
+
required: true,
|
|
152
|
+
onDelete: "CASCADE"
|
|
153
|
+
})], ExpenseReceipt.prototype, "expenseId", 2);
|
|
154
|
+
__decorateClass$1([crossPackageRef("@happyvertical/smrt-assets:Asset", { required: true })], ExpenseReceipt.prototype, "assetId", 2);
|
|
155
|
+
__decorateClass$1([crossPackageRef("@happyvertical/smrt-profiles:Profile")], ExpenseReceipt.prototype, "uploadedByProfileId", 2);
|
|
156
|
+
__decorateClass$1([field({ type: "text" })], ExpenseReceipt.prototype, "filename", 2);
|
|
157
|
+
__decorateClass$1([field({ type: "text" })], ExpenseReceipt.prototype, "mimeType", 2);
|
|
158
|
+
__decorateClass$1([field({ type: "integer" })], ExpenseReceipt.prototype, "byteCount", 2);
|
|
159
|
+
__decorateClass$1([field({
|
|
160
|
+
type: "text",
|
|
161
|
+
required: true
|
|
162
|
+
})], ExpenseReceipt.prototype, "contentSha256", 2);
|
|
163
|
+
ExpenseReceipt = __decorateClass$1([TenantScoped({ mode: "optional" }), smrt({
|
|
164
|
+
tableName: "expense_receipts",
|
|
165
|
+
api: { include: [] },
|
|
166
|
+
mcp: { include: [] },
|
|
167
|
+
cli: false,
|
|
168
|
+
indexes: [{
|
|
169
|
+
name: "expense_receipts_expense_sha256_key",
|
|
170
|
+
columns: ["expenseId", "contentSha256"],
|
|
171
|
+
unique: true
|
|
172
|
+
}, {
|
|
173
|
+
name: "expense_receipts_tenant_sha256_idx",
|
|
174
|
+
columns: ["tenantId", "contentSha256"]
|
|
175
|
+
}]
|
|
176
|
+
})], ExpenseReceipt);
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/collections/ExpenseReceiptCollection.ts
|
|
179
|
+
var ExpenseReceiptCollection_exports = /* @__PURE__ */ __exportAll({ ExpenseReceiptCollection: () => ExpenseReceiptCollection });
|
|
180
|
+
var __defProp = Object.defineProperty;
|
|
181
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
182
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
183
|
+
enumerable: true,
|
|
184
|
+
configurable: true,
|
|
185
|
+
writable: true,
|
|
186
|
+
value
|
|
187
|
+
}) : obj[key] = value;
|
|
188
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
189
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
190
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
191
|
+
if (kind && result) __defProp(target, key, result);
|
|
192
|
+
return result;
|
|
193
|
+
};
|
|
194
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
195
|
+
var DEFAULT_DUPLICATE_LIMIT = 100;
|
|
196
|
+
var MAX_DUPLICATE_LIMIT = 1e3;
|
|
197
|
+
var ExpenseReceiptCollection = class extends SmrtJunction {
|
|
198
|
+
leftField = "expenseId";
|
|
199
|
+
rightField = "assetId";
|
|
200
|
+
sortField = "createdAt";
|
|
201
|
+
positionField = null;
|
|
202
|
+
/**
|
|
203
|
+
* Attach a receipt file to an expense.
|
|
204
|
+
*
|
|
205
|
+
* Does not change the expense in any way — in particular not its review
|
|
206
|
+
* state.
|
|
207
|
+
*
|
|
208
|
+
* @throws {DuplicateReceiptError} when this expense already carries a file
|
|
209
|
+
* with the same SHA-256.
|
|
210
|
+
*/
|
|
211
|
+
async attachReceipt(input) {
|
|
212
|
+
const receipt = await this.create({
|
|
213
|
+
expenseId: input.expenseId,
|
|
214
|
+
assetId: input.assetId,
|
|
215
|
+
contentSha256: input.contentSha256,
|
|
216
|
+
uploadedByProfileId: input.uploadedByProfileId ?? null,
|
|
217
|
+
filename: input.filename ?? "",
|
|
218
|
+
mimeType: input.mimeType ?? "",
|
|
219
|
+
byteCount: input.byteCount ?? 0
|
|
220
|
+
});
|
|
221
|
+
await receipt.save();
|
|
222
|
+
return receipt;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Refused: a receipt is evidence and needs its content hash, which a
|
|
226
|
+
* generic junction attach does not carry. Use {@link attachReceipt}.
|
|
227
|
+
*
|
|
228
|
+
* @throws {ExpenseError} `EXPENSE_RECEIPT_INVALID`, always.
|
|
229
|
+
*/
|
|
230
|
+
async attach(_expenseId, _assetId, _opts = {}) {
|
|
231
|
+
throw new ExpenseError("EXPENSE_RECEIPT_INVALID", "ExpenseReceiptCollection.attach() is not supported: use attachReceipt(), which records and checks the file hash.");
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Refused before anything is deleted. The inherited replace-all deletes
|
|
235
|
+
* every receipt of the expense and re-attaches without hashes, which would
|
|
236
|
+
* lose the evidence. Attach with {@link attachReceipt}; remove a link with
|
|
237
|
+
* `detach()`.
|
|
238
|
+
*
|
|
239
|
+
* @throws {ExpenseError} `EXPENSE_RECEIPT_INVALID`, always.
|
|
240
|
+
*/
|
|
241
|
+
async setLinks(_expenseId, _assetIds, _opts = {}) {
|
|
242
|
+
throw new ExpenseError("EXPENSE_RECEIPT_INVALID", "ExpenseReceiptCollection.setLinks() is not supported: it would delete receipt evidence. Use attachReceipt() to add and detach() to remove.");
|
|
243
|
+
}
|
|
244
|
+
/** Receipts of one expense, oldest first. */
|
|
245
|
+
async receiptsFor(expenseId) {
|
|
246
|
+
return this.byLeft(expenseId);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Files (by SHA-256) attached to more than one expense, for a person to
|
|
250
|
+
* review. Nothing is changed or rejected: the same receipt on two expenses
|
|
251
|
+
* may be a split cost or a double claim, and only a reviewer can tell.
|
|
252
|
+
*
|
|
253
|
+
* With `expenseId`, reports only files that expense shares with others;
|
|
254
|
+
* without it, scans the visible tenant (largest groups first).
|
|
255
|
+
*/
|
|
256
|
+
async findDuplicateReceipts(options = {}) {
|
|
257
|
+
const limit = Math.min(Math.max(Math.trunc(options.limit ?? DEFAULT_DUPLICATE_LIMIT), 1), MAX_DUPLICATE_LIMIT);
|
|
258
|
+
let hashes;
|
|
259
|
+
if (options.expenseId) {
|
|
260
|
+
const own = await this.byLeft(options.expenseId);
|
|
261
|
+
hashes = [...new Set(own.map((receipt) => receipt.contentSha256))];
|
|
262
|
+
} else {
|
|
263
|
+
const [facet] = await this.facets({ fields: [{
|
|
264
|
+
field: "contentSha256",
|
|
265
|
+
limit: MAX_DUPLICATE_LIMIT
|
|
266
|
+
}] });
|
|
267
|
+
hashes = (facet?.values ?? []).filter((value) => value.count > 1).map((value) => String(value.value)).slice(0, limit);
|
|
268
|
+
}
|
|
269
|
+
if (hashes.length === 0) return [];
|
|
270
|
+
const rows = await this.list({
|
|
271
|
+
where: { contentSha256: hashes },
|
|
272
|
+
orderBy: ["content_sha256 ASC", "created_at ASC"]
|
|
273
|
+
});
|
|
274
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
275
|
+
for (const receipt of rows) {
|
|
276
|
+
const group = byHash.get(receipt.contentSha256) ?? [];
|
|
277
|
+
group.push(receipt);
|
|
278
|
+
byHash.set(receipt.contentSha256, group);
|
|
279
|
+
}
|
|
280
|
+
const groups = [];
|
|
281
|
+
for (const [contentSha256, receipts] of byHash) {
|
|
282
|
+
const expenseIds = [...new Set(receipts.map((receipt) => receipt.expenseId))].sort();
|
|
283
|
+
if (expenseIds.length < 2) continue;
|
|
284
|
+
groups.push({
|
|
285
|
+
contentSha256,
|
|
286
|
+
expenseIds,
|
|
287
|
+
receipts
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
groups.sort((a, b) => b.expenseIds.length - a.expenseIds.length || a.contentSha256.localeCompare(b.contentSha256));
|
|
291
|
+
return groups.slice(0, limit);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
__publicField(ExpenseReceiptCollection, "__smrtPackage__", "@happyvertical/smrt-expenses");
|
|
295
|
+
__publicField(ExpenseReceiptCollection, "_itemClass", ExpenseReceipt);
|
|
296
|
+
ExpenseReceiptCollection = __decorateClass([smrt({
|
|
297
|
+
api: { include: [] },
|
|
298
|
+
mcp: { include: [] },
|
|
299
|
+
cli: false
|
|
300
|
+
})], ExpenseReceiptCollection);
|
|
301
|
+
//#endregion
|
|
302
|
+
export { ExpenseReceipt as n, __exportAll as r, ExpenseReceiptCollection as t };
|
|
303
|
+
|
|
304
|
+
//# sourceMappingURL=ExpenseReceiptCollection-y1SlmnBS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpenseReceiptCollection-y1SlmnBS.js","names":[],"sources":["../../src/models/ExpenseReceipt.ts","../../src/collections/ExpenseReceiptCollection.ts"],"sourcesContent":["/**\n * ExpenseReceipt — the expense-owned join between an {@link Expense} and the\n * smrt-assets `Asset` that proves it.\n *\n * @packageDocumentation\n */\n\nimport { AssetCollection } from '@happyvertical/smrt-assets';\nimport {\n classifyDatabaseError,\n crossPackageRef,\n field,\n foreignKey,\n isUniqueViolationError,\n SmrtObject,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport {\n DuplicateReceiptError,\n ExpenseError,\n type ExpenseReceiptOptions,\n} from '../types.js';\nimport {\n assertMinorUnits,\n effectiveTenant,\n identityConflict,\n normalizeSha256,\n normalizeTenantId,\n pinNaturalKey,\n} from '../validation.js';\nimport { Expense } from './Expense.js';\n\n/**\n * The unique index that refuses a second copy of a file on one expense. Kept\n * in step with the literal in the `@smrt()` indexes below (the scanner reads\n * that literal); the PostgreSQL schema test asserts the name.\n */\nconst RECEIPT_FILE_KEY = 'expense_receipts_expense_sha256_key';\n\n/** The columns of {@link RECEIPT_FILE_KEY}; no other unique key uses them. */\nconst RECEIPT_FILE_COLUMNS = new Set(['expense_id', 'content_sha256']);\n\n/**\n * The columns a unique violation reports. Core's typed write error carries\n * them in `details.fieldName` (recovered from PostgreSQL's\n * `Key (expense_id, content_sha256)=…` detail, or SQLite's column list) but\n * not the driver error itself, so this is where the violated key is named.\n */\nfunction violatedColumns(error: unknown): string[] {\n const details = (error as { details?: { fieldName?: unknown } } | null)\n ?.details;\n const fieldName =\n typeof details?.fieldName === 'string' ? details.fieldName : '';\n return fieldName\n .split(',')\n .map((column) => column.trim())\n .filter(Boolean);\n}\n\n/**\n * Columns a receipt keeps for life: it is evidence, not a draft. The tenant\n * is among them because it is derived from the expense on insert; letting a\n * later save (e.g. one without tenant context) change it would split the\n * receipt from its expense's tenant.\n */\nconst IMMUTABLE = [\n ['tenantId', 'tenant_id'],\n ['expenseId', 'expense_id'],\n ['assetId', 'asset_id'],\n ['contentSha256', 'content_sha256'],\n] as const;\n\n/**\n * A receipt file attached to an expense.\n *\n * Asset ownership uses a noun-specific join (`expense_receipts`), not the\n * generic `AssetAssociation`. The file's SHA-256 is stored so:\n *\n * - the same file attached twice to one expense is refused — a unique index\n * on `(expense_id, content_sha256)` plus a pre-insert check that raises\n * {@link DuplicateReceiptError}. The index leaves `tenant_id` out on\n * purpose: an expense belongs to exactly one tenant, so the key is already\n * tenant-scoped, and a NULL tenant would otherwise make every global row\n * distinct and the index unenforced;\n * - the same file on two different expenses is allowed, and surfaced for a\n * person to look at by `ExpenseReceiptCollection.findDuplicateReceipts()`.\n *\n * Attaching a receipt never changes the expense's review state. The receipt\n * takes its tenant from the expense, and its asset must be visible in that\n * tenant. The generated surface is closed, like {@link Expense}'s.\n */\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'expense_receipts',\n api: { include: [] },\n mcp: { include: [] },\n cli: false,\n indexes: [\n {\n name: 'expense_receipts_expense_sha256_key',\n columns: ['expenseId', 'contentSha256'],\n unique: true,\n },\n {\n name: 'expense_receipts_tenant_sha256_idx',\n columns: ['tenantId', 'contentSha256'],\n },\n ],\n})\nexport class ExpenseReceipt extends SmrtObject { static __smrtPackage__ = \"@happyvertical/smrt-expenses\";\n /** Owning tenant; always the expense's tenant. */\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n /** The expense this receipt proves. Deleting the expense removes the link. */\n @foreignKey(Expense, { required: true, onDelete: 'CASCADE' })\n expenseId: string = '';\n\n /** The smrt-assets `Asset` holding the file. */\n @crossPackageRef('@happyvertical/smrt-assets:Asset', { required: true })\n assetId: string = '';\n\n /** Profile that uploaded the file. */\n @crossPackageRef('@happyvertical/smrt-profiles:Profile')\n uploadedByProfileId: string | null = null;\n\n /** Original filename as uploaded. */\n @field({ type: 'text' })\n filename: string = '';\n\n /** MIME type of the file. */\n @field({ type: 'text' })\n mimeType: string = '';\n\n /** Size of the file in bytes. */\n @field({ type: 'integer' })\n byteCount: number = 0;\n\n /** Lowercase hex SHA-256 of the file's bytes. */\n @field({ type: 'text', required: true })\n contentSha256: string = '';\n\n constructor(options: ExpenseReceiptOptions = {}) {\n super(options);\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.expenseId !== undefined) this.expenseId = options.expenseId;\n if (options.assetId !== undefined) this.assetId = options.assetId;\n if (options.uploadedByProfileId !== undefined)\n this.uploadedByProfileId = options.uploadedByProfileId;\n if (options.filename !== undefined) this.filename = options.filename;\n if (options.mimeType !== undefined) this.mimeType = options.mimeType;\n if (options.byteCount !== undefined) this.byteCount = options.byteCount;\n if (options.contentSha256 !== undefined)\n this.contentSha256 = options.contentSha256;\n }\n\n /**\n * Validate the link, refuse a second copy of the same file on the same\n * expense, then persist. Never touches the expense.\n *\n * @throws {DuplicateReceiptError} when the expense already has this file.\n * @throws {ExpenseError} `EXPENSE_RECEIPT_INVALID` for a bad hash, a\n * missing or foreign-tenant expense or asset, or a change to an\n * immutable column.\n */\n override async save(): Promise<this> {\n const label = this.id || '<new>';\n this.tenantId = normalizeTenantId(this.tenantId);\n this.contentSha256 = normalizeSha256(\n 'ExpenseReceipt',\n label,\n this.contentSha256,\n );\n assertMinorUnits(\n 'ExpenseReceipt',\n label,\n 'byteCount',\n this.byteCount,\n 'EXPENSE_RECEIPT_INVALID',\n );\n if (!this.expenseId || !this.assetId) {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n `ExpenseReceipt ${label}: expenseId and assetId are required.`,\n );\n }\n\n const persisted = this.id\n ? (((await this.db.get(this.tableName, { id: this.id })) as\n | Record<string, unknown>\n | undefined\n | null) ?? null)\n : null;\n // The checks below are against `persisted`; pin the write to that row.\n const inserting = await pinNaturalKey('ExpenseReceipt', this, persisted);\n if (persisted) {\n const self = this as unknown as Record<string, unknown>;\n for (const [fieldName, column] of IMMUTABLE) {\n // The tenant is compared as it will be written: an empty tenantId\n // saved inside a tenant context is filled in by the interceptor.\n const value =\n fieldName === 'tenantId'\n ? effectiveTenant(this.tenantId)\n : self[fieldName];\n if (String(persisted[column] ?? '') !== String(value ?? '')) {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n `ExpenseReceipt ${this.id}: ${fieldName} cannot change; attach ` +\n 'a new receipt instead.',\n );\n }\n }\n } else {\n await this.assertLinkTargets(label);\n await this.assertNotDuplicate();\n }\n\n try {\n return await super.save();\n } catch (error) {\n if (inserting && isUniqueViolationError(error)) {\n throw await this.classifyInsertConflict(error);\n }\n throw error;\n }\n }\n\n /**\n * A refused INSERT is either the same file racing onto the same expense,\n * or a natural-key collision with another receipt.\n *\n * Classified from the error first, by the violated key's name or columns\n * (no other unique key on this table uses `expense_id` or\n * `content_sha256`). That matters inside a caller's transaction,\n * which the violation has already aborted on PostgreSQL, so nothing could\n * be re-read there. Only an adapter that reports neither falls back to a\n * re-read; if that re-read fails too, the original error is kept rather\n * than guessed at.\n */\n private async classifyInsertConflict(error: unknown): Promise<unknown> {\n const info = classifyDatabaseError(error);\n const columns = violatedColumns(error);\n if (\n info.constraint === RECEIPT_FILE_KEY ||\n info.driverMessages.some((text) => text.includes(RECEIPT_FILE_KEY)) ||\n columns.some((column) => RECEIPT_FILE_COLUMNS.has(column))\n ) {\n return new DuplicateReceiptError(this.expenseId, this.contentSha256, {\n cause: error,\n });\n }\n if (info.constraint || columns.length > 0) {\n // Another named key: the natural key or the primary key.\n return identityConflict('ExpenseReceipt', this, error);\n }\n try {\n await this.assertNotDuplicate();\n } catch (duplicate) {\n if (duplicate instanceof DuplicateReceiptError) {\n return new DuplicateReceiptError(this.expenseId, this.contentSha256, {\n cause: error,\n });\n }\n return error;\n }\n return identityConflict('ExpenseReceipt', this, error);\n }\n\n private async assertLinkTargets(label: string): Promise<void> {\n const { ExpenseCollection } = await import(\n '../collections/ExpenseCollection.js'\n );\n const expenses = await ExpenseCollection.create({ db: this.db });\n const expense = await expenses.get({ id: this.expenseId });\n if (!expense) {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n `ExpenseReceipt ${label}: expense ${this.expenseId} was not found.`,\n );\n }\n if (this.tenantId === null || this.tenantId === undefined) {\n this.tenantId = expense.tenantId ?? null;\n }\n if ((this.tenantId ?? null) !== (expense.tenantId ?? null)) {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n `ExpenseReceipt ${label}: a receipt belongs to its expense's tenant.`,\n );\n }\n\n const assets = await AssetCollection.create({ db: this.db });\n const asset = await assets.get({ id: this.assetId });\n const assetTenant = asset?.tenantId ?? null;\n if (!asset || (assetTenant !== null && assetTenant !== this.tenantId)) {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n `ExpenseReceipt ${label}: asset ${this.assetId} was not found in ` +\n \"the expense's tenant.\",\n );\n }\n if (!this.filename) this.filename = asset.name ?? '';\n if (!this.mimeType) this.mimeType = asset.mimeType ?? '';\n }\n\n private async assertNotDuplicate(): Promise<void> {\n const { ExpenseReceiptCollection } = await import(\n '../collections/ExpenseReceiptCollection.js'\n );\n const receipts = await ExpenseReceiptCollection.create({ db: this.db });\n const existing = await receipts.list({\n where: {\n expenseId: this.expenseId,\n contentSha256: this.contentSha256,\n },\n limit: 1,\n });\n if (existing.length > 0) {\n throw new DuplicateReceiptError(this.expenseId, this.contentSha256);\n }\n }\n}\n","/**\n * ExpenseReceiptCollection — attach receipts and find files shared between\n * expenses.\n *\n * @packageDocumentation\n */\n\nimport {\n type JunctionAttachOptions,\n SmrtJunction,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { ExpenseReceipt } from '../models/ExpenseReceipt.js';\nimport {\n type AttachReceiptInput,\n type DuplicateReceiptGroup,\n ExpenseError,\n type FindDuplicateReceiptsOptions,\n} from '../types.js';\n\nconst DEFAULT_DUPLICATE_LIMIT = 100;\nconst MAX_DUPLICATE_LIMIT = 1000;\n\n/**\n * Junction collection over `expense_receipts` (left: expense, right: asset).\n * Reads go through the normal tenant interceptors.\n *\n * Writes: {@link attachReceipt} adds a receipt; the inherited `detach()`\n * removes one (an explicit act; the asset itself is kept). The inherited\n * `attach()` and `setLinks()` are refused because they cannot carry the\n * content hash and `setLinks()` deletes before it writes.\n *\n * Collection classes are registered too, so this one carries its own closed\n * surface; without it every public method here would become an MCP tool.\n */\n@smrt({ api: { include: [] }, mcp: { include: [] }, cli: false })\nexport class ExpenseReceiptCollection extends SmrtJunction<ExpenseReceipt> { static __smrtPackage__ = \"@happyvertical/smrt-expenses\";\n static readonly _itemClass = ExpenseReceipt;\n protected leftField = 'expenseId';\n protected rightField = 'assetId';\n protected override sortField: string | null = 'createdAt';\n protected override positionField: string | null = null;\n\n /**\n * Attach a receipt file to an expense.\n *\n * Does not change the expense in any way — in particular not its review\n * state.\n *\n * @throws {DuplicateReceiptError} when this expense already carries a file\n * with the same SHA-256.\n */\n async attachReceipt(input: AttachReceiptInput): Promise<ExpenseReceipt> {\n const receipt = await this.create({\n expenseId: input.expenseId,\n assetId: input.assetId,\n contentSha256: input.contentSha256,\n uploadedByProfileId: input.uploadedByProfileId ?? null,\n filename: input.filename ?? '',\n mimeType: input.mimeType ?? '',\n byteCount: input.byteCount ?? 0,\n });\n await receipt.save();\n return receipt;\n }\n\n /**\n * Refused: a receipt is evidence and needs its content hash, which a\n * generic junction attach does not carry. Use {@link attachReceipt}.\n *\n * @throws {ExpenseError} `EXPENSE_RECEIPT_INVALID`, always.\n */\n override async attach(\n _expenseId: string,\n _assetId: string,\n _opts: JunctionAttachOptions = {},\n ): Promise<ExpenseReceipt> {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n 'ExpenseReceiptCollection.attach() is not supported: use ' +\n 'attachReceipt(), which records and checks the file hash.',\n );\n }\n\n /**\n * Refused before anything is deleted. The inherited replace-all deletes\n * every receipt of the expense and re-attaches without hashes, which would\n * lose the evidence. Attach with {@link attachReceipt}; remove a link with\n * `detach()`.\n *\n * @throws {ExpenseError} `EXPENSE_RECEIPT_INVALID`, always.\n */\n override async setLinks(\n _expenseId: string,\n _assetIds: string[],\n _opts: JunctionAttachOptions = {},\n ): Promise<void> {\n throw new ExpenseError(\n 'EXPENSE_RECEIPT_INVALID',\n 'ExpenseReceiptCollection.setLinks() is not supported: it would delete ' +\n 'receipt evidence. Use attachReceipt() to add and detach() to remove.',\n );\n }\n\n /** Receipts of one expense, oldest first. */\n async receiptsFor(expenseId: string): Promise<ExpenseReceipt[]> {\n return this.byLeft(expenseId);\n }\n\n /**\n * Files (by SHA-256) attached to more than one expense, for a person to\n * review. Nothing is changed or rejected: the same receipt on two expenses\n * may be a split cost or a double claim, and only a reviewer can tell.\n *\n * With `expenseId`, reports only files that expense shares with others;\n * without it, scans the visible tenant (largest groups first).\n */\n async findDuplicateReceipts(\n options: FindDuplicateReceiptsOptions = {},\n ): Promise<DuplicateReceiptGroup[]> {\n const limit = Math.min(\n Math.max(Math.trunc(options.limit ?? DEFAULT_DUPLICATE_LIMIT), 1),\n MAX_DUPLICATE_LIMIT,\n );\n\n let hashes: string[];\n if (options.expenseId) {\n const own = await this.byLeft(options.expenseId);\n hashes = [...new Set(own.map((receipt) => receipt.contentSha256))];\n } else {\n const [facet] = await this.facets({\n fields: [{ field: 'contentSha256', limit: MAX_DUPLICATE_LIMIT }],\n });\n // One row per (expense, hash) is enforced, so count > 1 means the\n // hash sits on more than one expense.\n // Already only shared hashes (largest groups first), so limiting the\n // hashes here limits the groups.\n hashes = (facet?.values ?? [])\n .filter((value) => value.count > 1)\n .map((value) => String(value.value))\n .slice(0, limit);\n }\n // The expense's own hashes include its unique receipts, so the limit is\n // applied to the groups below, after unshared hashes are dropped.\n if (hashes.length === 0) return [];\n\n const rows = await this.list({\n where: { contentSha256: hashes },\n orderBy: ['content_sha256 ASC', 'created_at ASC'],\n });\n const byHash = new Map<string, ExpenseReceipt[]>();\n for (const receipt of rows) {\n const group = byHash.get(receipt.contentSha256) ?? [];\n group.push(receipt);\n byHash.set(receipt.contentSha256, group);\n }\n\n const groups: DuplicateReceiptGroup[] = [];\n for (const [contentSha256, receipts] of byHash) {\n const expenseIds = [\n ...new Set(receipts.map((receipt) => receipt.expenseId)),\n ].sort();\n if (expenseIds.length < 2) continue;\n groups.push({ contentSha256, expenseIds, receipts });\n }\n groups.sort(\n (a, b) =>\n b.expenseIds.length - a.expenseIds.length ||\n a.contentSha256.localeCompare(b.contentSha256),\n );\n return groups.slice(0, limit);\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { t as ExpenseReceiptCollection } from "./ExpenseReceiptCollection-y1SlmnBS.js";
|
|
2
|
+
import { i as withActionPrincipal, n as authorizeExpenseAction, r as principalHolds, t as ExpenseActionError } from "./principal-_1CdcvvW.js";
|
|
3
|
+
import { ExpenseError } from "../types.js";
|
|
4
|
+
//#region src/actions/expense-actions.ts
|
|
5
|
+
var RECORD_FIELDS = [
|
|
6
|
+
"costObjectType",
|
|
7
|
+
"costObjectId",
|
|
8
|
+
"category",
|
|
9
|
+
"description",
|
|
10
|
+
"amount",
|
|
11
|
+
"currency",
|
|
12
|
+
"incurredOn",
|
|
13
|
+
"paidByName",
|
|
14
|
+
"vendorId",
|
|
15
|
+
"commitmentId",
|
|
16
|
+
"commitmentLineId",
|
|
17
|
+
"refundOfId",
|
|
18
|
+
"reimbursable"
|
|
19
|
+
];
|
|
20
|
+
var CONFLICT_CODES = /* @__PURE__ */ new Set([
|
|
21
|
+
"EXPENSE_REVIEW_TRANSITION",
|
|
22
|
+
"EXPENSE_REVIEW_FIELDS_LOCKED",
|
|
23
|
+
"EXPENSE_IDENTITY_CONFLICT",
|
|
24
|
+
"EXPENSE_RECEIPT_DUPLICATE"
|
|
25
|
+
]);
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ExpenseActionError(400, "invalid_request", "The action body must be a JSON object.");
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function optionalString(body, key) {
|
|
31
|
+
const value = body[key];
|
|
32
|
+
if (value === void 0 || value === null) return void 0;
|
|
33
|
+
if (typeof value !== "string") throw new ExpenseActionError(400, "invalid_request", `${key} must be a string.`);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function requiredString(body, key) {
|
|
37
|
+
const value = optionalString(body, key)?.trim();
|
|
38
|
+
if (!value) throw new ExpenseActionError(400, "invalid_request", `${key} is required.`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
async function domainCall(fn) {
|
|
42
|
+
try {
|
|
43
|
+
return await fn();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof ExpenseError) throw new ExpenseActionError(CONFLICT_CODES.has(error.code) ? 409 : 400, error.code, error.message);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function itemClass(expenses) {
|
|
50
|
+
return expenses.constructor._itemClass;
|
|
51
|
+
}
|
|
52
|
+
function options(expenses) {
|
|
53
|
+
return expenses.options;
|
|
54
|
+
}
|
|
55
|
+
async function loadExpense(expenses, principal, body) {
|
|
56
|
+
const expenseId = requiredString(body, "expenseId");
|
|
57
|
+
const expense = await expenses.get({ id: expenseId });
|
|
58
|
+
if (!expense || (expense.tenantId ?? null) !== principal.tenantId) throw new ExpenseActionError(404, "not_found", `Expense ${expenseId} was not found.`);
|
|
59
|
+
return expense;
|
|
60
|
+
}
|
|
61
|
+
async function recordExpenseAction(expenses, input) {
|
|
62
|
+
const body = asRecord(input);
|
|
63
|
+
const principal = await authorizeExpenseAction(itemClass(expenses), "record", options(expenses), { allowSuperAdminBypass: true });
|
|
64
|
+
const paidBy = optionalString(body, "paidBy") ?? "company";
|
|
65
|
+
let paidByProfileId = null;
|
|
66
|
+
if (paidBy === "person") {
|
|
67
|
+
paidByProfileId = optionalString(body, "paidByProfileId")?.trim() || principal.profileId;
|
|
68
|
+
if (paidByProfileId !== principal.profileId && !await principalHolds(itemClass(expenses), "recordReview", options(expenses))) throw new ExpenseActionError(403, "permission_denied", "Recording another person's expense needs the recordReview permission.");
|
|
69
|
+
}
|
|
70
|
+
const fields = {};
|
|
71
|
+
for (const key of RECORD_FIELDS) if (body[key] !== void 0) fields[key] = body[key];
|
|
72
|
+
const breakdown = body.financialBreakdown;
|
|
73
|
+
if (breakdown !== void 0 && breakdown !== null && (typeof breakdown !== "object" || Array.isArray(breakdown))) throw new ExpenseActionError(400, "invalid_request", "financialBreakdown must be an object or null.");
|
|
74
|
+
return withActionPrincipal(principal, () => domainCall(() => expenses.create({
|
|
75
|
+
...fields,
|
|
76
|
+
...breakdown !== void 0 ? { financialBreakdown: breakdown === null ? null : JSON.stringify(breakdown) } : {},
|
|
77
|
+
tenantId: principal.tenantId,
|
|
78
|
+
paidBy,
|
|
79
|
+
paidByProfileId,
|
|
80
|
+
recordedByProfileId: principal.profileId,
|
|
81
|
+
recordedAt: null
|
|
82
|
+
})));
|
|
83
|
+
}
|
|
84
|
+
async function attachExpenseReceiptAction(expenses, input) {
|
|
85
|
+
const body = asRecord(input);
|
|
86
|
+
const principal = await authorizeExpenseAction(itemClass(expenses), "attachReceipt", options(expenses), { allowSuperAdminBypass: true });
|
|
87
|
+
return withActionPrincipal(principal, async () => {
|
|
88
|
+
const expense = await loadExpense(expenses, principal, body);
|
|
89
|
+
if (expense.recordedByProfileId !== principal.profileId && expense.paidByProfileId !== principal.profileId && !await principalHolds(itemClass(expenses), "recordReview", options(expenses))) throw new ExpenseActionError(403, "permission_denied", "Attaching a receipt to another person's expense needs the recordReview permission.");
|
|
90
|
+
const byteCount = body.byteCount;
|
|
91
|
+
if (byteCount !== void 0 && typeof byteCount !== "number") throw new ExpenseActionError(400, "invalid_request", "byteCount must be a number.");
|
|
92
|
+
const receipts = await ExpenseReceiptCollection.create(options(expenses));
|
|
93
|
+
return domainCall(() => receipts.attachReceipt({
|
|
94
|
+
expenseId: expense.id,
|
|
95
|
+
assetId: requiredString(body, "assetId"),
|
|
96
|
+
contentSha256: requiredString(body, "contentSha256"),
|
|
97
|
+
uploadedByProfileId: principal.profileId,
|
|
98
|
+
...optionalString(body, "filename") !== void 0 ? { filename: optionalString(body, "filename") } : {},
|
|
99
|
+
...optionalString(body, "mimeType") !== void 0 ? { mimeType: optionalString(body, "mimeType") } : {},
|
|
100
|
+
...byteCount !== void 0 ? { byteCount } : {}
|
|
101
|
+
}));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function reviewExpenseAction(expenses, input) {
|
|
105
|
+
const body = asRecord(input);
|
|
106
|
+
const principal = await authorizeExpenseAction(itemClass(expenses), "recordReview", options(expenses), { allowSuperAdminBypass: false });
|
|
107
|
+
const decision = optionalString(body, "decision");
|
|
108
|
+
if (decision !== "approve" && decision !== "reject") throw new ExpenseActionError(400, "invalid_request", "decision must be 'approve' or 'reject'.");
|
|
109
|
+
return withActionPrincipal(principal, async () => {
|
|
110
|
+
const expense = await loadExpense(expenses, principal, body);
|
|
111
|
+
return domainCall(() => decision === "approve" ? expense.review({
|
|
112
|
+
reviewerProfileId: principal.profileId,
|
|
113
|
+
note: optionalString(body, "note") ?? ""
|
|
114
|
+
}) : expense.reject({
|
|
115
|
+
reviewerProfileId: principal.profileId,
|
|
116
|
+
reason: optionalString(body, "reason") ?? ""
|
|
117
|
+
}));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function markExpenseReimbursedAction(expenses, input) {
|
|
121
|
+
const body = asRecord(input);
|
|
122
|
+
const principal = await authorizeExpenseAction(itemClass(expenses), "recordReimbursement", options(expenses), { allowSuperAdminBypass: false });
|
|
123
|
+
return withActionPrincipal(principal, async () => {
|
|
124
|
+
const expense = await loadExpense(expenses, principal, body);
|
|
125
|
+
const reference = optionalString(body, "reference");
|
|
126
|
+
return domainCall(() => expense.markReimbursed(reference !== void 0 ? { reference } : {}));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
export { attachExpenseReceiptAction, markExpenseReimbursedAction, recordExpenseAction, reviewExpenseAction };
|
|
131
|
+
|
|
132
|
+
//# sourceMappingURL=expense-actions-Ce5CBaBE.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expense-actions-Ce5CBaBE.js","names":[],"sources":["../../src/actions/expense-actions.ts"],"sourcesContent":["/**\n * Service-backed write actions for `Expense` and `ExpenseReceipt` (#4001).\n *\n * The generated CRUD surface stays closed: these are the only generated\n * writes, and each delegates to the existing domain flow — `Expense.save()`\n * with its review lock and refund/commitment/vendor guards, the method-only\n * `review()` / `reject()` / `markReimbursed()`, and\n * `ExpenseReceiptCollection.attachReceipt()` with its hash deduplication.\n * The thin `ExpenseCollection` methods call in here.\n */\nimport type { SmrtClassOptions } from '@happyvertical/smrt-core';\nimport type { ExpenseCollection } from '../collections/ExpenseCollection.js';\nimport { ExpenseReceiptCollection } from '../collections/ExpenseReceiptCollection.js';\nimport type { Expense } from '../models/Expense.js';\nimport type { ExpenseReceipt } from '../models/ExpenseReceipt.js';\nimport {\n ExpenseError,\n type ExpenseErrorCode,\n type ExpenseFinancialBreakdown,\n type ExpensePayer,\n} from '../types.js';\nimport {\n authorizeExpenseAction,\n ExpenseActionError,\n type ExpenseActionPrincipal,\n principalHolds,\n withActionPrincipal,\n} from './principal.js';\n\n/** Body of the `record` action. Tenant and recorder come from the session. */\nexport interface RecordExpenseActionInput {\n costObjectType?: string;\n costObjectId?: string;\n category?: string;\n description?: string;\n /** Integer minor units of `currency`. */\n amount: number;\n currency: string;\n /** `YYYY-MM-DD`. */\n incurredOn: string;\n paidBy?: ExpensePayer;\n /** Defaults to the signed-in person when `paidBy` is `person`. */\n paidByProfileId?: string | null;\n paidByName?: string | null;\n vendorId?: string | null;\n commitmentId?: string | null;\n commitmentLineId?: string | null;\n refundOfId?: string | null;\n reimbursable?: boolean;\n financialBreakdown?: ExpenseFinancialBreakdown | null;\n}\n\n/** Body of the `attachReceipt` action. */\nexport interface AttachExpenseReceiptActionInput {\n expenseId: string;\n assetId: string;\n contentSha256: string;\n filename?: string;\n mimeType?: string;\n byteCount?: number;\n}\n\n/** Body of the `recordReview` action. */\nexport interface ReviewExpenseActionInput {\n expenseId: string;\n /** `approve` accepts the cost; `reject` refuses it and needs a `reason`. */\n decision: 'approve' | 'reject';\n note?: string;\n reason?: string;\n}\n\n/** Body of the `recordReimbursement` action. */\nexport interface MarkExpenseReimbursedActionInput {\n expenseId: string;\n /** Payroll line, payables reference, or transfer id of the payback. */\n reference?: string;\n}\n\n/** `record` fields copied onto the new expense; everything else is ignored. */\nconst RECORD_FIELDS = [\n 'costObjectType',\n 'costObjectId',\n 'category',\n 'description',\n 'amount',\n 'currency',\n 'incurredOn',\n 'paidByName',\n 'vendorId',\n 'commitmentId',\n 'commitmentLineId',\n 'refundOfId',\n 'reimbursable',\n] as const;\n\nconst CONFLICT_CODES = new Set<ExpenseErrorCode>([\n 'EXPENSE_REVIEW_TRANSITION',\n 'EXPENSE_REVIEW_FIELDS_LOCKED',\n 'EXPENSE_IDENTITY_CONFLICT',\n 'EXPENSE_RECEIPT_DUPLICATE',\n]);\n\nfunction asRecord(value: unknown): Record<string, unknown> {\n if (!value || typeof value !== 'object' || Array.isArray(value)) {\n throw new ExpenseActionError(\n 400,\n 'invalid_request',\n 'The action body must be a JSON object.',\n );\n }\n return value as Record<string, unknown>;\n}\n\nfunction optionalString(\n body: Record<string, unknown>,\n key: string,\n): string | undefined {\n const value = body[key];\n if (value === undefined || value === null) return undefined;\n if (typeof value !== 'string') {\n throw new ExpenseActionError(\n 400,\n 'invalid_request',\n `${key} must be a string.`,\n );\n }\n return value;\n}\n\nfunction requiredString(body: Record<string, unknown>, key: string): string {\n const value = optionalString(body, key)?.trim();\n if (!value) {\n throw new ExpenseActionError(400, 'invalid_request', `${key} is required.`);\n }\n return value;\n}\n\n/** Domain refusals become 4xx with their stable code; others pass through. */\nasync function domainCall<T>(fn: () => Promise<T>): Promise<T> {\n try {\n return await fn();\n } catch (error) {\n if (error instanceof ExpenseError) {\n throw new ExpenseActionError(\n CONFLICT_CODES.has(error.code) ? 409 : 400,\n error.code,\n error.message,\n );\n }\n throw error;\n }\n}\n\nfunction itemClass(expenses: ExpenseCollection) {\n return (expenses.constructor as unknown as { _itemClass: unknown })\n ._itemClass as typeof Expense;\n}\n\nfunction options(expenses: ExpenseCollection): SmrtClassOptions {\n return expenses.options;\n}\n\nasync function loadExpense(\n expenses: ExpenseCollection,\n principal: ExpenseActionPrincipal,\n body: Record<string, unknown>,\n): Promise<Expense> {\n const expenseId = requiredString(body, 'expenseId');\n const expense = await expenses.get({ id: expenseId });\n // Optional tenancy also admits global (NULL-tenant) rows in a tenant\n // context; a write must land on the principal's own tenant only.\n if (!expense || (expense.tenantId ?? null) !== principal.tenantId) {\n throw new ExpenseActionError(\n 404,\n 'not_found',\n `Expense ${expenseId} was not found.`,\n );\n }\n return expense;\n}\n\n/**\n * `record`: a new `unreviewed` expense, recorded by the signed-in person in\n * their tenant. Review, reimbursement and duplicate fields are never taken\n * from the body. Paying on someone else's behalf needs `expenses.recordReview`.\n */\nexport async function recordExpenseAction(\n expenses: ExpenseCollection,\n input: unknown,\n): Promise<Expense> {\n const body = asRecord(input);\n const principal = await authorizeExpenseAction(\n itemClass(expenses),\n 'record',\n options(expenses),\n { allowSuperAdminBypass: true },\n );\n const paidBy = (optionalString(body, 'paidBy') ?? 'company') as ExpensePayer;\n let paidByProfileId: string | null = null;\n if (paidBy === 'person') {\n paidByProfileId =\n optionalString(body, 'paidByProfileId')?.trim() || principal.profileId;\n if (\n paidByProfileId !== principal.profileId &&\n !(await principalHolds(\n itemClass(expenses),\n 'recordReview',\n options(expenses),\n ))\n ) {\n throw new ExpenseActionError(\n 403,\n 'permission_denied',\n \"Recording another person's expense needs the recordReview permission.\",\n );\n }\n }\n const fields: Record<string, unknown> = {};\n for (const key of RECORD_FIELDS) {\n if (body[key] !== undefined) fields[key] = body[key];\n }\n const breakdown = body.financialBreakdown;\n if (\n breakdown !== undefined &&\n breakdown !== null &&\n (typeof breakdown !== 'object' || Array.isArray(breakdown))\n ) {\n throw new ExpenseActionError(\n 400,\n 'invalid_request',\n 'financialBreakdown must be an object or null.',\n );\n }\n return withActionPrincipal(principal, () =>\n // One create: `collection.create()` saves, and `Expense.save()` validates\n // the facts (`validateShape()` parses them) before its first INSERT, so an\n // invalid breakdown never leaves a half-recorded row behind.\n domainCall(() =>\n expenses.create({\n ...fields,\n ...(breakdown !== undefined\n ? {\n financialBreakdown:\n breakdown === null ? null : JSON.stringify(breakdown),\n }\n : {}),\n tenantId: principal.tenantId,\n paidBy,\n paidByProfileId,\n recordedByProfileId: principal.profileId,\n recordedAt: null,\n }),\n ),\n );\n}\n\n/**\n * `attachReceipt`: link a stored asset to an expense as its receipt through\n * `ExpenseReceiptCollection.attachReceipt()`. The uploader is the signed-in\n * person, who must have recorded or paid the expense unless they hold\n * `expenses.recordReview`.\n */\nexport async function attachExpenseReceiptAction(\n expenses: ExpenseCollection,\n input: unknown,\n): Promise<ExpenseReceipt> {\n const body = asRecord(input);\n const principal = await authorizeExpenseAction(\n itemClass(expenses),\n 'attachReceipt',\n options(expenses),\n { allowSuperAdminBypass: true },\n );\n return withActionPrincipal(principal, async () => {\n const expense = await loadExpense(expenses, principal, body);\n if (\n expense.recordedByProfileId !== principal.profileId &&\n expense.paidByProfileId !== principal.profileId &&\n !(await principalHolds(\n itemClass(expenses),\n 'recordReview',\n options(expenses),\n ))\n ) {\n throw new ExpenseActionError(\n 403,\n 'permission_denied',\n \"Attaching a receipt to another person's expense needs the recordReview permission.\",\n );\n }\n const byteCount = body.byteCount;\n if (byteCount !== undefined && typeof byteCount !== 'number') {\n throw new ExpenseActionError(\n 400,\n 'invalid_request',\n 'byteCount must be a number.',\n );\n }\n const receipts = await ExpenseReceiptCollection.create(options(expenses));\n return domainCall(() =>\n receipts.attachReceipt({\n expenseId: expense.id as string,\n assetId: requiredString(body, 'assetId'),\n contentSha256: requiredString(body, 'contentSha256'),\n uploadedByProfileId: principal.profileId,\n ...(optionalString(body, 'filename') !== undefined\n ? { filename: optionalString(body, 'filename') }\n : {}),\n ...(optionalString(body, 'mimeType') !== undefined\n ? { mimeType: optionalString(body, 'mimeType') }\n : {}),\n ...(byteCount !== undefined ? { byteCount: byteCount as number } : {}),\n }),\n );\n });\n}\n\n/**\n * `recordReview`: accept (`Expense.review()`) or refuse with a reason\n * (`Expense.reject()`), attributed to the signed-in reviewer. Money: needs an\n * explicit `expenses.recordReview` grant, super-admin bypass does not apply.\n * Named apart from `Expense.review()` so allowlisting the collection action\n * never opens the instance method, which takes the reviewer from its input.\n */\nexport async function reviewExpenseAction(\n expenses: ExpenseCollection,\n input: unknown,\n): Promise<Expense> {\n const body = asRecord(input);\n const principal = await authorizeExpenseAction(\n itemClass(expenses),\n 'recordReview',\n options(expenses),\n { allowSuperAdminBypass: false },\n );\n const decision = optionalString(body, 'decision');\n if (decision !== 'approve' && decision !== 'reject') {\n throw new ExpenseActionError(\n 400,\n 'invalid_request',\n \"decision must be 'approve' or 'reject'.\",\n );\n }\n return withActionPrincipal(principal, async () => {\n const expense = await loadExpense(expenses, principal, body);\n return domainCall(() =>\n decision === 'approve'\n ? expense.review({\n reviewerProfileId: principal.profileId,\n note: optionalString(body, 'note') ?? '',\n })\n : expense.reject({\n reviewerProfileId: principal.profileId,\n reason: optionalString(body, 'reason') ?? '',\n }),\n );\n });\n}\n\n/**\n * `recordReimbursement`: record that the person who paid has been paid back\n * (`Expense.markReimbursed()`); never changes the review state. Money: needs\n * an explicit `expenses.recordReimbursement` grant.\n */\nexport async function markExpenseReimbursedAction(\n expenses: ExpenseCollection,\n input: unknown,\n): Promise<Expense> {\n const body = asRecord(input);\n const principal = await authorizeExpenseAction(\n itemClass(expenses),\n 'recordReimbursement',\n options(expenses),\n { allowSuperAdminBypass: false },\n );\n return withActionPrincipal(principal, async () => {\n const expense = await loadExpense(expenses, principal, body);\n const reference = optionalString(body, 'reference');\n return domainCall(() =>\n expense.markReimbursed(reference !== undefined ? { reference } : {}),\n );\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { getCurrentTenant, withTenant } from "@happyvertical/smrt-tenancy";
|
|
2
|
+
import { OperationPermissionError, UserCollection, assertOperationPermission, getCurrentSessionPermissionContext } from "@happyvertical/smrt-users";
|
|
3
|
+
//#region src/actions/principal.ts
|
|
4
|
+
var ExpenseActionError = class extends Error {
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
publicMessage;
|
|
8
|
+
constructor(status, code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ExpenseActionError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.publicMessage = message;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function profileIdOf(user) {
|
|
17
|
+
if (!user || typeof user !== "object") return null;
|
|
18
|
+
const value = user.profileId;
|
|
19
|
+
return typeof value === "string" && value ? value : null;
|
|
20
|
+
}
|
|
21
|
+
function ambientPrincipal() {
|
|
22
|
+
const tenant = getCurrentTenant();
|
|
23
|
+
const session = getCurrentSessionPermissionContext();
|
|
24
|
+
if (tenant?.tenantId) {
|
|
25
|
+
const sessionProfile = session?.userId && session.userId === tenant.userId ? profileIdOf(session.user) : null;
|
|
26
|
+
return {
|
|
27
|
+
tenantId: tenant.tenantId,
|
|
28
|
+
userId: tenant.userId ?? null,
|
|
29
|
+
permissions: tenant.permissions,
|
|
30
|
+
profileId: profileIdOf(tenant.user) ?? sessionProfile,
|
|
31
|
+
fromTenancy: true
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
tenantId: session?.tenantId ?? null,
|
|
36
|
+
userId: session?.userId ?? null,
|
|
37
|
+
permissions: session?.permissionSet ?? /* @__PURE__ */ new Set(),
|
|
38
|
+
profileId: profileIdOf(session?.user),
|
|
39
|
+
fromTenancy: false
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function loadProfileId(userId, options) {
|
|
43
|
+
try {
|
|
44
|
+
return profileIdOf(await (await UserCollection.create({ db: options.db ?? options.persistence })).get({ id: userId }));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function principalHolds(collection, action, options) {
|
|
50
|
+
const ambient = ambientPrincipal();
|
|
51
|
+
try {
|
|
52
|
+
await assertOperationPermission({
|
|
53
|
+
collection,
|
|
54
|
+
action,
|
|
55
|
+
db: options.db ?? options.persistence,
|
|
56
|
+
tenantId: ambient.tenantId,
|
|
57
|
+
userId: ambient.userId,
|
|
58
|
+
permissionSet: ambient.permissions,
|
|
59
|
+
allowSystemContextBypass: false,
|
|
60
|
+
allowSuperAdminBypass: false
|
|
61
|
+
});
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof OperationPermissionError) return false;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function authorizeExpenseAction(collection, action, options, guard) {
|
|
69
|
+
const ambient = ambientPrincipal();
|
|
70
|
+
try {
|
|
71
|
+
await assertOperationPermission({
|
|
72
|
+
collection,
|
|
73
|
+
action,
|
|
74
|
+
db: options.db ?? options.persistence,
|
|
75
|
+
tenantId: ambient.tenantId,
|
|
76
|
+
userId: ambient.userId,
|
|
77
|
+
permissionSet: ambient.permissions,
|
|
78
|
+
allowSystemContextBypass: false,
|
|
79
|
+
allowSuperAdminBypass: guard.allowSuperAdminBypass
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof OperationPermissionError) throw new ExpenseActionError(403, "permission_denied", error.message);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
if (!ambient.tenantId || !ambient.userId) throw new ExpenseActionError(403, "permission_denied", "Expense actions need an authenticated tenant context.");
|
|
86
|
+
const profileId = ambient.profileId ?? await loadProfileId(ambient.userId, options);
|
|
87
|
+
if (!profileId) throw new ExpenseActionError(403, "actor_profile_required", "Expense actions need a user with a linked profile.");
|
|
88
|
+
return {
|
|
89
|
+
tenantId: ambient.tenantId,
|
|
90
|
+
userId: ambient.userId,
|
|
91
|
+
permissions: ambient.permissions,
|
|
92
|
+
profileId,
|
|
93
|
+
fromTenancy: ambient.fromTenancy
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function withActionPrincipal(principal, fn) {
|
|
97
|
+
if (principal.fromTenancy) return fn();
|
|
98
|
+
return withTenant({
|
|
99
|
+
tenantId: principal.tenantId,
|
|
100
|
+
userId: principal.userId,
|
|
101
|
+
permissions: new Set(principal.permissions)
|
|
102
|
+
}, fn);
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
export { withActionPrincipal as i, authorizeExpenseAction as n, principalHolds as r, ExpenseActionError as t };
|
|
106
|
+
|
|
107
|
+
//# sourceMappingURL=principal-_1CdcvvW.js.map
|