# Expenses: the module source.
#
# PROVENANCE. Written 2026-10-25 in the wave-3 spec pass (erp-method-w3, spec/wave3-v1). Version 1 SIGNED
# by seemo 2026-10-25 (signoff/expenses/; wave 3). It replaces the catalog's pending entry (start_from [gl, hr]). Written from:
# - the neighbours' specs at erp-method v0.1.29: hr v1 (the person, the derived manager, sensitive bank details, and
# its extension point expenses_supplier_role), gl v6 (POST /journal-entry/journals, fx rates, cost centres,
# periods), tax v5 (POST /documents/compute direction purchase; POST /documents/{document_ref}/check-contents for
# whether a receipt is a valid VAT invoice; business entertainment non-reclaimable by category), payments v5 (bank
# statements and reconciliation rules; no payout route yet), ar v6 (POST /invoices/raise), business-partner
# - the AI-step contract every brick follows (product, payments, consolidation ai_steps): suggest by default,
# consent first, a person decides each field, audited, sovereign models, an eval set
# - UK: ITEPA 2003 ss. 229-236 and HMRC approved mileage allowance payments (45p a mile for the first 10,000 business
# miles in a tax year, 25p after), HMRC benchmark scale rates for subsistence (EIM05231), VAT Regulations 1995
# reg. 29 (input tax needs a VAT invoice; a less detailed invoice up to 250.00 GBP), the VAT (Input Tax) Order 1992
# art. 5 (business entertainment blocked); Germany: s. 9 (4a) EStG (Verpflegungsmehraufwand 28.00 / 14.00 EUR,
# breakfast 20%, lunch and dinner 40% of the full-day rate deducted when provided) and s. 9 (1) no. 4a EStG
# (0.30 EUR a kilometre by car); Directive 2006/112/EC arts. 168 and 178 (the right to deduct needs an invoice)
# - Odoo 17 hr_expense (hr.expense, hr.expense.sheet) as the migration source; SAP Travel Management (FI-TV) and
# SAP Concur as parity references only
#
# The decisions a finance manager checks first:
# - nobody approves their own claim, or a claim of theirs routed back to them as a delegate: matched on email AND sub
# - rates (per diem, mileage), policy limits and expense categories are dated config rows per country; code never
# knows a country
# - the AI receipt reader only suggests, only after the employee consented, and every field it filled is marked until
# the employee confirms it; finance sees what the model read and what the person changed
# - an approved report posts one journal (expense, reclaimable VAT, employee payable or card liability); payments pays
# the employee and the bank statement says when it was paid
# - a company-card transaction is the cost; its receipt is the evidence; a card line nobody claims is chased, and
# personal spend on a company card is owed back, never quietly expensed
# Version 2 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 1 stays the signed one until it is signed):
# - permissions: the system actor (hr's service client grant) holds exp:leaver:intake, as time-off's holds
# tof:leaver:intake
# - PROCESS change: card_txn gains unmatch_card_txn (matched -> imported, while the claim is a draft; EXP-25);
# expense_report gains a reversed state and reverse_report (posted | paid -> reversed, finance, a mirror journal
# with gl event reverse, the payout cancelled if not exported, else an employee receivable; EXP-26, permission
# exp:report:reverse). No step is renamed or removed
# - hr seam (decided answer 2): a card holder and a delegate are person ids, their email read from HR-63 (was
# holder and delegate email typed in); the migration matches employees by work email through HR-64 (was the
# employee_map door)
# - PROCESS change (the migration run, seemo's decided answer 4, pattern §11): load writes staging rows only; sign
# is the first write to the ledger, through the normal use cases; discard deletes staging rows only; a signed
# run is reversed, never deleted. Effects added; no step renamed or removed
# - AI consent (pattern §4, decided answer 3): read_receipt keeps per-person consent under the owner's mode,
# because it reads the employee's own receipt
id: expenses
name: Expenses
short: Expenses
version: 2
maturity: tested # version 1 signed by seemo 2026-10-25; erp-brick-expenses 0.2.0 builds it (slices A and B, tested, not deployed); the latest version is a draft for re-sign
summary: >-
Let people claim what they spent for work in a minute: photograph the receipt, add mileage or a daily allowance by
the country's rules, see the company-card transactions waiting for a receipt, and send the claim to their manager.
Finance checks it against the policy and the VAT rules, the books get the cost and the reclaimable VAT, and the
employee is paid back with the next payment run.
decisions:
- proposed: "the approval chain is derived from hr on the submission date: the claimant's manager, then finance above finance_approval_threshold; a level whose approver is the claimant goes one level up; nobody approves their own claim (email AND sub)"
status: "for sign-off"
- proposed: "a returned claim is a new revision; approvals of an earlier revision never count for a later one"
status: "for sign-off"
- proposed: "the employee is reimbursed through payments as a payout to the employee's person partner (bp_key person:<person_id>); payroll reimbursement is an extension"
status: "for sign-off: question for the reference customer (customer_varies)"
- proposed: "the AI receipt reader runs in suggest mode, only after the employee's recorded consent, on the platform's sovereign models; off is always available"
status: "for sign-off"
- proposed: "a foreign-currency receipt is converted at gl's spot rate on the receipt date; a matched card transaction's billed amount replaces it"
status: "for sign-off"
facets:
domain: finance
industries: [any]
customer_words:
- "expense claims"
- "receipts"
- "reimburse the team"
- "mileage"
- "per diem"
- "subsistence"
- "company credit cards"
- "the card statement"
- "travel expenses"
- "Reisekosten"
replaces: [spreadsheets, paper envelopes of receipts, Concur, Expensify, Odoo Expenses, Pleo]
pillars: [sql, vector] # vector: duplicate receipt detection by content
first_module_fit: low
depends_on:
- module: gl
why: an approved claim posts its cost, VAT and payable; categories map to gl purposes and cost centres
required: true
- module: hr
why: the claimant is a person employed on the date, with a company, a cost centre and a derived manager; the approval chain is read from hr
required: true
- module: tax
why: the reclaimable VAT on each receipt, and whether the receipt is a valid VAT invoice at all
required: recommended
- module: payments
why: the employee is paid back and the payment is confirmed from the bank statement; without payments finance pays by hand and marks the claim paid
required: recommended
- module: business-partner
why: the employee's person partner (the payee) and a customer a cost is re-billed to
required: recommended
- module: ar
why: re-billing a cost to a customer is an invoice raised in ar (an extension)
required: optional
feeds: [gl, payments, ar]
owns_master_data: [expense_category]
references_master_data: [person, company, cost_centre, account, tax_code, business_partner, fx_rate]
actors:
- id: employee
name: Anyone who spends money for work
does: creates claims, adds receipts, mileage and allowances, matches card transactions, submits, answers questions, consents to the AI reader (or not)
sees: only their own claims, cards and receipts
permissions: [exp:report:read_own, exp:report:write_own, exp:report:submit, exp:card_txn:match_own, exp:ai:consent]
- id: manager
name: Line manager (derived from hr)
does: approves or returns their team's claims; delegates while absent
sees: the claims of people they manage on the submission date, and of people delegated to them
permissions: [exp:report:read_own, exp:report:write_own, exp:report:submit, exp:card_txn:match_own, exp:ai:consent, exp:report:read_team, exp:report:approve, exp:delegation:write]
note: "a manager is an employee too: their own claim goes to their manager, never to themselves"
- id: finance
name: Expenses accountant
does: checks claims against policy and VAT rules, approves above the threshold, imports card feeds, chases unclaimed card lines, posts, reads every claim
sees: every claim of their companies
permissions: [exp:report:read_all, exp:report:finance_approve, exp:report:post, exp:report:reverse, exp:card:write, exp:card_feed:import, exp:card_txn:resolve, exp:policy:write, exp:category:write, exp:rate:write, exp:report:read_own, exp:report:write_own, exp:report:submit, exp:migration:read]
note: "finance never approves a claim they submitted or one they approved as a manager: email AND sub"
- id: owner
name: The owner (or whoever the owner names)
does: holds the controls (approval thresholds, whether a policy limit warns or blocks, AI step modes, delegation rules), signs the migration reconciliation
sees: everything
permissions: [exp:report:read_all, exp:settings:controls, exp:migration:read, exp:migration:sign]
note: "anyone else holding exp:settings:controls is a configuration error the access test refuses"
- id: system
name: The system
does: reads receipts (when consented), computes mileage and allowances, checks policy, derives the approval chain, posts approved claims, hands payouts to payments, takes card feeds, flags duplicates, marks lines stale, loads an Odoo migration
sees: everything, through its own service account
permissions: [exp:report:mark_paid, exp:ai:read_receipt, exp:report:derive, exp:journal:post_auto, exp:payout:request, exp:card_feed:ingest, exp:number_series:claim, exp:migration:load, exp:migration:reconcile, exp:leaver:intake]
note: "exact grants to expenses' service client, never a wildcard, except exp:report:mark_paid, granted to payments' service client alone, and exp:leaver:intake, granted to hr's service client alone; the same client holds gl:journal:post, gl:fx:read, gl:cost_centre:read, gl:period:read, gl:company:read, tax:document_tax:compute and bp:partner:read"
stories:
# ── claiming ─────────────────────────────────────────────────────────────────────────────
- id: EXP-01
kind: person
actor: employee
story: As an employee, I want to photograph a receipt and have its date, merchant, amount and VAT filled in for me to check, so that a claim takes a minute, not an evening.
given: 'employee 3301 (UK01, cost centre SALES-N) consented to the receipt reader on 2026-10-01; ai step read_receipt in mode suggest; a hotel receipt from a Manchester hotel dated 2026-11-05, total 264.00 GBP, VAT 44.00, VAT number GB 987 6543 21'
when: 'I add the photo to my draft claim UK01-EXP-2026-00042'
then: >-
the image is stored as a DataK3 object raw/expenses/UK01-EXP-2026-00042/2/<sha256>.jpg; the reader suggests date
2026-11-05, merchant, total 264.00, currency GBP, VAT 44.00, the VAT number and category ACCOMMODATION, each marked
suggested; the line counts as ready only when I confirm or correct each field; what the model read and what I
changed is kept (exp_ai_finding); with the step off, or without my consent, the fields are empty and I type them
screen: My expenses
reference: not built
- id: EXP-02
kind: person
actor: employee
story: As an employee, I want to be asked once whether a model may read my receipts, and to change my mind, so that my receipts are read by a machine only because I agreed.
given: 'employee 3301 has never been asked; the platform''s sovereign models in the EU/UK region'
when: 'I open My expenses for the first time, read what is processed and where, and agree; on 2026-12-01 I withdraw'
then: >-
a consent row (person_id, step read_receipt, given_at, text_version) is written; from 2026-12-01 no new image of
mine is sent to the model and the fields stay empty; what was read before stays as I confirmed it; finance cannot
give consent for me
screen: My expenses
reference: not built
- id: EXP-03
kind: person
actor: employee
story: As an employee in the UK, I want mileage in my own car paid at the HMRC rates, including the change after 10,000 miles, so that I am paid the tax-free amount without working it out.
given: 'mileage rates UK car: 0.45 GBP a mile up to 10,000 business miles in a tax year (6 April to 5 April), 0.25 after; employee 3301 has 9,850 approved business miles in tax year 2026-27'
when: 'I claim 300 miles on 2026-11-04 (Leeds to Manchester and back, two client visits)'
then: >-
the line is 150 miles x 0.45 = 67.50 plus 150 miles x 0.25 = 37.50, total 105.00, with the band split shown; the
cumulative is read from approved lines of the same tax year (derived, never stored); a claim submitted earlier and
approved later changes the band, so this line is marked stale and recomputed at approval (EXP-20); miles are
NUMERIC(19,4), zero or negative refused 422
screen: My expenses
reference: not built
customer_varies: rates per vehicle type and country; whether you pay above the tax-free rate (the excess is taxable)
- id: EXP-04
kind: person
actor: employee
story: As an employee in Germany, I want my daily allowance for a business trip computed from the trip times, with meals I was given deducted, so that I am paid what the tax law allows.
given: 'employee 5120 (DE01); per diem DE domestic from 2025-01-01: 28.00 EUR for 24 hours away, 14.00 for an arrival or departure day or more than 8 hours; breakfast 20%, lunch 40%, dinner 40% of 28.00 deducted when provided'
when: 'I claim a trip Berlin to Munich from 2026-11-10 07:00 to 2026-11-12 19:00 with hotel breakfast on 2026-11-11 and 2026-11-12'
then: >-
arrival day 14.00 + full day 28.00 + departure day 14.00 = 56.00 less 2 x 5.60 = 11.20 gives 44.80 EUR; the trip is
one line with its days shown; overlapping trips of the same person are refused; the allowance is never below 0.00;
a per diem line needs no receipt
screen: My expenses
reference: not built
customer_varies: per diem by destination country (the BMF foreign rates are rows, never code); a UK company may use HMRC benchmark rates or actual cost
- id: EXP-05
kind: person
actor: employee
story: As an employee, I want a receipt in another currency converted for me, and replaced by what the card was actually billed, so that I am paid back what it cost.
given: 'employee 3301 (UK01, GBP) paid a Paris hotel 238.00 EUR on 2026-11-03 with their own card; gl spot rate EUR to GBP on 2026-11-03 0.870000; a second Paris dinner of 64.00 EUR paid with company card 4417, billed 56.12 GBP'
when: I add both
then: >-
the hotel line is 238.00 EUR at 0.870000 = 207.06 GBP (rounded once, half up, to GBP); the dinner line takes the
card's billed 56.12 GBP once matched and keeps 64.00 EUR as the receipt amount; a rate missing in gl blocks the line
with the currency and date named; amounts are never summed across currencies
screen: My expenses
reference: not built
- id: EXP-06
kind: person
actor: employee
story: As a company-card holder, I want my card transactions waiting for receipts in one list, so that I match each one instead of finding them at month end.
given: 'card 4417 (employee 3301) feed imported for 2026-11-01 to 2026-11-07: 5 transactions, one of 264.00 GBP from the Manchester hotel on 2026-11-05 and one of 18.00 GBP at a cinema on 2026-11-06'
when: 'I attach the hotel receipt (EXP-01) and mark the cinema as personal'
then: >-
the hotel transaction is matched to line 2 of my claim (the suggestion: same amount, date within 3 days, merchant
similar); the cinema transaction becomes personal spend I owe back, deducted from my next reimbursement or repaid
(EXP-14); a transaction can be matched to one line only, and a line to one transaction; an unmatched
transaction older than card_receipt_days (14) is chased (EXP-17)
screen: My expenses
reference: not built
- id: EXP-07
kind: person
actor: employee
story: As an employee, I want to be told while I fill in a claim when a line breaks the expense policy, so that I fix it before my manager has to send it back.
given: 'policy UK01: ACCOMMODATION London 180.00 a night warn, elsewhere 140.00 a night warn; MEALS_CLIENT 75.00 a person block without attendees; ALCOHOL not reimbursable (block)'
when: 'I add a Manchester hotel at 132.00 a night (2 nights, 264.00), a client dinner of 96.00 for 3 people with the attendees named, and a bar bill of 22.00'
then: >-
the hotel passes (132.00 under 140.00); the dinner passes (32.00 a person, attendees listed); the bar bill is
refused ("alcohol is not reimbursed, policy UK01 ALCOHOL"); a warn rule lets me submit with a reason that the
approver sees; a block rule stops submission; each rule names its row so finance can change it without code
screen: My expenses
reference: not built
- id: EXP-08
kind: person
actor: employee
story: As an employee, I want to submit my claim and see who has to approve it, so that I know where it is.
given: 'claim UK01-EXP-2026-00042 rev 1: train 86.40 (own money, VAT zero-rated), hotel 264.00 (card 4417, VAT 44.00), client dinner 96.00 (own money, VAT 16.00 non-reclaimable entertainment), mileage 105.00; total 551.40, reimbursable 287.40; finance_approval_threshold 500.00; manager of 3301 on 2026-11-07 is 2280 (hr, derived)'
when: 'I submit on 2026-11-07'
then: >-
gates: every line ready (fields confirmed), a receipt on every line above receipt_required_above (10.00) except
mileage and per diem, no block rule broken, every card transaction of the period matched or marked personal; the
chain is level 1 manager 2280, level 2 finance (551.40 is above 500.00), fixed at submission; the claim moves to
submitted and its lines are frozen; a submission with a gate unmet is refused with each gate named
screen: My expenses
reference: not built
# ── approving ────────────────────────────────────────────────────────────────────────────
- id: EXP-09
kind: person
actor: manager
story: As a manager, I want to approve or return my team's claims with the receipts and the policy warnings in front of me, so that I approve what I can see.
given: 'claim UK01-EXP-2026-00042 rev 1 at level 1 for manager 2280 (sub m2280, m@co)'
when: 'I approve it; earlier, my own claim UK01-EXP-2026-00039 had been routed: its level 1 is my manager 1904, not me'
then: >-
level 1 recorded (approved_by, approved_sub, at, revision 1); the claim moves to level 2 (finance); an approver who
is the claimant (same sub, or same email under a second login) is never on the chain: the level goes to the next
manager up, and to finance's head where there is none; a delegate is never the claimant either
screen: Approvals
reference: not built
- id: EXP-10
kind: person
actor: manager
story: As a manager, I want to send a claim back with a question, so that the employee fixes it without starting again.
given: 'claim UK01-EXP-2026-00051 rev 1 at level 1'
when: 'I return it with "which client was the dinner with?"; the employee answers, edits line 3 and resubmits'
then: >-
rev 1 is returned (terminal for rev 1, with my question kept); the employee edits a rev 2 copy of the lines, and
resubmission starts the chain again from level 1; approvals of rev 1 never count for rev 2; nothing moves back to an
earlier state
screen: Approvals
reference: not built
- id: EXP-11
kind: person
actor: finance
story: As the expenses accountant, I want to check a claim's VAT and policy before it posts, so that we reclaim VAT only on valid invoices and pay only what the policy allows.
given: 'claim UK01-EXP-2026-00042 rev 1 at level 2; tax answers per line: train Z 0.00; hotel input S 44.00 reclaimable (a valid VAT invoice with the hotel''s VAT number); dinner S 16.00 non-reclaimable (business entertainment); mileage outside scope'
when: 'I open it, see tax''s content check pass for the hotel, and approve'
then: >-
level 2 recorded; the claim is approved and posts (EXP-12); a receipt that fails tax's content check (no VAT number
on a 300.00 receipt) has its VAT made non-reclaimable and its full amount charged to the cost, with the gate shown;
I cannot approve a claim I submitted or approved at level 1 (email AND sub); a rejection needs a reason and is
terminal (the claim is not paid)
screen: Approvals
reference: not built
# ── posting and paying ───────────────────────────────────────────────────────────────────
- id: EXP-12
kind: system
actor: system
trigger: when the last level of a claim is approved
story: The system posts the approved claim, so that the cost, the reclaimable VAT and what we owe are in the books on the day it was approved.
given: 'claim UK01-EXP-2026-00042 rev 1 approved on 2026-11-09; categories: TRAVEL_RAIL -> purpose travel, ACCOMMODATION -> accommodation, MEALS_CLIENT -> entertainment, MILEAGE -> mileage'
when: the post runs, and runs again after a timeout
then: >-
gl POST /journal-entry/journals with journal_id derived_id("exp:UK01-EXP-2026-00042:r1", "approve"), source_module
expenses, company UK01, journal_date 2026-11-09 (the business date of approval): debit travel 86.40, accommodation
220.00, input VAT 44.00 (purpose tax, tax_code the input code), entertainment 96.00 (VAT included in the cost),
mileage 105.00, each with cost centre SALES-N; credit employee_payable 287.40 (bp_id the employee's person partner)
and card_liability 264.00 (card 4417); 551.40 each side; the retry is gl's replay; a closed period waits as
post_failed with gl's reason
reference: not built
- id: EXP-13
kind: integration
actor: system
trigger: after a claim posts with something reimbursable
story: The employee is paid back through payments, and the claim is paid when the bank says so.
given: 'claim UK01-EXP-2026-00042 posted with 287.40 reimbursable; personal card spend of 18.00 owed by 3301 (EXP-06); reimburse_with nett'
when: the payout is requested, twice, and later payments confirms it from the statement
then: >-
one payout of 269.40 (287.40 - 18.00) to the employee's person partner, payout_ref exp:UK01-EXP-2026-00042:r1:pay;
the 18.00 recovery is its own claim on the card transaction (recover:<card_txn_id>), so the payout and the
recovery never share a key; the repeat is payments' replay; the claim moves to paid only on payments' confirmation;
a payout refused by payments (no bank details) leaves the claim posted with the reason
owner: payments
reference: not built
- id: EXP-14
kind: person
actor: finance
story: As the expenses accountant, I want personal spend on a company card recovered, so that the company never pays for a cinema ticket.
given: 'card transaction card:4417:TX-88213 of 18.00 GBP marked personal by employee 3301 on 2026-11-07; no reimbursement due for 30 days'
when: 'the next claim of 3301 pays 120.00, or, with none within 30 days, I ask the employee to repay'
then: >-
the recovery is posted once (journal derived_id("card:4417:TX-88213", "personal"): debit employee_receivable 18.00,
credit card_liability 18.00) and cleared either by netting from the next payout or by a receipt in payments; a
recovery is never netted twice, and netting and repayment of the same transaction collide on one claim row so only
one wins
screen: Cards
reference: not built
- id: EXP-15
kind: person
actor: finance
story: As the expenses accountant, I want company-card feeds imported per card, so that every card transaction is in the system before anyone claims it.
given: 'card provider file for statement 2026-11 of account CORP-UK: cards 4417 and 5520, 46 transactions, total 6,412.75 GBP'
when: I import it, and import it again
then: >-
46 card transactions keyed card:<card_id>:<provider txn ref>, each assigned to its card's holder; the repeat imports
none; the file's total must equal the sum of its lines or nothing is imported; a card nobody holds is refused with
the card named; the provider's monthly direct debit is matched in payments by its reconciliation rule to purpose
card_liability, with no route needed between the two modules
screen: Cards
reference: not built
- id: EXP-16
kind: system
actor: system
trigger: when a receipt image is added
story: The system flags a receipt that looks like one already claimed, so that the same dinner is not paid twice.
given: 'employee 3301 claimed a 96.00 GBP dinner on 2026-11-04 in claim 00042; employee 3308 adds a photo of the same receipt'
when: the image is added
then: >-
the same image digest, or a near-identical image by vector similarity above duplicate_threshold with the same date
and amount, marks the line possible_duplicate naming claim 00042; the approver sees it; nothing is refused by the
flag alone, and a person decides (a split bill is legitimate)
reference: not built
- id: EXP-17
kind: system
actor: system
trigger: daily, in the composition's worker (no server-side scheduler)
story: The system chases card transactions with no receipt and claims waiting too long for approval, so that month end has no surprises.
given: 'card transaction card:4417:TX-88301 of 42.00 imported 2026-11-02, unmatched on 2026-11-17 (card_receipt_days 14); claim 00047 waiting at level 1 for 6 business days (approval_reminder_days 5)'
when: the daily pass runs on 2026-11-17, and again on the same business date
then: one reminder to the card holder and one to the approver, keyed (kind, ref, business_date), so the second pass sends nothing; the business date is in business_timezone
reference: not built
- id: EXP-18
kind: integration
actor: system
trigger: when a line is ready, and at finance approval
story: Tax answers each receipt's VAT and whether the receipt is a valid VAT invoice.
given: a line with category, amount, currency, receipt date and the merchant's VAT number
when: expenses calls tax
then: tax answers tax_code, rate, vat, vat_reclaimable, vat_non_reclaimable and the content gates; expenses stores the answer as its copy and never applies a rate itself
owner: tax
reference: not built
- id: EXP-19
kind: integration
actor: system
trigger: at submission (the chain), and on every read of the claimant
story: The claimant, their company, cost centre and manager come from hr.
given: 'employee 3301 assigned to SALES-N in UK01, managed by 2280 on 2026-11-07'
when: expenses derives the chain
then: it reads the person's employment and manager on the submission date from hr and fixes the chain on the claim; a leaver can still submit claims for 90 days after their last day (leaver_claim_days), approved by their last manager's successor
owner: hr
reference: not built
- id: EXP-20
kind: system
actor: system
trigger: when an input of a not-yet-approved line changes (a rate row, an fx rate, an earlier mileage approval, a policy row)
story: The system marks lines stale and recomputes them before anyone approves, so that nobody approves an amount computed from yesterday's rules.
given: 'claim 00046 (submitted, 200 miles) and claim 00049 (submitted, 300 miles) of employee 3301 with 9,850 approved miles; 00049 computed as 150 at 0.45 + 150 at 0.25'
when: '00046 is approved first (cumulative 10,050)'
then: >-
00049's mileage line is marked stale and recomputed as 300 at 0.25 = 75.00 (was 105.00), shown to the approver as
a change with its cause; an approved or posted line is never recomputed; the stale flag clears only by a recompute
reference: not built
- id: EXP-21
kind: integration
actor: finance
trigger: when an approved line is marked rebillable to a customer
story: A cost re-billed to a customer is raised as an invoice line in ar (an extension).
given: 'line 1 of claim UK01-EXP-2026-00042 (train 86.40) marked rebillable to customer 50211 at cost'
when: the claim posts
then: expenses asks ar to raise the line for customer 50211 (source_kind expense, source_id exp:<report_id>:<line_no>), once; ar owns the invoice; the expense is not posted twice
owner: ar
reference: not built
- id: EXP-22
kind: person
actor: owner
story: As the owner, I want the controls on approvals held by me, so that a manager cannot raise the threshold above which finance looks.
given: 'finance_approval_threshold 500.00; policy mode per rule'
when: a finance user tries to lower a block to a warn on ALCOHOL; I raise the threshold to 750.00 from 2026-12-01
then: the first is refused (exp:settings:controls); mine is a dated row after today; a claim submitted on 2026-11-30 keeps 500.00
screen: Settings
reference: not built
- id: EXP-23
kind: person
actor: finance
story: As the expenses accountant, I want categories, rates and policy rules kept per company and country as dated rows, so that a new tax year's rates are entered, not programmed.
given: 'mileage UK car 0.45 / 0.25 from 2011-04-06; DE per diem 28.00 / 14.00 from 2025-01-01'
when: I add DE per diem rows for a destination (France, 2027-01-01) and a new category CONFERENCE (purpose training, tax category standard)
then: each row is dated with valid_from after today; a claim uses the rows in force on each line's expense date; a category maps to one gl purpose and one tax category, never to an account number typed here
screen: Settings
reference: not built
- id: EXP-24
kind: person
actor: owner
story: As the owner moving off Odoo, I want the posted and paid claims carried and the open ones reconciled per employee, so that I can sign that nobody is owed more or less.
given: 'Odoo 17 hr_expense: sheets in states post and done for 2025 and 2026, three sheets approved and not paid'
when: the migration loads, reconciles and I sign
then: >-
posted and paid sheets become read-only claims (report_id odoo:<db>:hr.expense.sheet:<id>); the three unpaid
sheets become posted claims with their reimbursable amounts, to be paid here; the open amount per employee equals
Odoo's to the penny; draft and submitted sheets are not carried (finished in Odoo, or re-entered)
screen: Migration
reference: not built
- id: EXP-25
kind: person
actor: employee
story: As an employee, I want to undo a card match I made to the wrong line, so that the claim shows what I actually spent before I submit it.
given: 'card transaction card:4417:TX-88301 of 42.50 GBP matched by employee 3301 to line 2 of draft claim UK01-EXP-2026-00231 (typed 40.00)'
when: 'I unmatch it'
then: >-
the transaction is imported (unmatched) again and the line's amount returns to the 40.00 I typed; the match is kept
in the transaction's history with who undid it and when; once the claim is submitted the match is frozen with the
lines and refused 409 {gate: claim_is_draft, fix: "ask the approver to return the claim"}; finance (exp:card_txn:resolve)
may unmatch for anyone on the same gate
screen: My expenses
reference: "v2 (2026-10-26): the signed process had no unmatch (erp-brick-expenses 0.2.0 AGENTS.md); not built"
- id: EXP-26
kind: person
actor: finance
story: As the expenses accountant, I want to reverse a claim posted in error, so that the ledger and the employee's balance are put right without editing what was posted.
given: 'claim UK01-EXP-2026-00198 posted 2026-11-10 (journal derived_id("exp:UK01-EXP-2026-00198:r1", "approve"), 86.40 GBP reimbursable), its payout requested'
when: 'I reverse it with a reason'
then: >-
a mirror journal is posted once, keyed derived_id("exp:UK01-EXP-2026-00198:r1", "reverse") (gl event reverse, as gl GL-30 lists for expenses), dated today
if the period is open, else the first day of the next open period; the claim is reversed (terminal; a corrected
claim is a new one); a payout not yet exported is cancelled (payments POST /payouts/{payout_ref}/cancel); one
already exported or paid stands, and the reversal leaves an employee_receivable of 86.40 that is netted from the next
payout or repaid in payments, as a personal card spend is (EXP-14); reversing twice posts nothing more
screen: Approvals
reference: "v2 (2026-10-26): the signed process had no reversal of a posted claim (erp-brick-expenses 0.2.0 AGENTS.md); not built"
processes:
- key: expense_claim
pcf_id: null # APQC 9.8 Process and manage travel and expense reimbursements (9.8.x): id to confirm
pcf_name: Process expense reimbursements
reference_names: ["Travel and expense", "Expense to pay"]
brick: expenses
workflows:
- entity: expense_report
states: [draft, submitted, approved, posted, paid, returned, rejected, cancelled, post_failed, reversed]
terminal: [paid, returned, rejected, cancelled, reversed]
transitions:
- { key: create_report, name: Start a claim, from: none, to: draft, by: employee, gate: "the claimant employed on the date (hr)", effect: "report_id claimed from the company's yearly series" }
- { key: read_receipt, name: "Read the receipt (AI, suggest)", from: draft, to: draft, by: system, gate: "the employee's consent recorded; the step's mode suggest", effect: "suggested fields marked until confirmed (EXP-01)" }
- { key: submit_report, name: Submit the claim, from: draft, to: submitted, by: employee, gate: "lines ready; receipts present; no block rule broken; card transactions of the period resolved; no stale line", effect: "approval chain derived from hr and fixed; lines frozen" }
- { key: approve_level, name: Approve one level, from: submitted, to: submitted, by: [manager, finance], approval: true, gate: "the approver is the level's approver or delegate and is not the claimant (email AND sub); the revision is the current one", effect: "the level recorded; on the last level the claim moves to approved" }
- { key: complete_approval, name: Last level approved, from: submitted, to: approved, by: system, gate: "every level of the revision approved" }
- { key: return_report, name: Return with a question, from: submitted, to: returned, by: [manager, finance], requires: reason, effect: "a draft revision n+1 created with the lines copied; rev n approvals void" }
- { key: reject_report, name: Reject, from: submitted, to: rejected, by: [manager, finance], requires: reason }
- { key: cancel_report, name: Cancel a draft, from: draft, to: cancelled, by: employee }
- { key: post_report, name: Post the claim, from: [approved, post_failed], to: posted, by: system, gate: "the gl period of the approval date open", effect: "one journal (EXP-12); a payout requested from a pending row (EXP-13)" }
- { key: fail_report_post, name: Posting refused, from: approved, to: post_failed, by: system, effect: "gl's gate and reason kept" }
- { key: confirm_paid, name: Paid, from: posted, to: paid, by: system, gate: "payments confirmed the payout" }
- { key: reverse_report, name: Reverse the posted claim, from: [posted, paid], to: reversed, by: finance, requires: reason, gate: "not reversed already; a period open for the reversal date (today, else the first day of the next open period)", effect: "v2 (EXP-26): a mirror journal keyed derived_id('exp:<report_id>:r<revision>', 'reverse'), gl event reverse; a payout not yet exported cancelled, else an employee_receivable netted or repaid as in EXP-14" }
readiness:
- { transition: submit_report, gate: lines_ready, reason: "lines with fields not confirmed: named", fix: "confirm or correct each suggested field" }
- { transition: submit_report, gate: receipts_present, reason: "lines above {receipt_required_above} with no receipt: named", fix: "add the receipt, or a signed missing-receipt declaration where the policy allows" }
- { transition: submit_report, gate: policy_blocks, reason: "a block rule is broken: named with its row", fix: "remove or change the line" }
- { transition: submit_report, gate: card_resolved, reason: "card transactions of the period not matched or marked personal: named", fix: "My expenses: match them" }
- { transition: submit_report, gate: not_stale, reason: "lines computed from rules that changed: named", fix: "recompute (one click)" }
- { transition: approve_level, gate: second_person, reason: "you are the claimant", fix: "the claim goes to the next approver up" }
- { transition: post_report, gate: period_open, reason: "the gl period is closed", fix: "finance posts it in the next open period" }
note: "returned is terminal for its revision; the claim continues as revision n+1, so no state is ever re-entered"
- entity: card_txn
states: [imported, matched, personal, recovered]
terminal: [recovered]
transitions:
- { key: import_card_txn, name: Imported from the feed, from: none, to: imported, by: system, gate: "the feed's total equals its lines; the card has a holder" }
- { key: match_card_txn, name: Match to a claim line, from: imported, to: matched, by: employee, gate: "the line unmatched; the same currency billed", effect: "the line's amount becomes the billed amount" }
- { key: mark_personal, name: Mark as personal, from: imported, to: personal, by: employee, effect: "recovery journal posted (EXP-14)" }
- { key: unmatch_card_txn, name: Undo the match, from: matched, to: imported, by: [employee, finance], gate: "the matched line's claim is a draft (claim_is_draft)", effect: "v2 (EXP-25): the line's amount returns to what was typed; the match kept in the transaction's history" }
- { key: recover_personal, name: Recovered, from: personal, to: recovered, by: system, gate: "netted from a payout or repaid in payments (one claim row decides)" }
- entity: exp_migration_run
states: [loaded, reconciled, signed, discarded]
terminal: [signed, discarded]
transitions:
- { key: load_exp_from_odoo, name: Load Odoo's expenses, from: none, to: loaded, by: system, gate: "hr, business-partner and gl migrations signed", effect: "v2 (pattern §11, seemo's decided answer 4): staging rows only (exp_migration_row, keyed by the source id); nothing written to the ledger, balances or journals" }
- { key: reconcile_exp_migration, name: Reconcile against Odoo, from: loaded, to: reconciled, by: system }
- { key: sign_exp_reconciliation, name: Sign the reconciliation, from: reconciled, to: signed, by: owner, approval: true, gate: "every check equal or explained", effect: "v2 (decided answer 4): the staged rows written through this module's normal use cases, idempotent on the source key; the first write to the ledger; a signed run is later reversed by reversing entries, never deleted" }
- { key: discard_exp_migration, name: Discard the run, from: [loaded, reconciled], to: discarded, by: owner, requires: reason, effect: "v2 (decided answer 4): the run's staging rows deleted, nothing else (no ledger row was written)" }
entities:
- id: expense_report
meaning: one claim by one person, approved as a whole
natural_key: report_id = "<company_code>-EXP-<yyyy>-<nnnnn>" (claimed from the company's yearly series; migrated odoo:<db>:hr.expense.sheet:<id>)
key_attributes: [person_id (hr), company_code, revision, status, title, submitted_at, submitted_by, submitted_sub, chain_json (levels fixed at submission), journal_id, payout_ref, total_amount, reimbursable_amount, currency]
master_data: module-owned
reference_table: exp_report
- id: expense_line
meaning: one cost on a claim (a receipt, a mileage trip, a per diem trip)
natural_key: [report_id, revision, line_no]
key_attributes: [kind (receipt | mileage | per_diem), category_code, expense_date, merchant, receipt_amount, receipt_currency, fx_rate (NUMERIC 19,6), amount (company currency), paid_with (own | card), card_txn_id, tax_code, vat_amount, vat_reclaimable, vat_non_reclaimable, cost_centre, rebill_bp_id, miles_or_km (NUMERIC 19,4), trip_from, trip_to, meals_provided, policy_result_json, ai_fields_json, stale (bool), possible_duplicate_of]
master_data: module-owned
reference_table: exp_line
- id: receipt_image
meaning: the photo or PDF of a receipt, kept as evidence for the retention period
natural_key: object key raw/expenses/<report_id>/<line_no>/<sha256>.<ext>
key_attributes: [sha256, embedding (VECTOR, for duplicates), uploaded_by, uploaded_at, retain_until]
master_data: module-owned
reference_table: exp_receipt_image
- id: approval_step
meaning: one level of one revision's chain, and what happened at it
natural_key: [report_id, revision, level]
key_attributes: [approver_person_id, delegate_person_id, decision (approved | returned | rejected), decided_by, decided_sub, decided_at, reason]
master_data: "module-owned (append-only)"
reference_table: exp_approval_step
- id: card
meaning: a company card and who holds it
natural_key: card_id VARCHAR (the provider's masked card reference, 4417)
key_attributes: [company_code, holder_person_id, provider, currency, status]
master_data: module-owned
reference_table: exp_card
- id: card_txn
meaning: one transaction on a company card from the provider's feed
natural_key: card_txn_id = "card:<card_id>:<provider txn ref>"
key_attributes: [posted_on, merchant, billed_amount, billed_currency, original_amount, original_currency, status, matched_line, statement_ref]
master_data: module-owned
reference_table: exp_card_txn
- id: card_claim
meaning: the row two operations on one card transaction race for (match, personal recovery by netting, recovery by repayment)
natural_key: [card_txn_id, operation]
key_attributes: [report_id, payout_ref, payment_id, claimed_at]
master_data: "module-owned (append-only; a plain INSERT; operation is part of the key, so a match and a recovery never collide, and netting and repayment of one recovery share operation recover so only one wins)"
reference_table: exp_card_claim
- id: expense_category
meaning: what a cost is for, in the employee's words, and how it posts
natural_key: [company_code, category_code, valid_from]
key_attributes: [name, gl_purpose, tax_category, receipt_required, kind (receipt | mileage | per_diem)]
master_data: SHARED, sole writer (gl maps the purpose to an account)
reference_table: exp_category
- id: rate
meaning: a mileage or per diem rate for a country, vehicle or destination, from a date
natural_key: [kind (mileage | per_diem), country, variant (car | motorcycle | bicycle | destination code), band, valid_from]
key_attributes: [rate_amount (NUMERIC 19,4), currency, band_upto (miles or km in the tax year), partial_day_amount, breakfast_pct, lunch_pct, dinner_pct, source (the statute or authority named)]
master_data: module-owned
reference_table: exp_rate
- id: policy_rule
meaning: a limit or a ban on a category, warn or block
natural_key: [company_code, rule_code, valid_from]
key_attributes: [category_code, scope (per item | per night | per person | per day), city_or_country, limit_amount, currency, mode (warn | block), requires (attendees | justification)]
master_data: module-owned
reference_table: exp_policy_rule
- id: consent
meaning: an employee's yes or no to an AI step reading their receipts
natural_key: [person_id, step_key, given_at]
key_attributes: [decision (given | withdrawn), text_version]
master_data: "module-owned (append-only)"
reference_table: exp_consent
- id: ai_finding
meaning: what the model read from a receipt and what the person did with each field
natural_key: [report_id, line_no, finding_no]
key_attributes: [model, inputs_digest, fields_json, confirmed_json, decided_by, decided_at]
master_data: module-owned
reference_table: exp_ai_finding
- id: outbox
meaning: a side effect waiting for a neighbour after the local commit
natural_key: [target, ref]
key_attributes: [payload, status, attempts, last_error]
master_data: module-owned
reference_table: exp_outbox
- id: settings
meaning: the controls and defaults, dated
natural_key: [key, valid_from]
key_attributes: [value, set_by]
master_data: module-owned
reference_table: exp_settings
master_data:
owns: [exp_category]
references:
- entity: person
owner: hr
how: "person_id on the claim; employment, company, cost centre and manager read from hr on the date"
if_absent: "no claims: expenses keeps no copy of the employee"
- entity: business_partner
owner: business-partner
how: "the employee's person partner (bp_key person:<person_id>, synced by hr) as payee; bp_id of a re-billed customer"
if_absent: "finance pays employees by hand"
- entity: account
owner: gl
how: "a category's gl purpose, mapped to an account in gl's account map"
if_absent: "no posting"
- entity: tax_code
owner: tax
how: "tax_code and the reclaimable split from tax's compute answer"
if_absent: "every receipt's VAT is part of its cost (nothing reclaimed)"
- entity: fx_rate
owner: gl
how: "GET /fx-rates/{currency}?on=<receipt date>&type=spot"
if_absent: "a foreign-currency line is refused with the rate named"
warning: >-
An expenses tool that keeps its own list of employees, managers or bank details is how a leaver's claim is approved
by nobody and paid to an old account. The person, the manager and the bank details stay in hr; the payee is hr's
person partner.
jobs:
- story: EXP-12
trigger: when a claim's last level is approved
runs: post the journal, write the payout outbox row
where_options: [in the approval request after commit, the composition's worker]
idempotent: 'journal_id = derived_id("exp:<report_id>:r<revision>", "approve")'
- story: EXP-16
trigger: when a receipt image is added
runs: digest and embedding, duplicate search
where_options: [in the request, the composition's worker]
idempotent: "keyed on the image's sha256"
- story: EXP-17
trigger: daily in the composition's worker (no server-side scheduler)
runs: card receipt and approval reminders
where_options: [always-on worker (--reserved 1 --max-replicas 1), external scheduler]
idempotent: "reminders keyed (kind, ref, business_date)"
- story: EXP-20
trigger: when a rate, policy, fx rate or an earlier mileage approval changes
runs: mark affected not-yet-approved lines stale
where_options: [in the request that made the change]
idempotent: "stale is a flag; recompute is a pure function of the inputs in force"
seams:
- story: EXP-13
with: payments
owner: payments
route: "POST /payouts"
permission: pay:payout:intake
request: '{payout_ref: "exp:<report_id>:r<revision>:pay", payee_bp_id (the employee''s person partner), person_id, company_code, currency, amount (string, NUMERIC 18,2), due_on (YYYY-MM-DD), reference (the report id, on the bank line), source_module: expenses, source_document: report_id, clearing_purpose: employee_payable}'
request_schema:
- { field: payout_ref, type: string, required: true }
- { field: payee_bp_id, type: bp_id, required: true }
- { field: person_id, type: person_id, required: true }
- { field: company_code, type: company_code, required: true }
- { field: currency, type: currency, required: true }
- { field: amount, type: decimal, required: true }
- { field: due_on, type: date, required: true }
- { field: reference, type: string, required: true }
- { field: source_module, type: string, required: true, values: [expenses] }
- { field: source_document, type: string, required: true }
- { field: clearing_purpose, type: string, required: true, values: [employee_payable] }
response: '201 {payout_ref, payout_id, status: requested}; 200 the same for the same payout_ref; 409 for a known payout_ref with a different amount; 422 {gate: payee_is_person | paying_account | bounds, reason, fix}'
rule: "payments approves it, pays it in a payout run, posts bank against employee_payable, and confirms it from the statement by calling expenses' POST /reports/{report_id}/paid; expenses never writes pay_ tables"
status: "satisfied by payments v6 (PAY-51, payout); the employee's bank details are read by payments from hr (hr v2 HR-59), never by expenses"
- story: EXP-13
with: payments
owner: expenses
route: "POST /reports/{report_id}/paid"
permission: exp:report:mark_paid
request: '{payout_ref, payment_id, paid_on (YYYY-MM-DD, the statement date), amount}'
rule: "called by payments when the statement shows the payout; idempotent on payout_ref; a different amount is refused 409"
request_schema:
- { field: report_id, type: string, required: true, in: path }
- { field: payout_ref, type: string, required: true }
- { field: payment_id, type: payment_id, required: true }
- { field: paid_on, type: date, required: true }
- { field: amount, type: decimal, required: true }
response: "200 the report, status paid; 200 the same for a repeated payout_ref; 409 {gate: amount_matches, reason, fix}"
status: "offered by expenses v1; payments v6 calls it after confirm_payout_paid (PAY-51)"
- story: EXP-13
with: payments
owner: payments
route: "POST /payouts/{payout_ref}/cancel"
permission: pay:payout:intake
request: 'path payout_ref; body {reason}'
response: '200 {payout_ref, status: cancelled}; 409 {gate: not_exported, reason, fix} once exported'
rule: "a claim reversed before it is paid cancels its payout; after export the payment stands and the amount is recovered from the employee (employee_receivable)"
status: "satisfied by payments v6 (PAY-51)"
- story: EXP-18
with: tax
owner: tax
route: "POST /documents/compute"
permission: tax:document_tax:compute
request: '{document_ref: "exp:<report_id>:<line_no>", direction: purchase, tax_point: the expense date, currency (the receipt''s), treatment_code (the merchant: uk_business unless the receipt shows otherwise), no bp_id (a merchant is not a partner), prices_include_tax: true, fx_rate (the line''s frozen rate), lines: [{line_no: 1, description, category_code (the expense category''s tax category), supply, amount, quantity: "1", unit_price}]}'
rule: "tax answers vat_reclaimable and vat_non_reclaimable per line (entertainment non-reclaimable by category); expenses stores the answer"
status: "exists in tax v5 (TAX-23); compute without bp_id when treatment_code is given satisfied by tax v6 (TAX-53)"
- story: EXP-18
with: tax
owner: tax
route: "POST /documents/{document_ref}/check-contents"
permission: tax:document_tax:compute
request: '{direction: purchase, contents: {invoice_number (the receipt''s), issue_date, supplier_name, supplier_address (the address shape, shared.yaml conventions.address), supplier_vat_number, customer_name (optional on a less detailed invoice)}}'
rule: "a receipt failing a content gate has its VAT made non-reclaimable (EXP-11)"
status: "exists in tax v5 (TAX-24); the less detailed invoice rule satisfied by tax v6 (TAX-50, simplified_invoice_threshold 250.00 GBP)"
- story: EXP-12
with: gl
owner: gl
route: "POST /journal-entry/journals"
permission: gl:journal:post
request: '{journal_id: derived_id("exp:<report_id>:r<revision>", "approve"), source_module: expenses, source_document: "exp:<report_id>:r<revision>", event: approve | personal | reverse, journal_date, currency, company_code, lines: [{purpose: <category purpose> | tax | employee_payable | card_liability | employee_receivable, tax_code, cost_centre, bp_id, debit, credit}]}'
rule: "one journal per approved revision; expenses never writes gl_ tables"
status: "satisfied by gl v7 (GL-30 row for source_module expenses, its purposes and the category purposes)"
- story: EXP-05
with: gl
owner: gl
route: "GET /fx-rates/{currency}"
permission: gl:fx:read
request: "path currency (the receipt's); query on (the expense date), type spot, to (the company currency)"
rule: "the rate is copied onto the line and frozen at submission"
status: "exists in gl v6 (GL-33)"
- story: EXP-23
with: gl
owner: gl
route: "GET /cost-centres/{cost_centre}"
permission: gl:cost_centre:read
request: "path cost_centre"
rule: "a line's cost centre (default the claimant's from hr) must exist and be open on the expense date"
status: "exists in gl v6 (GL-61)"
- story: EXP-12
with: gl
owner: gl
route: "GET /periods/open"
permission: gl:period:read
request: "query date (the approval business date), company_code"
rule: "the gate period_open"
status: "exists in gl v6 (GL-32)"
- story: EXP-19
with: hr
owner: hr
route: "GET /persons/{person_id}/employment?on="
permission: hr:employment:read # to be added by hr v2 (machine)
request: "path person_id; query on (the date)"
response: "200 {person_id, on, employed, engagement_id, worker_type, company_code, org_unit_id, location_code, cost_centre, bp_id}"
rule: "employed or not, company, cost centre; the same need as pos's POS-32"
status: "satisfied by hr v2 (HR-56, employment on a date)"
- story: EXP-19
with: hr
owner: hr
route: "GET /persons/{person_id}/manager?on=&level="
permission: hr:manager:read # to be added by hr v2 (machine)
request: "path person_id; query on (the submission date), level (1 or 2)"
response: "200 {person_id, on, level, manager: {person_id, bp_id, pool_user_sub, email} | null, source: override | org_tree}"
rule: "hr's derived manager (ManagerOf: the chief of the assignment's org unit, walking up; the override wins), and the next manager up when asked with level=2"
status: "satisfied by hr v2 (HR-57, the derived manager on a date)"
- story: EXP-19
with: hr
owner: hr
route: "GET /persons?pool_user_sub="
permission: hr:employment:read # to be added by hr v2 (machine)
request: "query pool_user_sub (the caller's sub)"
response: "200 {person_id, bp_id, company_code}; 404 when no person carries that sub"
rule: "the signed-in user's person_id (hr_person.pool_user_sub)"
status: "satisfied by hr v2 (HR-58, the person by pool_user_sub)"
- story: EXP-19
with: hr
owner: expenses
route: "POST /employees/{person_id}/leaver"
permission: exp:leaver:intake
request: '{last_day (YYYY-MM-DD), source_ref: "hr:left:<person_id>:<n>"}'
request_schema:
- { field: person_id, type: person_id, required: true, in: path }
- { field: last_day, type: date, required: true }
- { field: source_ref, type: string, required: true }
rule: "called by hr after the Left event commits (from its pending rows); idempotent on source_ref. Expenses fixes leaver_claim_days from last_day, moves the person's open reports' approval to the derived manager's successor (EXP-19), stops their company cards from the day after last_day and lists unmatched card transactions for finance; it never deletes a report"
response: "200 {person_id, last_day, claims_until, open_reports, cards_stopped}; 200 the same for a repeated source_ref; 422 {gate: bounds, reason, fix}"
status: "offered by expenses v1; hr v2 calls it on hr.person.left (HR-54)"
- story: EXP-21
with: ar
owner: ar
route: "POST /invoices/raise"
permission: ar:invoice:raise
request: '{source_kind: expense, source_id: "exp:<report_id>:<line_no>", bp_id (the customer), currency, kind: regular, lines: [{description, quantity: "1", unit_code (optional; C62), unit_price (the cost, or the cost plus markup, NUMERIC 19,4), tax_category}]}'
response: '201 the invoice {invoice_id, status: draft, lines, tax, totals}; 200 the same for the same (source_kind, source_id)'
rule: "an extension; ar owns the invoice; idempotent on source_id"
status: "satisfied by ar v7 (AR-20 row for expenses, source_kind expense)"
- story: EXP-21
with: business-partner
owner: business-partner
route: "GET /partners/{bp_id}"
permission: bp:partner:read
request: "path bp_id"
rule: "the customer a line is re-billed to, and the employee's person partner (the payee)"
status: "exists in business-partner (BP-01)"
# ── v2 (2026-10-26, seemo's decided answer 2): hr's organisational assignment and roster by person id, in place of typed-in doors ──
- story: EXP-15
with: hr
owner: hr
route: "GET /persons/{person_id}/assignment?on="
permission: hr:assignment:read
rule: "a card holder is a person_id; the holder's email is read here when needed (was holder_email typed on put_card)"
status: "satisfied by hr v4 (HR-63)"
- story: EXP-09
with: hr
owner: hr
route: "GET /persons/{person_id}/assignment?on="
permission: hr:assignment:read
rule: "a delegate is a person_id; their email and sub come from here (was a delegate named by email)"
status: "satisfied by hr v4 (HR-63)"
- story: EXP-24
with: hr
owner: hr
route: "GET /roster?company_code=&on=&org_unit_code=&manager_person_id=&limit=&offset="
permission: hr:roster:read
rule: "the migration matches Odoo employees by work email (was an employee_map door)"
status: "satisfied by hr v4 (HR-64)"
tool_names: # shared.yaml conventions.tool_names (2026-10-25): the migration family's names, so this brick composes beside the others; the transitions keep their keys
- { tool: load_exp_from_odoo, route: "the exp_migration_run workflow's transition (route chosen at build)" }
- { tool: reconcile_exp_migration, route: "the exp_migration_run workflow's transition (route chosen at build)" }
- { tool: discard_exp_migration, route: "the exp_migration_run workflow's transition (route chosen at build)" }
- { tool: get_exp_migration_map, route: "GET /migration-map/{db}/{model}" } # the one map route, shared.yaml conventions.odoo_migration.map_route
screens:
- { id: my_expenses, name: My expenses, stories: [EXP-01, EXP-02, EXP-03, EXP-04, EXP-05, EXP-06, EXP-07, EXP-08, EXP-25], actors: [employee, manager, finance], note: "my claims, card transactions waiting for receipts, a camera button; suggested fields highlighted until confirmed; policy results inline; the chain and where it is" }
- { id: approvals, name: Approvals, stories: [EXP-09, EXP-10, EXP-11, EXP-26], actors: [manager, finance], note: "claims waiting for me (and delegated to me), receipts beside each line, policy warnings with reasons, duplicates, stale changes, tax's VAT split" }
- { id: cards, name: Cards, stories: [EXP-14, EXP-15], actors: [finance], note: "cards and holders, feeds imported, unmatched and personal transactions, recoveries" }
- { id: settings, name: Settings, stories: [EXP-22, EXP-23], actors: [finance, owner], note: "categories, rates, policy rules, reminders; the controls to the owner only" }
- { id: migration, name: Migration, stories: [EXP-24], actors: [owner, finance] }
permissions:
namespace: exp
catalog:
- exp:report:read_own
- exp:leaver:intake
- exp:report:write_own
- exp:report:submit
- exp:report:read_team
- exp:report:read_all
- exp:report:approve
- exp:report:finance_approve
- exp:report:post
- exp:report:reverse # v2 (EXP-26): finance reverses a posted claim
- exp:card_txn:match_own
- exp:card:write
- exp:card_feed:import
- exp:card_txn:resolve
- exp:delegation:write
- exp:policy:write
- exp:category:write
- exp:rate:write
- exp:ai:consent
- exp:settings:controls # thresholds, warn vs block, AI step modes, delegation rules; never held by whoever they control
- exp:migration:read
- exp:migration:sign
- exp:report:mark_paid # machine: held by payments' service client only
- exp:ai:read_receipt # machine
- exp:report:derive # machine
- exp:journal:post_auto # machine
- exp:payout:request # machine
- exp:card_feed:ingest # machine
- exp:number_series:claim # machine
- exp:migration:load # machine
- exp:migration:reconcile # machine
machine:
- exp:report:mark_paid
- exp:ai:read_receipt
- exp:report:derive
- exp:journal:post_auto
- exp:payout:request
- exp:card_feed:ingest
- exp:number_series:claim
- exp:migration:load
- exp:migration:reconcile
row_visibility:
rule: "an employee sees only their own claims, cards and receipts; a manager sees the claims of people they managed on the submission date and those delegated to them; finance and the owner see every claim of their companies"
enforced: "in every use case: the caller's person_id from hr (by sub), the chain fixed on the claim, the company list"
not_found_not_forbidden: true
config_rows:
- table: exp_settings
changes: "how claims are approved and chased; every row dated (valid_from after today; the first row of a key may start today)"
settings:
- { key: finance_approval_threshold, type: decimal, default: "500.00", changes: "claims above this go to finance after the manager; exp:settings:controls" }
- { key: receipt_required_above, type: decimal, default: "10.00", changes: "a receipt line above this needs an image; exp:settings:controls" }
- { key: card_receipt_days, type: int, default: 14, changes: "days before an unmatched card transaction is chased" }
- { key: approval_reminder_days, type: int, default: 5, changes: "business days before an approver is reminded" }
- { key: leaver_claim_days, type: int, default: 90, changes: "days after the last day a leaver may still submit" }
- { key: reimburse_with, type: str, default: "nett", changes: "nett (personal card spend netted from the payout) | separate" }
- { key: duplicate_threshold, type: decimal, default: "0.92", changes: "image similarity above which a receipt is flagged" }
- { key: delegation_allowed, type: bool, default: true, changes: "managers may delegate approval while absent; exp:settings:controls" }
- { table: exp_settings, changes: "each AI step's mode (ai_steps: off | suggest)" }
- { table: exp_category, changes: "the categories employees choose, their gl purpose and tax category" }
- { table: exp_rate, changes: "mileage and per diem rates per country, vehicle and destination, dated" }
- { table: exp_policy_rule, changes: "limits and bans, warn or block" }
- { table: exp_card, changes: "company cards and their holders" }
in_code_not_rows:
- "that nobody approves their own claim, under any login (email AND sub), including as a delegate"
- "that the chain is fixed at submission and a later revision starts it again"
- "that a submitted line never changes; a returned claim continues as a new revision"
- "that the AI reader runs only after the employee's consent and never confirms a field itself"
- "that a card transaction is matched once and a recovery is netted or repaid once"
- "that neighbours are called only from pending rows after the local commit"
- "that the journal of an approved revision is one balanced journal and is never rewritten (a correction is a reversal)"
- "that 'today' is the business date in business_timezone"
ai_steps:
- key: read_receipt
benefit: "a receipt becomes a line in seconds: date, merchant, total, currency, VAT and the VAT number read from the photo"
mode: suggest
modes_allowed: ["off", suggest]
safe_for_auto: false # money paid to a person and VAT reclaimed from the state; a person confirms every field
inputs: [the receipt image, the employee's categories]
output: "each field with a confidence; the category with a one-line reason"
fallback: "the fields stay empty and the employee types them"
consent: "v2 (pattern §4, seemo's decided answer 3): the owner's mode first (exp:settings:controls), then per employee, because the step reads that person's own receipt: recorded before the first image is sent, withdrawable at any time (EXP-02); GDPR arts. 6 and 13 (lawful basis, information); images never leave the platform's sovereign region"
human_review: "every suggested field is marked until the employee confirms or corrects it; the approver sees which fields the model filled and which the person changed"
audit: "exp_ai_finding: model, inputs digest, fields read, fields confirmed, who, when"
eval: "a labelled set of 500 receipts per country (UK, DE, FR, AT) incl. faded thermal paper and foreign currency; accuracy per field; total and VAT must reach 99% before the step is offered"
note: "a task in the signed process (Task_expense_claim_read_receipt), so the customer sees and signs it, and may switch it off"
- key: explain_policy_warning
benefit: "an approver sees why a line is unusual (the hotel is twice the city's average this month) beside the rule it broke"
mode: suggest
modes_allowed: ["off", suggest]
safe_for_auto: false
inputs: [the line, the policy rule, other approved lines of the same category and city]
output: "a short comparison; the approver decides"
fallback: "the rule's own message"
audit: "exp_ai_finding"
eval: "seeded outliers; precision of the comparison"
extension_points:
- { name: rebill, what: "re-bill approved lines to a customer at cost or with a markup through ar (EXP-21); needs source_kind expense in ar" }
- { name: payroll_reimbursement, what: "reimburse through payroll instead of payments (common in Germany for allowances), and recover personal spend by payroll deduction; hr's payroll extract carries it" }
- { name: cash_advance, what: "a travel advance paid before the trip and settled against the claim" }
- { name: projects, what: "a line charged to a project (projects module) instead of a cost centre" }
- { name: travel_booking, what: "pre-trip approval and booked travel imported as card-like transactions" }
tests:
acceptance: "one per story id above, named for it"
access:
- "an employee cannot read another person's claim (404), approve anything, or see another holder's card"
- "a manager sees only the claims of people they managed on the submission date, plus delegations; never approves their own"
- "finance cannot approve a claim they submitted or approved at level 1"
- "exp:settings:write cannot change a control; anyone but the owner holding exp:settings:controls is refused at configuration"
- "exp:report:mark_paid is refused to every person and to any client but payments'"
- "the machine permissions are refused to every person role and to a wildcard grant"
invariants:
- "claim keys: the journal of an approval (derived_id(<report>:r<rev>, approve)), a reversal (…, reverse), a personal recovery (derived_id(<card_txn_id>, personal)), the payout (exp:<report_id>:r<rev>:pay) and a card claim (card_txn_id, operation) are all distinct, so a payout and a recovery of the same claim never answer as each other's replay; netting and repayment of one recovery share one claim row and only one wins"
- "side effects (the journal, the payout, the ar re-bill, reminders) are sent only from pending outbox rows written in the same transaction as the state change; a crash after commit sends them on the next run; a rolled-back approval sends nothing"
- "state machines never go backwards: submitted never returns to draft (a return makes revision n+1), approved never returns to submitted, posted changes only by a reversal journal, paid is terminal; each is tested by attempting the reverse"
- "self-approval: no level of any revision is approved by the claimant, matched on email AND sub, including as a delegate and including finance's level"
- "the business date is in business_timezone: an approval at 2026-11-09 23:30 Europe/London posts on 2026-11-09, one at 00:30 BST on 2026-07-15 (23:30 UTC on the 14th) posts on 2026-07-15; the UTC date is never used; expense dates are the document's own"
- "money bounded: an amount, miles, km or rate that is negative where it cannot be, above NUMERIC(18,2) (rates NUMERIC(19,4)), or with more decimals than the currency allows answers 422 {gate: bounds}, never a 500"
- "results marked stale: a rate, policy, fx or earlier-mileage change marks every affected not-yet-approved line stale; submission and approval are refused on a stale line; approved lines never change"
- "no statement growth with unrelated rows: an employee's list, a card's unmatched list and a payout contain only their own person's, card's and claim's rows; inserting another person's claims or another card's transactions changes none of them (tested by seeding unrelated rows)"
- "the journal of a claim balances to the penny; reclaimable VAT is never more than the receipt's VAT; a card line's amount equals its card transaction's billed amount"
- "the mileage band uses only approved lines of the same tax year; two claims approved in either order end at the same total"
scenarios:
- id: SC-EXP-01
name: a UK sales trip from receipt photo to bank
stories: [EXP-01, EXP-03, EXP-06, EXP-07, EXP-08, EXP-09, EXP-11, EXP-12, EXP-13]
given: employee 3301 with a train, a hotel on card 4417, a client dinner and 300 miles
when: the claim is filled with the reader, submitted, approved by 2280 and finance, posted and paid
then: journal of 551.40 each side; payout 269.40 after 18.00 personal spend; paid on the statement date
- id: SC-EXP-02
name: a manager's own claim and a second login
stories: [EXP-09, EXP-11]
given: manager 2280 submits their own claim of 620.00
when: 2280 tries to approve it under a second email with the same sub; finance user F who approved level 1 tries level 2
then: both refused; the chain is 1904 then another finance user
- id: SC-EXP-03
name: two mileage claims approved out of order
stories: [EXP-03, EXP-20]
given: 9,850 approved miles; claims of 200 and 300 miles submitted
when: the 200 is approved first, then the 300
then: the 300-mile line was marked stale and recomputed to 75.00 (the 200 miles were 80.00); the total over both is 155.00 whichever is approved first
- id: SC-EXP-04
name: a German trip with meals provided
stories: [EXP-04, EXP-12]
given: employee 5120 Berlin to Munich 10-12 November with two breakfasts
when: claimed, approved and posted
then: 44.80 EUR per diem, no VAT, posted to purpose per_diem
- id: SC-EXP-05
name: consent withdrawn
stories: [EXP-02, EXP-01]
given: employee 3301 consented, then withdrew on 2026-12-01
when: a receipt is added on 2026-12-02
then: no call to the model; the fields are empty; the earlier findings remain
scale:
reads: "the tables reader serves 16 concurrent reads; Approvals reads one claim's lines in one query; the daily pass reads the unmatched and waiting lists with one query each"
hot_paths: ["submit (gates and chain)", "approve (the journal and the payout row)", "the card feed import"]
workers: "the daily reminder pass in the composition's worker; nothing pinned warm is required"
volumes: "a company of 500 people claims about 1,500 lines a month; a card feed of 5,000 transactions imports in one request"
platform_constraints:
- constraint: "No read-your-writes inside an open transaction."
today: "an approval commits the level, then a second transaction completes the chain and posts"
- constraint: "A plain INSERT of a committed key is rejected (23505)."
today: "report numbers, approval steps and card claims are plain INSERTs; the loser re-reads"
customer_varies:
- question: How do you pay people back, with the supplier payment run, with payroll, or by hand?
changes: the payout seam (payments) or the payroll_reimbursement extension
typical:
uk_company: payments run weekly, a bank transfer to the employee
german_company: allowances often through payroll, receipts by transfer
warning: "a reimbursement above the tax-free rate is taxable pay and belongs in payroll"
- question: Which mileage and daily allowance rates do you pay, and do you pay more than the tax-free amount?
changes: exp_rate rows
standard: "UK ITEPA 2003 ss. 229-236 (AMAP 45p / 25p); HMRC EIM05231 (benchmark scale rates); DE s. 9 (4a) EStG (28.00 / 14.00 EUR) and s. 9 (1) no. 4a EStG (0.30 EUR/km)"
- question: Above what amount should finance look at a claim after the manager?
changes: finance_approval_threshold
typical:
small_company: every claim to the owner
mid_market: manager, then finance above 500.00
- question: What does your expense policy say about hotels, meals with clients and alcohol, and is a breach a warning or a stop?
changes: exp_policy_rule
- question: May the AI read receipts, and should employees be asked one by one?
changes: ai_steps.read_receipt mode; consent is always per employee
standard: "GDPR arts. 6 and 13"
- question: Do you re-bill travel costs to customers, and at cost or with a markup?
changes: the rebill extension
- question: How long must receipts be kept?
changes: retain_until on receipt images
standard: "UK: 6 years (HMRC record keeping, VAT Notice 700/21); DE: s. 147 AO"
variation_points:
- node: Task_expense_claim_read_receipt
op: on_off
default: "suggest, after consent"
depth: 1
guideline: "warn - off is safe; auto is not offered because money is paid to a person on what the model read"
- node: Task_expense_claim_approve_level
op: narrow
default: "manager, then finance above 500.00"
depth: 1
requirement: "refuse - a chain in which the claimant approves their own claim"
- node: Gateway_expense_claim_policy
op: row_value
default: "warn for limits, block for bans"
depth: 1
guideline: "warn - turning blocks into warnings moves the policy to the approver's attention span"
migration:
odoo:
runs_after: [business-partner, hr, gl, tax]
reads:
- model: hr.expense.sheet
fields: [id, name, employee_id, company_id, state (draft | submit | approve | post | done | cancel; to verify per version at the door), payment_state, user_id (the manager), total_amount, currency_id, accounting_date, account_move_ids, approval_date]
filter: "states approve, post and done within the door's years"
why: "claims as history, and the approved or posted ones still to be paid"
- model: hr.expense
fields: [id, name, sheet_id, employee_id, product_id (the category), date, total_amount_currency, currency_id, total_amount, tax_ids, payment_mode (own_account | company_account), analytic_distribution, description, sale_order_id, state]
filter: "lines of the sheets above"
why: "lines; payment_mode company_account maps to paid_with card"
- model: product.product (can_be_expensed)
fields: [id, default_code, name, property_account_expense_id, supplier_taxes_id, expense_policy (no | cost | sales_price)]
filter: "can_be_expensed true"
why: "the categories; expense_policy cost or sales_price marks rebill"
- model: ir.attachment
fields: [id, res_model, res_id, name, mimetype, datas]
filter: "res_model hr.expense of the lines above"
why: "receipt images carried as DataK3 objects"
maps:
- { from: "hr.expense.sheet", to: "expense_report", rule: 'report_id = "odoo:<db>:hr.expense.sheet:<id>"; the person matched through hr''s roster (HR-64) by hr.employee.work_email, then HR-63 by person_id; an unmatched employee is listed for HR at the door, never guessed, never a typed map (v2, 2026-10-26); status: approve -> approved, post -> posted, done -> paid' }
- { from: "hr.expense", to: "expense_line", rule: "category through the product map at the door; amounts to the penny; Odoo's taxes through tax's map" }
- { from: "product.product (can_be_expensed)", to: "expense_category", rule: "category_code = default_code; gl_purpose named at the door" }
- { from: "ir.attachment", to: "receipt_image", rule: "object key raw/expenses/<report_id>/<line_no>/<sha256>; retain_until from the expense date" }
door:
- "which Odoo expense products become which categories and gl purposes"
- "how many years of paid claims to carry (recommended: the current and the prior financial year)"
- "submitted and draft sheets: finished in Odoo before cut-over (recommended), or re-entered"
reconcile:
- { check: "unpaid reimbursable per employee", odoo: "SUM(total_amount) of sheets approve or post with payment_state not paid, own_account lines", ours: "reimbursable_amount of posted claims per person", must: equal }
- { check: "claims and totals per month", odoo: "search_count and SUM(total_amount) of hr.expense.sheet by accounting_date month", ours: "expense_report (migration) by month", must: equal }
- { check: "receipt images carried", odoo: "search_count of ir.attachment on the lines", ours: "receipt_image rows", must: equal }
not_carried:
- "draft and submitted sheets (finished in Odoo first)"
- "Odoo's approval history beyond the approver and date (no levels in Odoo)"
- "Odoo's journal entries: gl's migration carries them"
idempotent_on: 'report_id = "odoo:<db>:hr.expense.sheet:<id>"; lines on [report_id, revision 1, line_no]; images on sha256; exp_migration_map on [odoo_db, odoo_model, odoo_id]'
needs: [] # wave 3: every route this spec needed in an existing module is now in that module (seam statuses say which version), except hr's below
needs_from_hr_v2: # added in hr v2 (spec/hr-v2, HR-56..59); kept as the record of what this module asked hr for. What hr v2 must add for expenses (and payments' payout):
- route: "GET /persons/{person_id}/employment?on="
permission: hr:employment:read # new, machine: held by pos's and expenses' service clients
request: "path person_id; query on (YYYY-MM-DD, default today in business_timezone)"
response: "200 {person_id, on, employed (bool: an engagement active on `on`), engagement_id, worker_type, company_code (the legal entity), org_unit_id, location_code, cost_centre, bp_id (the person partner)}; employed false (and nulls) when no engagement covers the date, never a 404 for a person the caller may ask about"
from: "hr's engagement and assignment slices in force on the date (hr v1 entities engagement, assignment); no pay, no sensitive data"
callers: [pos POS-32, expenses EXP-19]
- route: "GET /persons/{person_id}/manager?on=&level="
permission: hr:manager:read # new, machine: held by expenses' service client
request: "path person_id; query on (YYYY-MM-DD), level (1 = the derived manager, 2 = the next one up; default 1)"
response: "200 {person_id, on, level, manager: {person_id, bp_id, pool_user_sub, email} | null, source: override | org_tree}; null when nobody holds the chief position up the tree"
from: "hr v1's domain service ManagerOf (the chief of the assignment's org unit, walking up; manager_override_person_id wins), derived on read, never stored"
callers: [expenses EXP-19]
- route: "GET /persons?pool_user_sub="
permission: hr:employment:read
request: "query pool_user_sub (the caller's own sub, from its token)"
response: "200 {person_id, bp_id, company_code} | 404 when no person carries that sub"
from: "hr_person.pool_user_sub (hr v1)"
callers: [expenses EXP-19]
- route: "GET /persons/{person_id}/bank-account?on="
permission: hr:bank_account:read_for_payout # new, machine: held ONLY by payments' service client; never a person role, never expenses
request: "path person_id; query on (YYYY-MM-DD, the payment date)"
response: "200 {person_id, account_holder, sort_code | null, account_number | null, iban | null, bic | null, valid_from}; 404 when none is held on that date"
from: "hr's sensitive data (bank), its own aggregate; every read audited (who, when, which person, which payout)"
callers: [payments PAY-51, for expenses EXP-13]
- event: "hr.person.left: hr v2 (HR-54) calls expenses' POST /employees/{person_id}/leaver (offered above, permission exp:leaver:intake held by hr's service client alone)"
reference:
# `name` is the brick's DISTRIBUTION name (pyproject [project].name): erp-compose init pins
# `<name> @ git+<https>@<tag>`, and pip refuses a pin whose name differs from the package's.
repos:
- name: erp-brick-expenses
role: brick
https: https://git.dodil.io/dodil/erp-brick-expenses.git
ssh: git@git.dodil.io:dodil/erp-brick-expenses.git
tag: v0.2.0
read_first: [README.md, AGENTS.md]
- name: brick-business-partner
role: brick
https: https://git.dodil.io/dodil/erp-brick-business-partner.git
ssh: git@git.dodil.io:dodil/erp-brick-business-partner.git
tag: v0.4.0
read_first: [README.md, AGENTS.md]
- name: erp-platform
role: platform
https: https://git.dodil.io/dodil/erp-platform.git
ssh: git@git.dodil.io:dodil/erp-platform.git
tag: v0.9.13
read_first: [README.md, AGENTS.md]
packages: []
contracts: []
build_guide: null
known_gaps:
- "nothing is built; this is version 1 of the spec, unsigned"
- "the needs above are change requests in payments, hr, tax, gl and ar"
- "card feed formats are per provider; the import takes one documented CSV shape until a provider capability exists"
- "APQC ids are null until verified against PCF v8.0"
# SAP parity: SAP Travel Management (FI-TV) / SAP Concur at mid-market scope. covered | different | deferred | not_planned.
sap_parity:
- { sap: "Expense reports with receipts (Concur Expense, FI-TV travel expenses)", ours: covered, where: "EXP-01, EXP-08, expense_report" }
- { sap: "Receipt OCR (Concur ExpenseIt)", ours: covered, where: "ai_steps.read_receipt", why: "suggest only, after consent, on sovereign models" }
- { sap: "Per diems by country (trip provision variants, statutory rates)", ours: covered, where: "EXP-04, exp_rate" }
- { sap: "Mileage with statutory bands", ours: covered, where: "EXP-03, exp_rate" }
- { sap: "Corporate card feeds and matching", ours: covered, where: "EXP-06, EXP-15, card_txn" }
- { sap: "Policy audit rules (warn / block)", ours: covered, where: "EXP-07, exp_policy_rule" }
- { sap: "Multi-level approval workflow", ours: covered, where: "EXP-09, EXP-11, approval_step" }
- { sap: "Posting to FI with input tax", ours: covered, where: "EXP-12, tax EXP-18" }
- { sap: "Payment via FI payment program / payroll", ours: different, where: "EXP-13 (payments), extension payroll_reimbursement", why: "payments pays; payroll is an extension" }
- { sap: "Travel requests and planning (pre-trip approval, booking)", ours: deferred, milestone: "M6", where: "extension_points.travel_booking", why: "claims first; pre-trip approval is a second process" }
- { sap: "Cash advances", ours: deferred, milestone: "M6", where: "extension_points.cash_advance", why: "rare in the target companies" }
- { sap: "Re-billing to customers (cost assignment to sales orders / projects)", ours: covered, where: "EXP-21 (extension through ar)" }