diff --git a/packages/expense/src/data/index.ts b/packages/expense/src/data/index.ts index ee188f9..363b42d 100644 --- a/packages/expense/src/data/index.ts +++ b/packages/expense/src/data/index.ts @@ -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, { @@ -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, + }, ], }); diff --git a/packages/expense/src/flows/expense_approval.flow.ts b/packages/expense/src/flows/expense_approval.flow.ts index cd613d3..f991dd7 100644 --- a/packages/expense/src/flows/expense_approval.flow.ts +++ b/packages/expense/src/flows/expense_approval.flow.ts @@ -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 * @@ -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', @@ -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' }, { diff --git a/packages/expense/src/objects/expense_line.object.ts b/packages/expense/src/objects/expense_line.object.ts index a1e0d85..3bacb3f 100644 --- a/packages/expense/src/objects/expense_line.object.ts +++ b/packages/expense/src/objects/expense_line.object.ts @@ -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. @@ -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', @@ -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: [ { diff --git a/packages/expense/src/objects/expense_report.object.ts b/packages/expense/src/objects/expense_report.object.ts index 2b8c43f..7578a8a 100644 --- a/packages/expense/src/objects/expense_report.object.ts +++ b/packages/expense/src/objects/expense_report.object.ts @@ -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 @@ -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', diff --git a/packages/expense/src/sharing/index.ts b/packages/expense/src/sharing/index.ts index 2e6e801..b8196ef 100644 --- a/packages/expense/src/sharing/index.ts +++ b/packages/expense/src/sharing/index.ts @@ -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 }, ], diff --git a/packages/expense/src/translations/en.ts b/packages/expense/src/translations/en.ts index 2000d61..cfbf6b0 100644 --- a/packages/expense/src/translations/en.ts +++ b/packages/expense/src/translations/en.ts @@ -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' }, diff --git a/packages/expense/src/translations/zh-CN.ts b/packages/expense/src/translations/zh-CN.ts index 32da41a..dba65c6 100644 --- a/packages/expense/src/translations/zh-CN.ts +++ b/packages/expense/src/translations/zh-CN.ts @@ -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: '备注' },