Skip to main content

@happyvertical/smrt-expenses

Expense records for the s-m-r-t framework: a cost someone incurred, charged to a job, work package or project, with the receipt that proves it, an explicit review, reimbursement recording, and drawdown of a purchase-order commitment.

  • Expense — polymorphic cost object, category, amount in integer minor units with currency, incurred date apart from recorded time, who recorded it, who paid (the company, a named person, or a named third party), optional smrt-commerce vendor and commitment, and a review state.
  • ExpenseReceipt — expense ↔ smrt-assets Asset join with uploader, filename, MIME type, byte count and contentSha256. The same file twice on one expense is refused; the same file on two expenses is reported for a person to look at.
  • Commitment drawdown — reviewed actuals reduce what remains open on a smrt-commerce PurchaseOrder (or one of its lines), each exactly once.

Out of scope: receipt OCR or parsing, posting to an accounting provider, and labour (see @happyvertical/smrt-timesheets, #3288).

Install​

pnpm add @happyvertical/smrt-expenses

Depends on @happyvertical/smrt-commerce (vendors, commitments) and @happyvertical/smrt-assets (receipt files). Profiles are referenced by id (@happyvertical/smrt-profiles:Profile) without a hard dependency.

Schema​

Tables are created by migrations, never at runtime:

smrt db:migrate # creates expenses and expense_receipts
smrt db:status --parity # verifies the live schema matches

Usage​

import {
ExpenseCollection,
ExpenseReceiptCollection,
computeContentSha256,
} from '@happyvertical/smrt-expenses';
import { withTenant } from '@happyvertical/smrt-tenancy';

await withTenant({ tenantId }, async () => {
const expenses = await ExpenseCollection.create({ db });
const expense = await expenses.create({
costObjectType: '@happyvertical/smrt-projects:Project',
costObjectId: project.id,
category: 'material',
description: 'Framing lumber',
amount: 100000, // $1,000.00 — integer minor units
currency: 'USD',
incurredOn: '2026-09-15',
recordedByProfileId: recorder.id,
commitmentId: purchaseOrder.id, // optional; must share the currency
});
await expense.save();

// Receipt: the hash is what makes duplicates detectable.
const receipts = await ExpenseReceiptCollection.create({ db });
await receipts.attachReceipt({
expenseId: expense.id,
assetId: asset.id,
contentSha256: computeContentSha256(fileBytes),
byteCount: fileBytes.byteLength,
uploadedByProfileId: recorder.id,
});

// Review is its own action — gate it in your route (e.g. `expenses.review`).
await expense.review({ reviewerProfileId: manager.id });

const position = await expenses.commitmentPosition(purchaseOrder.id);
// A 4,600.00 award with 1,000.00 reviewed: { drawn: 100000, open: 360000 }
});

Review​

Every expense starts unreviewed. Only these methods move it:

MethodFromTo
review({ reviewerProfileId })unreviewedreviewed
reject({ reviewerProfileId, reason })unreviewed, reviewedrejected
markDuplicate({ reviewerProfileId, duplicateOfId })unreviewed, reviewedrejected + duplicate marker
reopen({ reviewerProfileId })reviewed, rejectedunreviewed

Writing reviewStatus (or the reviewer, time, note or duplicate marker) directly and saving is refused. A reviewed expense's amount, currency, dates, cost object, vendor, commitment and payer are frozen until reopen(). Attaching a receipt and recording a reimbursement never change review state.

Receipts and duplicates​

// Same file, same expense → DuplicateReceiptError (code EXPENSE_RECEIPT_DUPLICATE)
// Same file, different expenses → allowed, and listed here:
const groups = await receipts.findDuplicateReceipts();
// [{ contentSha256, expenseIds: [a, b], receipts: [...] }]

Money and currencies​

Amounts are integer minor units ($19.99 is 1999). Totals are per currency and never summed across currencies:

await expenses.totalsByCurrency({ where: { costObjectId: job.id } });
// { USD: { amount: 125000, count: 3 }, CAD: { amount: 4000, count: 1 } }

An expense matched to a commitment must be in the commitment's currency; a mismatch is refused on save.

Reimbursement​

An expense paidBy: 'person' with paidByProfileId may be reimbursable (owed back). markReimbursed({ at, reference }) records the payback. Paying it is payroll's or payables' job (#3292).

Consumer controls​

  • Closed generated surface. No generated CRUD, read, MCP or CLI operation reaches cost data (generated reads check no read permission). List expenses through your own permission-checked routes.

  • Generated write actions (#4001). ExpenseCollection publishes four service-backed REST actions, each guarded inside the action by its expenses.<action> permission, with the tenant and acting profile taken from the session:

    RouteBody
    POST /expenses/recordexpense fields (amount minor units, currency, incurredOn, cost object, payer, …)
    POST /expenses/attachReceipt{ expenseId, assetId, contentSha256, filename?, mimeType?, byteCount? }
    POST /expenses/recordReview{ expenseId, decision: 'approve' | 'reject', note?, reason? }
    POST /expenses/recordReimbursement{ expenseId, reference? }

    recordReview and recordReimbursement refuse super-admin bypass. Acting on another person's expense needs expenses.recordReview. A recorded expense is unreviewed (awaiting review); there is no separate submit.

  • Required tenancy. Tenancy is optional by default, like smrt-commerce. To require a tenant context for every read and write:

    import { registerTenantScopedClass } from '@happyvertical/smrt-tenancy';

    registerTenantScopedClass('@happyvertical/smrt-expenses:Expense', { mode: 'required' });
    registerTenantScopedClass('@happyvertical/smrt-expenses:ExpenseReceipt', { mode: 'required' });

Mapping from consumer models​

Donor fieldsmrt-expenses
domacraft.com FinancialCost.kind = actualan Expense
FinancialCost.kind = commitmenta smrt-commerce PurchaseOrder (Contract), with ContractLineItem lines
FinancialCost.kind = labour@happyvertical/smrt-timesheets (#3288), not an expense
FinancialCost.kind = allowanceout of scope: a planned amount is a budget record, not an incurred cost
FinancialCost.currency, amount (minor units)currency, amount
FinancialCost.reviewStatusreviewStatus + reviewedByProfileId, reviewedAt, reviewNote
FinancialCost duplicate-review markermarkDuplicate() → duplicateOfId, rejected
FinancialCost.matchedCommitmentIdcommitmentId (+ optional commitmentLineId)
domacraft.com#143 vendor / worker / work packagevendorId / paidByProfileId / costObjectType + costObjectId
FinancialCostAsset (cost, Asset)ExpenseReceipt (expenseId, assetId)
FinancialCostAsset uploader, filename, mime, bytes, contentSha256uploadedByProfileId, filename, mimeType, byteCount, contentSha256
FinancialCostAsset unique tenant + cost + hashunique index (expense_id, content_sha256) — the expense fixes the tenant, and it also holds for NULL-tenant rows
teamworks-os JobExpense.jobcostObjectType + costObjectId
JobExpense.categorycategory (material, consumable, outside-service, freight, other, …)
JobExpense.descriptiondescription
JobExpense.amount (cents)amount + currency (set it explicitly)
JobExpense incurred dateincurredOn (YYYY-MM-DD); entry time is recordedAt
JobExpense logged-byrecordedByProfileId

Imported rows start unreviewed; replay each donor review through review() / reject() / markDuplicate() so the transition rules apply.

Testing​

pnpm --filter @happyvertical/smrt-expenses test # SQLite + UI SSR
pnpm --filter @happyvertical/smrt-expenses test:postgres # PostgreSQL lane
pnpm --filter @happyvertical/smrt-expenses test:e2e # Native browser forms

See also​

Expense and receipt UI​

ExpenseForm and ExpenseReviewPanel from @happyvertical/smrt-expenses/svelte provide native purchase entry, caller-driven duplicate/correction review and Assets receipt composition. ExpenseForm uses the shared localized currency selector while preserving native field names and rejected raw drafts. See the UI contract for public props, selector restrictions, native payloads, retained errors and server responsibilities.

The Svelte export also provides ExpenseList for a caller-authorized cost-object projection, ExpenseReceiptCapture for native file upload or shared camera capture, and ExpenseReviewQueue for native approval/rejection with retained reasons. ExpenseForm includes an optional amount keypad and editable cost-object references. See the UI contract for public Props, request identity, no-JavaScript behavior and server responsibilities.

Optional supplier financial facts​

Existing expenses retain only their recorded amount: getFinancialBreakdown() and getCostBasis() return null until a caller supplies explicit facts. An empty tax array asserts no tax; absent facts never imply tax-free treatment.

setFinancialBreakdown({ currency, subtotal, taxes, total }) accepts integer minor units. Each tax component is { code, amount, recoverableAmount }: its unique lower-case kebab-case code is caller-defined and its recoverable portion is explicit, including zero. Subtotal plus taxes must exactly equal both total and Expense.amount, in Expense.currency. Components share the total's sign, recovery cannot exceed charged tax, and all values must be safe integers. Unknown fields and malformed persisted JSON fail closed. The financialBreakdown text column stores these facts; use the helpers rather than editing JSON.

getCostBasis() returns { currency, subtotal, taxTotal, recoverableTax, nonRecoverableTax, total, costBasis }. Cost basis equals subtotal plus non-recoverable tax (equivalently total minus recoverable tax), with no rounding. For example, subtotal 10000, tax 1000 and recoverable tax 400 yield basis 10600. This is a purchase-cost fact, not a customer markup or sales-tax decision. Callers select their tax treatment; the package supplies no jurisdiction defaults.

paidBy: 'third-party' requires paidByName. It excludes paidByProfileId and reimbursable; company and person behavior remains unchanged. No reimbursement or settlement is inferred from the named payer.

Negative amounts require refundOfId, naming a positive non-refund expense visible in the same tenant and currency. Refund breakdown components are negative (or zero), including recovered tax. Self, missing, foreign-owner, currency-mismatched, and refund-of-refund sources fail closed. Source lookup uses the caller's database executor. Every persisted linked refund reserves gross cash capacity, including unreviewed or rejected rows. Cumulative refunds cannot exceed source gross. Known financial reversals additionally cannot exceed the source subtotal or any exact source tax-code component's charged, recoverable, or nonrecoverable amount; components cannot compensate for one another. Source and refund edits revalidate the prospective position. Known linked facts cannot be cleared. This records reversals, not settlement allocation, customer credit authority, or entitlement.

await expenses.refundPosition(sourceId) (also source.getRefundPosition()) returns positive sourceAmount, refundedAmount, and remainingAmount, plus all refundIds. Its financialPosition contains the original breakdown, signed negative cumulative refunded breakdown, and positive/zero remaining breakdown. It is null if source facts or any refund facts are unknown; unknown never implies zero tax or completed reversal. Known refunds on unknown legacy sources remain valid, bounded by known gross. When source facts exist, all known refund components remain bounded even if another refund's facts are unknown. The read retains the caller transaction and tenant boundary. Source gross is preserved: recording refunds never overwrites it.

Expense saves validate and persist in one transaction, including nested caller transactions. On PostgreSQL, source edits and refund writes acquire ordered row locks on the expense and proposed source before relationship validation, keeping the source/refund checks valid through the write and commit. SQLite uses the framework's serialized write transactions. The existing per-row revision check still rejects stale object writes; protecting the relationship additionally requires these locks and the source revision claim below. Callers must not concurrently reuse the same mutable model instance while it is temporarily transaction-bound. Direct SQL writes remain outside these model-level guarantees.

Linking a refund, changing its source, or editing its amount/financial facts claims the source revision in that same transaction through claimRevision(source.updated_at). This makes a source edit under an older PostgreSQL REPEATABLE READ snapshot fail with a serialization error instead of missing the new refund. Reload a source after linking a refund before editing/reviewing it; on a serialization failure, retry the entire caller transaction with fresh instances. Failed outer transactions roll back both the refund and the source revision claim. Saves that do not change capacity-relevant fields do not advance the source revision.

Financial breakdown, third-party payer name, and refund provenance join the existing reviewed-money lock. Save reopen() first, then correct facts and review again; corrections cannot ride alongside the reopen operation. New columns are additive via smrt db:migrate; legacy amounts survive without invented facts. The SQLite and PostgreSQL suites execute upgrades from the preceding schema and check that repeat migration is a no-op.

Refund cash recipients and reimbursement positions​

A refund's optional refundRecipient is guarded JSON, read/written through getRefundRecipient() / setRefundRecipient({ kind: 'company' }) or setRefundRecipient({ kind: 'person', profileId }) or setRefundRecipient({ kind: 'third-party', name }). A third-party name is exact retained source evidence (nonblank, at most 200 characters), not independently verified party/customer identity. It is never trimmed, fuzzily matched or resolved to another party by this API. It states who actually received the cash, independently of paidBy. Missing recipient is unknown; historical payer fields are never reinterpreted as receipt evidence. The field belongs only to linked negative refunds and joins the reviewed-money freeze.

For a positive person-paid reimbursable source, await expenses.reimbursementPosition(sourceId) (or source.getReimbursementPosition()) reports original sourceAmount, personRefundAmount, companyRefundAmount, retained reimbursedAmount, remainingAmount, and separate owedToCompany. Company-received refunds do not reduce the person's original gross cash claim. Same-person refunds do. A later person-received refund after reimbursement creates money owed to the company; it never becomes a negative reimbursement or automatically records settlement. These cash values remain separate from economic getCostBasis().

markReimbursed({ amount?, at?, reference? }) records only the full known remaining claim and retains exact reimbursedAmount, preserving source gross. An optional expected amount must match it exactly; partial payment is refused. An existing payment cannot be overwritten, and its cash claim/payer remain fixed. Payment fields cannot be forged by direct save. Existing no-refund callers may omit amount and record the full original gross as before.

unknownReason identifies an unknown recipient, another person's recipient, an explicit third-party receipt (unknown-third-party-recipient), or a historical reimbursement marker without retained paid cash. A third-party receipt is never counted as company/person cash or interpreted as income or settlement. Unknown positions return null liability/receivable rather than inferring zero or a historical gross payment. Resolve legacy evidence explicitly; this API supplies no automatic legacy conversion. Recording is not transfer execution. Both public position reads and writes preserve caller transaction affinity and source locks; refund-recipient changes also advance the source revision to reject stale REPEATABLE READ reimbursement decisions.

Supplier retention and payable evidence​

Expense.recordSupplierPayable() and the matching collection method retain an original supplier obligation separately from incurred money, tax recovery, company cost and employee reimbursement. The original payableBasisMinor is explicit or null; neither payer nor expense gross implies unpaid supplier debt. Original held/released/settled events carry exact source identity, original author, calendar date and explicit same-day sequence. Unknown original author is null, never a fabricated current reviewer. The current recorder and import review evidence are independent. Returned events have immutable UUIDs for host crosswalks. Recording evidence does not approve the expense, execute a payment, create a journal or contact an accounting provider.

const position = await expenses.recordSupplierPayable(expenseId, {
requestId, currency: 'CAD', payableBasisMinor: null,
source: { namespace: 'original-books', key: 'receipt-42', version: '1', author: 'Original bookkeeper' },
recordedByProfileId: reviewerProfileId,
evidenceRef: 'Reviewed original source export',
events: [{
kind: 'hold', amountMinor: 1000, effectiveOn: '2026-09-15', sequence: 0,
evidenceRef: 'Original retained amount',
source: { namespace: 'original-books', key: 'hold-7', version: '1', author: 'Original bookkeeper' },
}],
});
// position.retainedMinor === 1000; position.remainingPayableMinor === null

completeSupplierPayableBasis() can complete an originally unknown basis once, with explicit review evidence while preserving the original unknown snapshot. recordSupplierPayableEvent() appends a dated hold, release, supplier settlement or refund interpretation. Exact recordedRetainedMinor remains visible while retainedMinor is null if a linked refund lacks its allocation evidence. A settlement is evidence of money already paid; it is not a bank instruction. Current settlements require a known basis and every linked refund's explicit interpretation. Releases consume only retained capacity. The current caller remains responsible for host permissions and reviewer identity; these methods are not generated REST/MCP/CLI actions.

A linked negative Expense does not establish how much supplier debt was reduced or how much cash was returned. Its explicit refund event therefore names refundExpenseId, payableReductionMinor, retainedReductionMinor, and cashReturnedMinor, each bounded by the owning source/capacity. Retained reduction cannot exceed payable reduction. Unknown refund interpretation makes the payable position unknown and prohibits new settlement or release; it never becomes zero or an inferred allocation. Expense refund gross/component caps continue to apply.

Every chronological prefix conserves retained and paid capacity: obligation is basis minus explicit payable reductions; net paid is settlements minus cash returns; retained is holds minus releases and retained reductions; payable is obligation minus net paid and retained. No intermediate amount may be negative. An original unknown basis uses expense gross only as an upper validation bound, never as a reported payable amount. Source events precede neither the expense nor their exact linked refund date, and same-day events require unique sequence. getSupplierPayablePosition({ asOfDate }) / collection supplierPayablePosition returns dated sums with the complete original/current authority for provenance. It returns null before the original Expense date, rather than projecting a future obligation into an earlier period. A later reviewed basis completion is explicit retrospective interpretation, not proof that the amount was known to the original author on an earlier date.

Commands share Expense's transaction, source-row locks and revision guards with refunds. Exact retries return retained IDs; changed requests are refused. Original source and current completion/event evidence cannot be replaced or deleted; managed source money/payer/context and linked refund facts cannot be edited around the position. Caller rollback includes both evidence and source revision. Expenses with no supplier-payable authority keep their existing behavior.