@happyvertical/smrt-expenses 0.0.0-stage → 0.53.0
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 +127 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +180 -2
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/__tests__/helpers/expense-suite.d.ts +64 -0
- package/dist/__tests__/helpers/expense-suite.d.ts.map +1 -0
- package/dist/chunks/Expense-C1uUZbAr.js +513 -0
- package/dist/chunks/Expense-C1uUZbAr.js.map +1 -0
- package/dist/collections/ExpenseCollection.d.ts +60 -0
- package/dist/collections/ExpenseCollection.d.ts.map +1 -0
- package/dist/collections/ExpenseReceiptCollection.d.ts +66 -0
- package/dist/collections/ExpenseReceiptCollection.d.ts.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +466 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +1460 -0
- package/dist/models/Expense.d.ts +186 -0
- package/dist/models/Expense.d.ts.map +1 -0
- package/dist/models/ExpenseReceipt.d.ts +72 -0
- package/dist/models/ExpenseReceipt.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +778 -0
- package/dist/types.d.ts +211 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +38 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +86 -0
- package/dist/validation.d.ts.map +1 -0
- package/package.json +60 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @happyvertical/smrt-expenses
|
|
2
|
+
|
|
3
|
+
Incurred costs against a cost object, with deduplicated receipts, an explicit
|
|
4
|
+
review, reimbursement recording, and smrt-commerce commitment drawdown (#3289).
|
|
5
|
+
|
|
6
|
+
## Models
|
|
7
|
+
|
|
8
|
+
- **Expense** (`expenses`): `costObjectType` (qualified class name) +
|
|
9
|
+
`costObjectId` (bare string), `category` (lowercase kebab-case; suggestions
|
|
10
|
+
in `SUGGESTED_EXPENSE_CATEGORIES`), `amount` integer minor units + ISO 4217
|
|
11
|
+
`currency`, `incurredOn` (`YYYY-MM-DD` text: a calendar date, not an
|
|
12
|
+
instant), `recordedAt`, `recordedByProfileId`, `paidBy` (`company` |
|
|
13
|
+
`person`) + `paidByProfileId`, `vendorId`, `commitmentId` /
|
|
14
|
+
`commitmentLineId`, review fields, `reimbursable` / `reimbursedAt` /
|
|
15
|
+
`reimbursementReference`.
|
|
16
|
+
- **ExpenseReceipt** (`expense_receipts`): noun-specific asset join
|
|
17
|
+
(`expenseId` → Expense, `assetId` → smrt-assets Asset) with uploader,
|
|
18
|
+
`filename`, `mimeType`, `byteCount`, `contentSha256`. Collection extends
|
|
19
|
+
`SmrtJunction` (left expense, right asset, ordered by `created_at`).
|
|
20
|
+
|
|
21
|
+
## Invariants
|
|
22
|
+
|
|
23
|
+
- **Review is method-only.** `review()`, `reject()`, `markDuplicate()`,
|
|
24
|
+
`reopen()` read the persisted status, check the move, and authorize exactly
|
|
25
|
+
one save through a module `WeakMap`. Every transition assigns every review
|
|
26
|
+
field, and the token carries that complete resulting state, so a field set
|
|
27
|
+
directly beside `review()`/`reject()` (e.g. an injected `duplicateOfId`)
|
|
28
|
+
is refused. A `reviewed` expense never carries `duplicateOfId`. `save()` compares every review field
|
|
29
|
+
(status, reviewer, time, note, `duplicateOfId`) with the stored row and
|
|
30
|
+
refuses an unauthorized change (`EXPENSE_REVIEW_FIELDS_LOCKED`), including
|
|
31
|
+
on a brand-new row. Review fields are also `readonly` for generated writes.
|
|
32
|
+
- **Writes are pinned to the guarded row.** The guards read the row stored
|
|
33
|
+
under `id`, but core's natural-key save adopts any same-owner row on
|
|
34
|
+
`(tenant_id, slug, context)` and upserts it with no revision predicate;
|
|
35
|
+
these models' slugs come from the id. So `pinNaturalKey()` lets a fresh
|
|
36
|
+
(not `isPersisted`) instance only INSERT (`requireInsertOnSave()`): naming
|
|
37
|
+
an existing row's id is `EXPENSE_IDENTITY_CONFLICT`, and a row appearing
|
|
38
|
+
mid-save fails the INSERT instead of being adopted. An existing row changes
|
|
39
|
+
only through a loaded instance (revision compare-and-swap) that keeps its
|
|
40
|
+
slug/context. Never `create({ id: <existing> })` or `getOrUpsert()` these
|
|
41
|
+
models; load and save instead.
|
|
42
|
+
- **Reviewed money is frozen.** While the stored status is `reviewed`,
|
|
43
|
+
`LOCKED_WHEN_REVIEWED` fields (money, currency, both dates, cost object,
|
|
44
|
+
vendor, commitment, payer) cannot change; `reopen()` first. `recordedAt` is
|
|
45
|
+
compared as an instant, since drivers return it as a `Date` or a string.
|
|
46
|
+
- **Drawdown counts once.** `ExpenseCollection.commitmentPosition()` sums only
|
|
47
|
+
`reviewed` rows with no `duplicateOfId`, in the commitment's currency, deduped
|
|
48
|
+
by id. Committed is the contract's `totalAmount` (or the line's `amount`).
|
|
49
|
+
- **Currencies never mix.** A commitment match must share the currency and
|
|
50
|
+
tenant (`EXPENSE_COMMITMENT_MISMATCH`), checked when the match, line or
|
|
51
|
+
currency is set or changed — not on every save, so a later commitment edit
|
|
52
|
+
never blocks e.g. `markReimbursed()`. `totalsByCurrency()` keys by currency.
|
|
53
|
+
Rows left in another currency by a later commitment change land in
|
|
54
|
+
`otherCurrencies`, never in `drawn`.
|
|
55
|
+
- **Duplicate receipts.** Same `(expense, sha256)` is refused by a pre-insert
|
|
56
|
+
read plus the unique index `expense_receipts_expense_sha256_key`
|
|
57
|
+
`(expense_id, content_sha256)`; a unique violation is rethrown as
|
|
58
|
+
`DuplicateReceiptError`, classified from the error itself (violated key
|
|
59
|
+
name, or `details.fieldName` columns on core's typed error) because on
|
|
60
|
+
PostgreSQL the violation aborts a caller's transaction and nothing can be
|
|
61
|
+
re-read; only an error naming no key falls back to a re-read. The index omits `tenant_id` deliberately: the
|
|
62
|
+
expense already fixes the tenant, and a nullable leading `tenant_id` would
|
|
63
|
+
leave NULL-tenant (global) receipts unenforced. Same hash on different
|
|
64
|
+
expenses is allowed; `findDuplicateReceipts()` reports it (facet on
|
|
65
|
+
`contentSha256`, count > 1). Receipt `tenantId` (derived from the
|
|
66
|
+
expense on insert), `expenseId`, `assetId` and `contentSha256` are
|
|
67
|
+
immutable. The inherited `SmrtJunction.attach()` and
|
|
68
|
+
`setLinks()` are overridden to throw (`setLinks()` would delete every
|
|
69
|
+
receipt, then fail to re-attach without hashes); `attachReceipt()` adds and
|
|
70
|
+
`detach()` removes a link, keeping the asset. Because `ExpenseReceipt`
|
|
71
|
+
overrides `save()`, `hasBaseJunctionLifecycle()` is false and no batch
|
|
72
|
+
junction path can write a hashless row around it.
|
|
73
|
+
- **Vendor ownership.** `vendorId` is a `crossPackageRef` (no FK, no
|
|
74
|
+
validation), so `assertVendor()` checks the vendor is visible in and owned
|
|
75
|
+
by the expense's tenant whenever it is set or changed
|
|
76
|
+
(`EXPENSE_VENDOR_MISMATCH`).
|
|
77
|
+
- **Tenant never moves.** Expense and receipt tenants are fixed once
|
|
78
|
+
stored, compared as `effectiveTenant()` (the field, or the active context
|
|
79
|
+
the `beforeSave` interceptor would fill an unset one from; unset is falsy,
|
|
80
|
+
so `''` counts). `normalizeTenantId()` stores `''` as NULL (global), never
|
|
81
|
+
as a pseudo-tenant, matching core/tenancy's falsy "unset". Otherwise a
|
|
82
|
+
global (NULL-tenant) row saved inside `withTenant()` would be silently
|
|
83
|
+
populated and split from its global expense or receipts.
|
|
84
|
+
- **Tenancy.** Optional (`@TenantScoped({ mode: 'optional' })`, nullable
|
|
85
|
+
`tenantId`), like smrt-commerce. A receipt takes its expense's tenant; its
|
|
86
|
+
expense and asset must be visible in that tenant. A commitment must belong
|
|
87
|
+
to the expense's tenant; before `super.save()` that is
|
|
88
|
+
`tenantId ?? getTenantId()`, because the interceptor has not populated it.
|
|
89
|
+
Consumers make it required with a qualified
|
|
90
|
+
`registerTenantScopedClass(…, { mode: 'required' })`.
|
|
91
|
+
- **Closed generated surface.** Models AND collections declare
|
|
92
|
+
`api: { include: [] }`, `mcp: { include: [] }`, `cli: false`. Collection
|
|
93
|
+
classes are registered too: an undecorated collection publishes its CRUD and
|
|
94
|
+
public methods as MCP tools. Core has no consumer-side switch to reopen a
|
|
95
|
+
package model's surface; consumers write their own routes.
|
|
96
|
+
- **Cost object is not dereferenced on save.** `getCostObject()` resolves it
|
|
97
|
+
through the registry and returns `null` for a foreign-tenant row. One cost
|
|
98
|
+
object per expense (columns, not a `SmrtPolymorphicAssociation` join) so a
|
|
99
|
+
cost can never roll up into two jobs.
|
|
100
|
+
|
|
101
|
+
## Cross-package references
|
|
102
|
+
|
|
103
|
+
- `vendorId` → `@crossPackageRef('@happyvertical/smrt-commerce:Vendor')`
|
|
104
|
+
- `commitmentId` → `…smrt-commerce:Contract` (usually a `PurchaseOrder`)
|
|
105
|
+
- `commitmentLineId` → `…smrt-commerce:ContractLineItem`
|
|
106
|
+
- `assetId` → `…smrt-assets:Asset`
|
|
107
|
+
- profile ids → `…smrt-profiles:Profile` (no dependency; references only)
|
|
108
|
+
- `expenseId`, `duplicateOfId` → `@foreignKey` within the package
|
|
109
|
+
|
|
110
|
+
## Scope
|
|
111
|
+
|
|
112
|
+
Allowance (a planned amount) is not an expense kind; it belongs with
|
|
113
|
+
budget/commitment records. Labour belongs to `@happyvertical/smrt-timesheets`
|
|
114
|
+
(#3288). No OCR, no accounting-provider posting.
|
|
115
|
+
|
|
116
|
+
## Validation
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pnpm --filter @happyvertical/smrt-expenses test
|
|
120
|
+
pnpm --filter @happyvertical/smrt-expenses typecheck
|
|
121
|
+
pnpm --filter @happyvertical/smrt-expenses test:postgres
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`src/__tests__/helpers/expense-suite.ts` is the shared behaviour suite: the
|
|
125
|
+
SQLite file and the PostgreSQL lane (`*.optional.test.ts`, schema from
|
|
126
|
+
`migrateSmrtSchemas` plus live parity) both run it. Use UUID tenant and
|
|
127
|
+
profile ids in tests: PostgreSQL stores them as `uuid`.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,181 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @happyvertical/smrt-expenses
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Expense records for the s-m-r-t framework: a cost someone incurred, charged to a
|
|
4
|
+
job, work package or project, with the receipt that proves it, an explicit
|
|
5
|
+
review, reimbursement recording, and drawdown of a purchase-order commitment.
|
|
6
|
+
|
|
7
|
+
- **Expense** — polymorphic cost object, category, amount in integer minor
|
|
8
|
+
units with currency, incurred date apart from recorded time, who recorded
|
|
9
|
+
it, who paid (the company or a named person out of pocket), optional
|
|
10
|
+
smrt-commerce vendor and commitment, and a review state.
|
|
11
|
+
- **ExpenseReceipt** — expense ↔ smrt-assets `Asset` join with uploader,
|
|
12
|
+
filename, MIME type, byte count and `contentSha256`. The same file twice on
|
|
13
|
+
one expense is refused; the same file on two expenses is reported for a
|
|
14
|
+
person to look at.
|
|
15
|
+
- **Commitment drawdown** — reviewed actuals reduce what remains open on a
|
|
16
|
+
smrt-commerce `PurchaseOrder` (or one of its lines), each exactly once.
|
|
17
|
+
|
|
18
|
+
Out of scope: receipt OCR or parsing, posting to an accounting provider, and
|
|
19
|
+
labour (see `@happyvertical/smrt-timesheets`, #3288).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pnpm add @happyvertical/smrt-expenses
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Depends on `@happyvertical/smrt-commerce` (vendors, commitments) and
|
|
28
|
+
`@happyvertical/smrt-assets` (receipt files). Profiles are referenced by id
|
|
29
|
+
(`@happyvertical/smrt-profiles:Profile`) without a hard dependency.
|
|
30
|
+
|
|
31
|
+
## Schema
|
|
32
|
+
|
|
33
|
+
Tables are created by migrations, never at runtime:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
smrt db:migrate # creates expenses and expense_receipts
|
|
37
|
+
smrt db:status --parity # verifies the live schema matches
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import {
|
|
44
|
+
ExpenseCollection,
|
|
45
|
+
ExpenseReceiptCollection,
|
|
46
|
+
computeContentSha256,
|
|
47
|
+
} from '@happyvertical/smrt-expenses';
|
|
48
|
+
import { withTenant } from '@happyvertical/smrt-tenancy';
|
|
49
|
+
|
|
50
|
+
await withTenant({ tenantId }, async () => {
|
|
51
|
+
const expenses = await ExpenseCollection.create({ db });
|
|
52
|
+
const expense = await expenses.create({
|
|
53
|
+
costObjectType: '@happyvertical/smrt-projects:Project',
|
|
54
|
+
costObjectId: project.id,
|
|
55
|
+
category: 'material',
|
|
56
|
+
description: 'Framing lumber',
|
|
57
|
+
amount: 100000, // $1,000.00 — integer minor units
|
|
58
|
+
currency: 'USD',
|
|
59
|
+
incurredOn: '2026-09-15',
|
|
60
|
+
recordedByProfileId: recorder.id,
|
|
61
|
+
commitmentId: purchaseOrder.id, // optional; must share the currency
|
|
62
|
+
});
|
|
63
|
+
await expense.save();
|
|
64
|
+
|
|
65
|
+
// Receipt: the hash is what makes duplicates detectable.
|
|
66
|
+
const receipts = await ExpenseReceiptCollection.create({ db });
|
|
67
|
+
await receipts.attachReceipt({
|
|
68
|
+
expenseId: expense.id,
|
|
69
|
+
assetId: asset.id,
|
|
70
|
+
contentSha256: computeContentSha256(fileBytes),
|
|
71
|
+
byteCount: fileBytes.byteLength,
|
|
72
|
+
uploadedByProfileId: recorder.id,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Review is its own action — gate it in your route (e.g. `expenses.review`).
|
|
76
|
+
await expense.review({ reviewerProfileId: manager.id });
|
|
77
|
+
|
|
78
|
+
const position = await expenses.commitmentPosition(purchaseOrder.id);
|
|
79
|
+
// A 4,600.00 award with 1,000.00 reviewed: { drawn: 100000, open: 360000 }
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Review
|
|
84
|
+
|
|
85
|
+
Every expense starts `unreviewed`. Only these methods move it:
|
|
86
|
+
|
|
87
|
+
| Method | From | To |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `review({ reviewerProfileId })` | `unreviewed` | `reviewed` |
|
|
90
|
+
| `reject({ reviewerProfileId, reason })` | `unreviewed`, `reviewed` | `rejected` |
|
|
91
|
+
| `markDuplicate({ reviewerProfileId, duplicateOfId })` | `unreviewed`, `reviewed` | `rejected` + duplicate marker |
|
|
92
|
+
| `reopen({ reviewerProfileId })` | `reviewed`, `rejected` | `unreviewed` |
|
|
93
|
+
|
|
94
|
+
Writing `reviewStatus` (or the reviewer, time, note or duplicate marker)
|
|
95
|
+
directly and saving is refused. A reviewed expense's amount, currency, dates,
|
|
96
|
+
cost object, vendor, commitment and payer are frozen until `reopen()`.
|
|
97
|
+
Attaching a receipt and recording a reimbursement never change review state.
|
|
98
|
+
|
|
99
|
+
### Receipts and duplicates
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// Same file, same expense → DuplicateReceiptError (code EXPENSE_RECEIPT_DUPLICATE)
|
|
103
|
+
// Same file, different expenses → allowed, and listed here:
|
|
104
|
+
const groups = await receipts.findDuplicateReceipts();
|
|
105
|
+
// [{ contentSha256, expenseIds: [a, b], receipts: [...] }]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Money and currencies
|
|
109
|
+
|
|
110
|
+
Amounts are integer minor units (`$19.99` is `1999`). Totals are per currency
|
|
111
|
+
and never summed across currencies:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
await expenses.totalsByCurrency({ where: { costObjectId: job.id } });
|
|
115
|
+
// { USD: { amount: 125000, count: 3 }, CAD: { amount: 4000, count: 1 } }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
An expense matched to a commitment must be in the commitment's currency; a
|
|
119
|
+
mismatch is refused on save.
|
|
120
|
+
|
|
121
|
+
### Reimbursement
|
|
122
|
+
|
|
123
|
+
An expense `paidBy: 'person'` with `paidByProfileId` may be `reimbursable`
|
|
124
|
+
(owed back). `markReimbursed({ at, reference })` records the payback. Paying
|
|
125
|
+
it is payroll's or payables' job (#3292).
|
|
126
|
+
|
|
127
|
+
## Consumer controls
|
|
128
|
+
|
|
129
|
+
- **Closed generated surface.** Models and collections ship with
|
|
130
|
+
`api: { include: [] }`, `mcp: { include: [] }`, `cli: false`: no generated
|
|
131
|
+
REST, SvelteKit, MCP or CLI operation reaches cost data. Expose expenses
|
|
132
|
+
through your own permission-checked routes.
|
|
133
|
+
- **Required tenancy.** Tenancy is optional by default, like smrt-commerce.
|
|
134
|
+
To require a tenant context for every read and write:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { registerTenantScopedClass } from '@happyvertical/smrt-tenancy';
|
|
138
|
+
|
|
139
|
+
registerTenantScopedClass('@happyvertical/smrt-expenses:Expense', { mode: 'required' });
|
|
140
|
+
registerTenantScopedClass('@happyvertical/smrt-expenses:ExpenseReceipt', { mode: 'required' });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Mapping from consumer models
|
|
144
|
+
|
|
145
|
+
| Donor field | smrt-expenses |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| domacraft.com `FinancialCost.kind = actual` | an `Expense` |
|
|
148
|
+
| `FinancialCost.kind = commitment` | a smrt-commerce `PurchaseOrder` (`Contract`), with `ContractLineItem` lines |
|
|
149
|
+
| `FinancialCost.kind = labour` | `@happyvertical/smrt-timesheets` (#3288), not an expense |
|
|
150
|
+
| `FinancialCost.kind = allowance` | out of scope: a planned amount is a budget record, not an incurred cost |
|
|
151
|
+
| `FinancialCost.currency`, `amount` (minor units) | `currency`, `amount` |
|
|
152
|
+
| `FinancialCost.reviewStatus` | `reviewStatus` + `reviewedByProfileId`, `reviewedAt`, `reviewNote` |
|
|
153
|
+
| `FinancialCost` duplicate-review marker | `markDuplicate()` → `duplicateOfId`, `rejected` |
|
|
154
|
+
| `FinancialCost.matchedCommitmentId` | `commitmentId` (+ optional `commitmentLineId`) |
|
|
155
|
+
| domacraft.com#143 vendor / worker / work package | `vendorId` / `paidByProfileId` / `costObjectType` + `costObjectId` |
|
|
156
|
+
| `FinancialCostAsset` (cost, Asset) | `ExpenseReceipt` (`expenseId`, `assetId`) |
|
|
157
|
+
| `FinancialCostAsset` uploader, filename, mime, bytes, `contentSha256` | `uploadedByProfileId`, `filename`, `mimeType`, `byteCount`, `contentSha256` |
|
|
158
|
+
| `FinancialCostAsset` unique tenant + cost + hash | unique index `(expense_id, content_sha256)` — the expense fixes the tenant, and it also holds for NULL-tenant rows |
|
|
159
|
+
| teamworks-os `JobExpense.job` | `costObjectType` + `costObjectId` |
|
|
160
|
+
| `JobExpense.category` | `category` (`material`, `consumable`, `outside-service`, `freight`, `other`, …) |
|
|
161
|
+
| `JobExpense.description` | `description` |
|
|
162
|
+
| `JobExpense.amount` (cents) | `amount` + `currency` (set it explicitly) |
|
|
163
|
+
| `JobExpense` incurred date | `incurredOn` (`YYYY-MM-DD`); entry time is `recordedAt` |
|
|
164
|
+
| `JobExpense` logged-by | `recordedByProfileId` |
|
|
165
|
+
|
|
166
|
+
Imported rows start `unreviewed`; replay each donor review through
|
|
167
|
+
`review()` / `reject()` / `markDuplicate()` so the transition rules apply.
|
|
168
|
+
|
|
169
|
+
## Testing
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pnpm --filter @happyvertical/smrt-expenses test # SQLite
|
|
173
|
+
pnpm --filter @happyvertical/smrt-expenses test:postgres # PostgreSQL lane
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## See also
|
|
177
|
+
|
|
178
|
+
- [AGENTS.md](./AGENTS.md) — invariants and gotchas for this package
|
|
179
|
+
- [smrt-commerce](../commerce/README.md) — vendors and purchase orders
|
|
180
|
+
- [smrt-assets](../assets/README.md) — receipt files
|
|
181
|
+
- [smrt-tenancy](../tenancy/README.md) — tenant context and required mode
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"__smrt-register__.d.ts","sourceRoot":"","sources":["../src/__smrt-register__.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The expense behaviour suite, shared by the default (SQLite) lane and the
|
|
3
|
+
* PostgreSQL lane so both adapters prove the same contract: review
|
|
4
|
+
* transitions, duplicate receipts, and commitment matching.
|
|
5
|
+
*
|
|
6
|
+
* Every test runs inside its own fresh tenant, with tenancy enabled, so the
|
|
7
|
+
* PostgreSQL lane can share one migrated database across tests.
|
|
8
|
+
*/
|
|
9
|
+
import { AssetCollection } from '@happyvertical/smrt-assets';
|
|
10
|
+
import { ContractCollection, ContractLineItemCollection } from '@happyvertical/smrt-commerce';
|
|
11
|
+
import type { DatabaseInterface } from '@happyvertical/smrt-core/migrations';
|
|
12
|
+
import { ExpenseCollection } from '../../collections/ExpenseCollection.js';
|
|
13
|
+
import { ExpenseReceiptCollection } from '../../collections/ExpenseReceiptCollection.js';
|
|
14
|
+
import { Expense } from '../../models/Expense.js';
|
|
15
|
+
import { type ExpenseOptions } from '../../types.js';
|
|
16
|
+
export declare const PROJECT = "@happyvertical/smrt-projects:Project";
|
|
17
|
+
/** A persisted object: its id is known. */
|
|
18
|
+
export type Saved<T> = T & {
|
|
19
|
+
id: string;
|
|
20
|
+
};
|
|
21
|
+
/** Collections bound to one database, plus a fresh tenant to work in. */
|
|
22
|
+
export interface ExpenseWorld {
|
|
23
|
+
db: DatabaseInterface;
|
|
24
|
+
tenantId: string;
|
|
25
|
+
expenses: ExpenseCollection;
|
|
26
|
+
receipts: ExpenseReceiptCollection;
|
|
27
|
+
contracts: ContractCollection;
|
|
28
|
+
lines: ContractLineItemCollection;
|
|
29
|
+
assets: AssetCollection;
|
|
30
|
+
/** Run `fn` inside this world's tenant. */
|
|
31
|
+
inTenant<T>(fn: () => Promise<T>): Promise<T>;
|
|
32
|
+
/** Create and save an expense with sensible defaults. */
|
|
33
|
+
expense(overrides?: ExpenseOptions): Promise<Saved<Expense>>;
|
|
34
|
+
/** Create and save an asset. */
|
|
35
|
+
asset(name?: string): Promise<{
|
|
36
|
+
id: string;
|
|
37
|
+
}>;
|
|
38
|
+
/** Create and save a purchase-order commitment. */
|
|
39
|
+
commitment(totalAmount: number, currency?: string): Promise<{
|
|
40
|
+
id: string;
|
|
41
|
+
}>;
|
|
42
|
+
/** Create and save a commerce vendor. */
|
|
43
|
+
vendor(): Promise<{
|
|
44
|
+
id: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
/** Build a world over `db` with its own tenant. */
|
|
48
|
+
export declare function createWorld(db: DatabaseInterface): Promise<ExpenseWorld>;
|
|
49
|
+
/**
|
|
50
|
+
* Attach a receipt in `world`'s tenant, then move that row out of the
|
|
51
|
+
* tenant's reads (raw `tenant_id = NULL`). A second attach of the same file
|
|
52
|
+
* then passes the tenant-filtered pre-insert read and reaches the unique
|
|
53
|
+
* index — the same path a concurrent attach takes.
|
|
54
|
+
*/
|
|
55
|
+
export declare function hideReceiptFromTenantReads(world: ExpenseWorld): Promise<{
|
|
56
|
+
expenseId: string;
|
|
57
|
+
sha: string;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Register the behaviour suite against whatever database `getDb()` returns
|
|
61
|
+
* when a test runs.
|
|
62
|
+
*/
|
|
63
|
+
export declare function defineExpenseSuite(getDb: () => DatabaseInterface): void;
|
|
64
|
+
//# sourceMappingURL=expense-suite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expense-suite.d.ts","sourceRoot":"","sources":["../../../src/__tests__/helpers/expense-suite.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAE3B,MAAM,8BAA8B,CAAC;AAEtC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qCAAqC,CAAC;AAO7E,OAAO,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAC;AAC3E,OAAO,EAAE,wBAAwB,EAAE,MAAM,+CAA+C,CAAC;AACzF,OAAO,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAElD,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,gBAAgB,CAAC;AAGxB,eAAO,MAAM,OAAO,yCAAyC,CAAC;AAE9D,2CAA2C;AAC3C,MAAM,MAAM,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAO1C,yEAAyE;AACzE,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,iBAAiB,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,QAAQ,EAAE,wBAAwB,CAAC;IACnC,SAAS,EAAE,kBAAkB,CAAC;IAC9B,KAAK,EAAE,0BAA0B,CAAC;IAClC,MAAM,EAAE,eAAe,CAAC;IACxB,2CAA2C;IAC3C,QAAQ,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC9C,yDAAyD;IACzD,OAAO,CAAC,SAAS,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;IAC7D,gCAAgC;IAChC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9C,mDAAmD;IACnD,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5E,yCAAyC;IACzC,MAAM,IAAI,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnC;AAED,mDAAmD;AACnD,wBAAsB,WAAW,CAC/B,EAAE,EAAE,iBAAiB,GACpB,OAAO,CAAC,YAAY,CAAC,CA2DvB;AAED;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAC9C,KAAK,EAAE,YAAY,GAClB,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAgB7C;AASD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,iBAAiB,GAAG,IAAI,CAgnCvE"}
|