Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions packages/expense/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { ExpenseLine } from '../objects/expense_line.object';

/**
* Seed data — covers the full template surface:
* • 6 categories with soft per-transaction limits
* • 8 categories with soft per-transaction limits (incl. Mileage, Telecom)
* • 5 reports spanning every lifecycle state + the approval threshold
* • 13 lines whose amounts match each report's total_amount
*
* Report totals are set explicitly so seeding stays consistent without
* relying on the line rollup hook (hooks fire on user edits, not seeds).
* Report totals are set explicitly because `total_amount` is a stored header
* field maintained at the top level — there is no line rollup hook (a nested
* parent write from a line hook crashes the sandbox; see CHARTER.md).
*/

const categories = defineSeed(ExpenseCategory, {
Expand Down Expand Up @@ -50,6 +51,14 @@ const categories = defineSeed(ExpenseCategory, {
per_txn_limit: 1000,
active: true,
},
{ name: 'Mileage', code: 'MILEAGE', gl_account: '6440', per_txn_limit: 500, active: true },
{
name: 'Telecom / Phone',
code: 'TELECOM',
gl_account: '6700',
per_txn_limit: 150,
active: true,
},
],
});

Expand Down
57 changes: 57 additions & 0 deletions packages/expense/src/flows/expense_approval.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ type Flow = Automation.Flow;
* and suspends the run until the expense-manager decides. It then resumes down
* its `approve` or `reject` out-edge.
*
* Amount-tiered routing: the manager signs off first; claims ≥ $1,000 then
* escalate to a finance-director second sign-off before final approval. Smaller
* claims are approved on the manager's decision alone.
*
* approve → status = approved, notify the employee
* reject → status = rejected, notify the employee to fix and resubmit
*
Expand Down Expand Up @@ -52,6 +56,28 @@ export const ExpenseApprovalFlow: Flow = {
},
},
},
{
id: 'needs_director',
type: 'decision',
label: 'Large Claim (≥ $1,000)?',
config: { condition: 'record.total_amount >= 1000', conditionDialect: 'cel' },
},
{
id: 'director_signoff',
type: 'approval',
label: 'Finance Director Sign-off',
config: {
approvers: [{ type: 'role', value: 'expense_director' }],
behavior: 'first_response',
lockRecord: true,
escalation: {
enabled: true,
timeoutHours: 72,
action: 'notify',
notifySubmitter: true,
},
},
},
{
id: 'set_approved',
type: 'update_record',
Expand Down Expand Up @@ -102,10 +128,41 @@ export const ExpenseApprovalFlow: Flow = {
{
id: 'e2',
source: 'manager_signoff',
target: 'needs_director',
type: 'conditional',
label: 'approve',
},
// Manager-approved: large claims escalate to the finance director; small
// claims are approved outright.
{
id: 'e2a',
source: 'needs_director',
target: 'director_signoff',
type: 'conditional',
condition: 'record.total_amount >= 1000',
label: '≥ $1,000',
},
{
id: 'e2b',
source: 'needs_director',
target: 'set_approved',
isDefault: true,
label: 'within manager limit',
},
{
id: 'e2c',
source: 'director_signoff',
target: 'set_approved',
type: 'conditional',
label: 'approve',
},
{
id: 'e2d',
source: 'director_signoff',
target: 'set_rejected',
type: 'conditional',
label: 'reject',
},
{ id: 'e3', source: 'set_approved', target: 'notify_approved', type: 'default' },
{ id: 'e4', source: 'notify_approved', target: 'end', type: 'default' },
{
Expand Down
17 changes: 14 additions & 3 deletions packages/expense/src/objects/expense_line.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import { P, F, tmpl } from '@objectstack/spec';

/**
* Expense Line — one out-of-pocket item on a report (a meal, a flight, a
* taxi). Lines belong to exactly one `expense_report`; their `amount`
* rolls up into `expense_report.total_amount` via the line hook.
* taxi). Lines belong to exactly one `expense_report`. Their `amount`
* contributes to `expense_report.total_amount`, but that header total is a
* STORED field maintained at the top level (client/seed) — NOT a rollup hook:
* a nested `update` of the parent from a line hook re-enters the QuickJS
* sandbox and crashes the runtime. See CHARTER.md ("Rollup vs. lifecycle
* hooks").
*
* Policy in v0 is deliberately light: a fixed receipt-required threshold
* (`needs_receipt` ≥ 75) plus the category's soft per-transaction limit.
Expand Down Expand Up @@ -67,6 +71,13 @@ export const ExpenseLine = ObjectSchema.create({
{ label: 'Personal — Other', value: 'personal_other' },
],
}),
reimbursable: Field.boolean({
label: 'Reimbursable',
defaultValue: true,
group: 'detail',
description:
'Uncheck for personal / non-reimbursable items. The reimbursable header total should exclude these (maintained client-side; not a hook rollup).',
}),
needs_receipt: Field.formula({
label: 'Receipt Required',
group: 'receipt',
Expand Down Expand Up @@ -94,7 +105,7 @@ export const ExpenseLine = ObjectSchema.create({

titleFormat: tmpl`{{record.description}}`,
displayNameField: 'description',
compactLayout: ['description', 'category', 'amount', 'expense_date', 'merchant'],
compactLayout: ['description', 'category', 'amount', 'reimbursable', 'expense_date', 'merchant'],

validations: [
{
Expand Down
9 changes: 6 additions & 3 deletions packages/expense/src/objects/expense_report.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
import { P, F, tmpl } from '@objectstack/spec';
/**
* Expense Report — the reimbursement claim an employee submits. It is a
* header over one or more `expense_line` rows; `total_amount` is rolled up
* from those lines by the `expense_line` afterInsert/Update/Delete hook.
* header over one or more `expense_line` rows. `total_amount` is a STORED
* header field maintained at the top level (client/seed), NOT a line-hook
* rollup: a nested parent `update` from a line hook crashes the QuickJS
* sandbox. See CHARTER.md ("Rollup vs. lifecycle hooks").
*
* Approval is threshold-driven: `approval_required` flips true when
* `total_amount >= 1000`. Below that, a manager can still approve, but the
Expand Down Expand Up @@ -84,7 +86,8 @@ export const ExpenseReport = ObjectSchema.create({
group: 'financial',
min: 0,
defaultValue: 0,
description: 'Sum of line amounts. Maintained by the line rollup hook.',
description:
'Sum of line amounts. Stored header field maintained client-side/seed (not a line rollup hook — that crashes the sandbox; see object docstring).',
}),
approval_required: Field.formula({
label: 'Approval Required',
Expand Down
16 changes: 11 additions & 5 deletions packages/expense/src/sharing/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.

/**
* Three-tier role hierarchy. Higher tiers inherit lower-tier grants.
* Role hierarchy. Higher tiers inherit lower-tier grants.
*
* expense_admin → expense_manager → expense_employee
* expense_admin → expense_director → expense_manager → expense_employee
*
* `expense_manager` is the approver pool referenced by the approval
* process and the notification flows.
* `expense_manager` is the first-tier approver; `expense_director` is the
* second-tier approver for large claims (≥ $1,000) — see
* `expense_approval.flow.ts`.
*/
export const RoleHierarchy = {
roles: [
{ name: 'expense_admin', label: 'Expense Admin', parentRole: null as string | null },
{
name: 'expense_director',
label: 'Finance Director',
parentRole: 'expense_admin' as string | null,
},
{
name: 'expense_manager',
label: 'Expense Manager',
parentRole: 'expense_admin' as string | null,
parentRole: 'expense_director' as string | null,
},
{ name: 'expense_employee', label: 'Employee', parentRole: 'expense_manager' as string | null },
],
Expand Down
1 change: 1 addition & 0 deletions packages/expense/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const en: TranslationData = {
personal_other: 'Personal — Other',
},
},
reimbursable: { label: 'Reimbursable' },
needs_receipt: { label: 'Receipt Required' },
receipt_attached: { label: 'Receipt Attached' },
notes: { label: 'Notes' },
Expand Down
1 change: 1 addition & 0 deletions packages/expense/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const zhCN: TranslationData = {
label: '支付方式',
options: { personal_card: '个人卡', cash: '现金', personal_other: '个人 — 其他' },
},
reimbursable: { label: '可报销' },
needs_receipt: { label: '需要票据' },
receipt_attached: { label: '已附票据' },
notes: { label: '备注' },
Expand Down
Loading