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).
1. Problem
Section titled “1. Problem”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 |
2. Decisions
Section titled “2. Decisions”- 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 withfx_rate_source = 'bank_statement', so a reader sees the rate the bank applied, butbase_amountis neveramount 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_chargedon a base-currency line is refused withreimbursement_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:
- one pricer and one registry entry in
platform/reimbursement; - a value in each consumer’s
reimbursement_basisCHECK (and its rate-source CHECK if the basis has its own source) in a new migration; - a value in
ExpenseReimbursementBasisinapi/openapi.yaml, thenmake generate; - an entry in
web/src/lib/reimbursement.tsand areimbursement.<key>block in the threecommonlocale 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.
4. Data and API
Section titled “4. Data and API”- Migration
01142_acquittal_reimbursement_basis.sqldoes the same foradvance_acquittal(actual_fx_rate_sourcegainsbank_statement). A zero acquittal refuses both fields (reimbursement_basis/none_exists). The acquittal’s PUT body and view carryreimbursement_basisandbank_charged_amountas the claim line’s do. - Migration
01141_claim_line_reimbursement_basis.sql:reimbursement_basis(NOT NULL, defaultexpense_date_rate, CHECK on the two keys);fx_rate_sourceCHECK gainsbank_statement;expense_claim_line_basis_sourceties the two (bank_chargediffbank_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 withbank_charged).ExpenseClaimLine:reimbursement_basis(required enum). The statement figure is read back asbase_amount.- Refusals use the existing
field=... rule=...422 format:bank_charged_amountrequired,positive,not_for_basis;reimbursement_basisinvalid,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.
5. Web
Section titled “5. Web”StepAmountshows “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.
6. Out of scope / open
Section titled “6. Out of scope / open”- 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_amountandfx_rateon the line.