Exchange Rate — multi-currency conversion table
The whole exchange module reduces to one idea: a tiny lookup table of
(fromCurrency, toCurrency) → raterows. It stores nothing more. Everything that uses a rate (orders, finance legs) copies a singleexchangeRatenumber onto its own document at entry time and never re-reads this table. So the table is a convenience lookup for the data-entry UI, not a live FX engine.
Source: BE src/modules/exchange · Admin src/modules/exchange · consumed in src/modules/inventory/order, src/modules/finance/transaction
1. Purpose & scope
The Exchange module owns a single collection, exchange_rates, holding manually-entered conversion rates between two currencies. It exposes CRUD plus one lookup query (exchangeRate) that the admin order screen calls to pre-fill an order's exchangeRate.
It does NOT:
- Fetch live/market rates from any external provider — every rate is hand-keyed by an operator (
src/modules/exchange/exchange.dto.tsCreateExchangeInput). - Store rate history or effective-date ranges — there is a
datefield but no version chain;exchangeRate(from, to)returns the single matching row (exchange.repository.tsfindOne). - Apply the rate to any amount itself. Conversion math lives in the consuming modules (order, transaction). See §4.
- Define currencies. Currencies are
Masterrecords under thecurrencymaster key; this module only references them by id. Seeconfig.mdfor the config/lookup system andmaster-reference-data.mdfor the Master hierarchy.
2. Data model
Collection: exchange_rates (exchange.schema.ts)
Represents one directional conversion rate from currency fromId to currency toId.
| field | type | required? | description |
|---|---|---|---|
fromId |
ObjectId |
yes | Ref → masters (a currency Master). Set via BaseSchema.toObjectId. |
toId |
ObjectId |
yes | Ref → masters (a currency Master). |
rate |
number |
yes | Multiplier: 1 unit of from = rate units of to. Admin validates > 0. |
date |
number |
no | Unix ts the rate applies (informational only; not used for selection). |
from |
string / Master |
— | Non-persisted. Resolved from fromId via $lookupFrom / from resolve-field. |
to |
string / Master |
— | Non-persisted. Resolved from toId via $lookupTo / to resolve-field. |
Plus the standard BaseSchema envelope (_id, ref, companyId, branchId, createdAt, updatedAt, createdBy, updatedBy, soft-delete deletedAt/deletedBy).
// exchange.schema.ts
@ApSchema({ collection: "exchange_rates" })
export class Exchange extends BaseSchema {
@Prop({ set: BaseSchema.toObjectId })
fromId: Types.ObjectId;
@Prop({ set: BaseSchema.toObjectId })
toId: Types.ObjectId;
@Prop()
rate: number;
@Prop()
date?: number;
from?: string; // non-persisted, populated by lookup/resolver
to?: string; // non-persisted
}
ExchangeSchema.plugin(SoftDelete, { deletedAt: true, deletedBy: true });Enums: none. There is no status/type enum on this collection.
Relationships & scoping:
fromId/toId→masterscollection (currencies). The repository joins them via$combineLookup($lookupFrom+$lookupTo, both$lookupagainstmasters).- Soft-deleted via
mongoose-delete; deleted rows are excluded from all reads. - Tenant/branch scoped through
BaseSchema+ the tenant DB connection (see../../platform/multi-tenancy.md). Each tenant maintains its own rate table.
Derived fields: from / to are not stored — resolved per-request. The repository's findOne aggregates the lookups first, then matches; the resolver's from/to resolve-fields fall back to masterSvc.findById(fromId|toId) when the parent didn't already carry the joined object.
3. API surface
All operations are GraphQL, guarded by @ApGqlAuthorize() at the resolver class level (exchange.resolver.ts). Mutations are wrapped with @AuditMeta({ module: 'exchange', collection: 'exchange_rates', ... }).
| Operation | Type | Input | Returns | Auth |
|---|---|---|---|---|
createExchange |
Mutation | CreateExchangeInput { fromId, toId, rate, date? } |
Exchange |
@ApGqlAuthorize + audit CREATE |
updateExchange |
Mutation | _id: String, UpdateExchangeInput (all optional) |
Exchange |
audit UPDATE |
deleteExchange |
Mutation | _id: String |
Boolean |
audit DELETE (soft-delete) |
findOneExchange |
Query | ExchangeQueryInput { keyword } |
Exchange |
@ApGqlAuthorize |
exchangePage |
Query | ExchangePageInput { keyword, skip, take } |
ExchangePageResult { totalRecords, data[] } |
@ApGqlAuthorize |
exchangeRate |
Query | ExchangeRateInput { from, to } |
Exchange (nullable) |
@ApGqlAuthorize |
GraphQL types (src/schema.gql):
type Exchange {
_id: String ref: String companyId: String branchId: String
fromId: String! toId: String! rate: Float! date: Float
from: Master to: Master
}
input CreateExchangeInput { fromId: String! toId: String! rate: Float! date: Float }
input UpdateExchangeInput { fromId: String toId: String rate: Float date: Float }
input ExchangeRateInput { from: String! to: String! } # NOTE: by currency NAME, not id
exchangeRate(rate: ExchangeRateInput!): ExchangeThe
exchangeRatelookup keys on currency name, not id.ExchangeService.rate({from, to})→exchangeRepo.findOne({ from, to }), andbuildQuerytranslates those into{ 'from.name': from, 'to.name': to }after the$lookupjoin (exchange.repository.ts). The admin therefore passescustomer.currency.nameand the company's local currency name, not ObjectIds.
4. Business rules & calculations
4.1 Rate entry & validation
- A rate is a plain multiplier (
1 fromUnit = rate toUnit). No inverse is auto-created — if you needUSD→NGNandNGN→USD, you enter two rows. - Admin-side validation (
exchange/components/create.tsx, Yup):fromIdandtoIdrequired currency objects,raterequired, numeric, positive (> 0). BE has no class-validator decorators on the inputs beyond GraphQL non-null — the positivity guard is UI-only. - No uniqueness constraint on
(fromId, toId)— duplicates are possible;findOnereturns the first match after the lookup.
4.2 How a rate reaches an order — the lookup
The lookup is a read-time convenience used only when creating/editing an order in the admin (inventory/order/new.tsx):
// new.tsx — getExchangeRate(customer)
const getExchangeRate = async (customer): Promise<number> => {
if (!customer?.currency?.name || customer.currency.name === localCurrency) return 1;
const result = await getRate({ from: customer.currency.name, to: localCurrency });
return result?.rate > 0 ? result.rate : 1; // ← fallback to 1 when no row / non-positive
};If no rate row matches, the order silently uses
1.getRateresolves to{ rate: 0 }on a miss (exchange/context.tsxgetRate), and every consumer coerces a non-positive rate to1. This is the client-side mirror of thesafeRaterule below.
The resolved number is written onto the Order.exchangeRate field (default 1) and then copied down onto every order item and every finance leg the order produces. The exchange table is never read again for that order — the order owns its rate snapshot.
4.3 Applying the rate — conversion math lives in consumers, not here
The stored exchangeRate converts a line amount from the document/foreign currency into the company base currency:
- Order item entry (admin): when editing an existing line, the foreign-currency rate/amount is recovered by dividing by the order rate (
add-or-update-item.tsx):and on submit the entered value is multiplied back byrate: (item.rate || 0) / (order?.exchangeRate || 1), amount: (item.amount || 0) / (order?.exchangeRate || 1),er = +values.exchangeRate > 0 ? … : 1(new.tsx). - Finance legs: the order copies
exchangeRateonto everyAccountTransactionit writes — bill, tax, COGS, inventory, payment legs all carryorder.exchangeRate(inventory/order/order.transaction.ts; sales/purchaseitem.service.tsrepairorder.exchangeRateonto their COGS/inventory/revenue legs when the order rate ≠ 1). - Payment legs: never persist a falsy rate —
payment.service.tscoercesmodel.exchangeRate = model.exchangeRate || 1on bothcreate()andaddSalesPayment()(regression-tested inpayment.service.spec.ts).
4.4 The safeRate rule — the critical invariant
When finance aggregates base-currency totals (trial balance, ledger, P&L), it multiplies each leg's amount by its exchangeRate. A leg accidentally stored with exchangeRate: 0 would multiply to zero and silently drop that leg, throwing the trial balance out of balance. The transaction repository defends against this:
// finance/transaction/transaction.repository.ts → totalDrAnCr()
// A 0 (or null) exchangeRate must NEVER zero out a posted amount — it is an
// invalid rate, not a real "multiply by zero". Treat 0/null as 1 here.
// ($ifNull alone does not catch 0.)
const safeRate = {
$cond: [{ $or: [{ $eq: ["$exchangeRate", 0] }, { $eq: ["$exchangeRate", null] }] }, 1, "$exchangeRate"]
};
const $sum = (type) => ({ $sum: { $cond: {
if: { $eq: ["$type", type] },
then: witExchangeRate ? { $multiply: ["$amount", safeRate] } : "$amount",
else: 0,
}}});The same (exchangeRate || 1) coercion appears wherever finance reads rates: journal.service.ts (amount / (trans.exchangeRate || 1)), journal.controller.ts, report.service.ts, report.controller.ts. Rule of thumb for any port: never trust a stored exchangeRate to be truthy; coerce 0/null → 1 at read time. Full detail of how legs use the rate is in ../finance/transaction.md.
State machine: none — exchange_rates rows have no status lifecycle.
Side effects of CRUD: create/update/delete only write the audit trail (@AuditMeta). No GL legs, no stock. Deleting a rate does not retro-change any order — orders carry their own snapshot.
Transactionality: the service extends AbstractBaseService with a no-op setSession; CRUD here is single-document and not part of a larger Mongo transaction.
5. Permissions
- Resolver-level
@ApGqlAuthorize()gates every operation (JWT + permission check — see../../platform/permissions-access.md). - Admin UI gates the "New Rate" button on
USER_ACCESS.EXCHANGES.MODULE/.ACTIONS.CREATE(exchange/page.tsx). - Mutations stamp the audit trail via
@AuditMeta({ module: 'exchange', collection: 'exchange_rates' }). No CASL abilities specific to this module.
6. Flows
6.1 Create / edit a rate (admin)
- Admin opens Exchanges page (
/exchanges) →fetchExchangePage(filter)→exchangePagequery. - Clicks New Rate →
CreateExchangemodal (Formik + Yup). - Selects From Currency and To Currency via
ApMasterSelectInput masterKey="currency"(currencies come from the Master tree), enters a positiverate. - Submit → payload flattens
fromId/toIdto their_idandparseFloat(rate)→saveExchange(_id, payload).- No
_id→createExchangemutation →ExchangeService.create→ repo insert → audit CREATE. - Existing
_id→updateExchange→ repo update → audit UPDATE, thenfetchExchangePagereloads.
- No
- Unhappy path: missing
fromId._id/toId._id→ submit aborts client-side; non-positiverate→ Yup blocks submit; GraphQL errors →toastSvc.graphQlError.
6.2 Consume a rate during order entry (the real use)
- Admin creates an order and selects a customer/supplier (
inventory/order/new.tsx). - If the counterparty's currency name ≠ company
localCurrency, the screen callsgetRate({ from: customer.currency.name, to: localCurrency })→exchangeRatequery →exchangeRepo.findOne({ 'from.name', 'to.name' }). - Returned
rate(or1on miss / non-positive) is set asOrder.exchangeRateand used to reprice lines (repricedItems,getOrderLineRate). - On checkout the order copies that one
exchangeRateonto every item and every finance leg (../finance/transaction.md). The exchange table is not consulted again.
6.3 Read base-currency totals (finance)
- Trial balance / ledger aggregations multiply
amount × safeRate(exchangeRate)(§4.4). A0/nullrate is treated as1, so a leg is never silently zeroed.
7. Admin UI
- Route:
src/pages/exchanges/(index renders the module page). Module screen:exchange/page.tsx. - Components:
components/exchangeTable.tsx— the rate list (exchanges,actions).components/create.tsx— create/update modal;FormSchema(Yup) requiresfromId/toIdcurrency objects and a positiverate; usesApMasterSelectInput masterKey="currency"andApTextInput type="number".detail.tsx— single-rate view.
- Context (
context.tsx) — the onlygqlconsumer. Methods:fetchExchangePage,saveExchange(routes tocreateExchange/updateExchange),deleteExchange,findOneExchange, andgetRate(exposed to other modules, notably order entry). State:exchanges,exchange,filter,totalRecords,modal,loading. - GraphQL (
gql/query.ts):EXCHANGE_PAGE,FIND_ONE_EXCHANGE,GET_RATE,CREATE_EXCHANGE,UPDATE_EXCHANGE,DELETE_EXCHANGE;pageandrateusefetchPolicy: 'no-cache'. Also exports SSR helpersfindOneExchangeAsync/findExchangePageAsync(the latter forcesskip:0, take:1000). - UX: inline currency create flows through the master select; no import/print here.
8. Dependencies & integrations
- MasterModule —
from/toresolve-fields callmasterSvc.findById; currencies are Master records (mastermodule,currencykey). - AuthModule —
@ApGqlAuthorize+ audit. - Consumed by
inventory/order—getRatepre-fillsOrder.exchangeRate(admin), which then flows into stock-less order math and finance legs. - Consumed by
finance/transaction,finance/journal,finance/report— read the copiedexchangeRate(never this table) and apply thesafeRatecoercion. See../finance/transaction.md. - No cron, no external FX provider, no events emitted/consumed.
9. Gotchas & project-specific rules
- Lookup is by currency name, not id.
exchangeRate(from, to)matchesfrom.name/to.namepost-join. Renaming a currency Master breaks existing lookups even though the rate row is intact. - No effective-dating / history.
dateis stored but ignored by selection. One row per direction is assumed; duplicates resolve to the first match. - Rates are a snapshot, not a live link. Orders and finance legs copy
exchangeRateat entry time. Editing the rate table never retro-updates posted documents — by design. 0/nullrate is always coerced to1(both clientgetExchangeRateand serversafeRate/payment.service/ journal / reports).$ifNullis deliberately not used because it does not catch a literal0.- No inverse rate auto-creation — both directions must be entered if both are needed.
ExchangeRepository.findOnelogs to console (console.log("ExchangeRepository.findOne", …)) — noise/TODO in current code.- Tenant-scoped — each tenant DB has its own
exchange_rates; there is no platform-wide/master-DB rate table.