Skip to content

Foreign-currency spends: how the claimant is reimbursed

Status: built on feat/claim-foreign-currency-reimbursement, not yet merged. Covers expense claim lines (migration 01141) and advance acquittals (01142).

A claim line spent in a foreign currency was always converted at the expense date’s rate (Ruling R5). A claimant who paid by card knows the exact base-currency figure their bank charged and expects to be reimbursed that, not a figure computed from a reference rate. The older claims app asks “How should we reimburse this?” as soon as the currency is not the base currency, with two answers:

Basis What the claimant does What the organization reimburses
bank_charged — “What my bank charged me” Enters the base-currency figure from the statement Exactly that figure
expense_date_rate — “Convert at the expense-date rate” (for cash) Nothing The receipt amount converted at the rate in force on the expense date
  • Per line, not per claim. A claim’s lines can be in different currencies and paid in different ways. The basis is a column on expense_claim_line.
  • Default is expense_date_rate, so every existing line, every API client that sends no basis and every base-currency line behaves as before. The wizard also defaults to it; a card payment is one click on the first card.
  • The base amount is the statement figure itself. The rate is derived from it (money.RateOf) and stored with fx_rate_source = 'bank_statement', so a reader sees the rate the bank applied, but base_amount is never amount x rate (which can differ by a rounding cent).
  • No exchange rate has to exist for a bank-charged line. That is a feature: a currency with no published rate can still be claimed.
  • Base-currency lines have no choice. bank_charged on a base-currency line is refused with reimbursement_basis / same_currency; the wizard hides the choice and resets it.
  • Allowances (mileage, per diem) are always base currency and refuse both new fields.

3. Scalability: one shared decision, many consumers

Section titled “3. Scalability: one shared decision, many consumers”

The rule is not a claim rule. internal/platform/reimbursement owns it: a registry from basis key to pricer, Check (shape rules that need no database), and Price(ctx, tx, Request) -> Priced (rate, source, base amount). A consumer builds a Request from its own record and maps a *Refusal onto its own field-error type. Consumers keep their own freeze rule; the package only prices.

Consumer Date priced on Field-error type Freeze
Expense claim line (platform/expenses/reimbursement.go) expense date LineError Ruling R5, kept by the line
Advance acquittal (platform/advances/acquittal.go) receipt date AcquittalError (currency renamed actual_currency) re-recorded until decided

A new basis (for example “company card, rate on the statement date”) is:

  1. one pricer and one registry entry in platform/reimbursement;
  2. a value in each consumer’s reimbursement_basis CHECK (and its rate-source CHECK if the basis has its own source) in a new migration;
  3. a value in ExpenseReimbursementBasis in api/openapi.yaml, then make generate;
  4. an entry in web/src/lib/reimbursement.ts and a reimbursement.<key> block in the three common locale files.

A new consumer (a petty-cash spend, a supplier-card expense) adds a column and calls Check and Price; no basis code is copied. On the web, components/money/ReimbursementChoice is the one UI for the choice, shown by the caller when the currency is not the base currency.

  • Migration 01142_acquittal_reimbursement_basis.sql does the same for advance_acquittal (actual_fx_rate_source gains bank_statement). A zero acquittal refuses both fields (reimbursement_basis / none_exists). The acquittal’s PUT body and view carry reimbursement_basis and bank_charged_amount as the claim line’s do.
  • Migration 01141_claim_line_reimbursement_basis.sql: reimbursement_basis (NOT NULL, default expense_date_rate, CHECK on the two keys); fx_rate_source CHECK gains bank_statement; expense_claim_line_basis_source ties the two (bank_charged iff bank_statement). The draft guard already compares whole rows, so a saved line stays frozen after submission.
  • ExpenseClaimLineInput: reimbursement_basis (optional enum), bank_charged_amount (Money, nullable; only with bank_charged). ExpenseClaimLine: reimbursement_basis (required enum). The statement figure is read back as base_amount.
  • Refusals use the existing field=... rule=... 422 format: bank_charged_amount required, positive, not_for_basis; reimbursement_basis invalid, same_currency, not_for_type.
  • Rule R5 still holds: an edit that changes nothing the conversion depends on keeps the frozen figures. A bank-charged line is recomputed from its input on every save (deterministic); an expense-date-rate line re-resolves only when the currency, amount, date or basis moved.
  • StepAmount shows “How should we reimburse this?” (RadioCards) when the currency is not the organization’s base currency; choosing the bank card reveals “Amount your bank charged (MYR)”. Going back to the base currency resets both.
  • The claim detail marks a bank-charged line “Bank-charged amount” beside its base amount. The acquittal form shows the same choice when the currency typed is not the base currency, and the recorded acquittal says “Converted at: Bank-charged amount”.
  • en, zh and ms strings added (common, claimDetail, advances); zh and ms want a native review.
  • A petty-cash spend and the advance request itself do not get the choice: a petty-cash float is a base-currency cash account, and an advance is paid before any bank charge exists. A consumer that needs it follows section 3.
  • Whether an organization should be able to require one basis (for example bank-charged for card payers) is a policy setting not built here.
  • Claim exports (ClaimExportEditor) do not yet print the basis.
  • No fraud check compares the statement figure with the reference rate; an approver sees both base_amount and fx_rate on the line.