A credit decision support microservice for embedded FMCG supply-chain finance in Saudi Arabia. It evaluates a merchant purchase transaction and returns APPROVED, PARTIALLY_APPROVED or DECLINED, with an approved amount, a price, repayment terms, and a reason the sales rep standing in the shop can actually deliver.
Built for Part 2 of the TradePay CTO case study. See ARCHITECTURE.md for the design rationale, how the decision logic works, how the transaction history was used, and what I would build next.
docker compose up --buildThe service listens on http://localhost:3000. Change the port with PORT=8080 docker compose up --build.
curl -s http://localhost:3000/ready
# {"ready":true,"merchants_loaded":40,"policy_version":"policy-v1.3.0",
# "audit_sink":{"durable":true,"healthy":true}}Decisions are written to an append-only JSONL log on the audit-log volume, which survives restarts:
docker compose exec decision-engine wc -l /app/audit/decisions.jsonl
docker compose restart && curl -s http://localhost:3000/readyStop and clean up:
docker compose down # stop
docker compose down -v # stop and delete the audit log volumeVerified on Docker 29.7.2 / Compose v5.3.1: image builds clean at 57 MB, container reports healthy, runs unprivileged as node (uid 1000) under tini, and ships no dev dependencies or tests.
docker build -t tradepay/decision-engine .
docker run --rm -p 3000:3000 tradepay/decision-engineRequires Node.js 20+ (developed on 22).
npm install
npm start # http://localhost:3000
npm test # 160 tests
npm run test:coverage
npm run bench # latency profileThe example payload from the brief, verbatim:
curl -s -X POST http://localhost:3000/decision \
-H 'Content-Type: application/json' \
-d '{
"merchant_id": "",
"merchant_business_description": "A small grocery store in a residential area of Riyadh, operating for 5 years.",
"risk_tier": "B",
"credit_limit": 50000,
"current_exposure": 30000,
"transaction_amount": 12000,
"monthly_purchase_volume": 65000,
"inventory_level": { "sku_A": 100, "sku_B": 50, "sku_C": 200 }
}'{
"decision": "PARTIALLY_APPROVED",
"approved_amount": 8000,
"interest_rate": 1.5,
"repayment_terms": "30 days",
"reason": "The transaction amount exceeds the remaining credit limit. The approved amount is based on the available credit."
}The response carries considerably more than the five contract fields — reason codes in Arabic and English, the risk assessment, the pricing breakdown, the reservation, version stamps and a per-stage latency breakdown. ARCHITECTURE.md § Worked example walks through exactly how that 8000 and that 1.5 were derived.
MER-SA-0041074 has 24 months of on-time repayment across three suppliers.
curl -s -X POST http://localhost:3000/decision \
-H 'Content-Type: application/json' \
-d '{
"merchant_id": "MER-SA-0041074",
"merchant_business_description": "Mini market in Al Naseem, Riyadh.",
"risk_tier": "A",
"credit_limit": 68000,
"current_exposure": 19010,
"transaction_amount": 12500,
"monthly_purchase_volume": 82502,
"inventory_level": { "sku_A": 60, "sku_B": 40 }
}'{
"decision": "APPROVED",
"approved_amount": 12500,
"interest_rate": 1.1,
"repayment_terms": "45 days",
"reason": "The transaction is within the merchant available credit."
}Score 959, band A — tier A pricing, 45-day terms. The response also carries LIMIT_ABOVE_MODEL: this merchant's standing limit exceeds what their observed volume would model, so the account is flagged for limit review without that affecting the decision.
MER-SA-0041888 is one of the bust-out archetypes: a flawless repayment record and SAR 246k outstanding against a SAR 78k limit.
curl -s -X POST http://localhost:3000/decision \
-H 'Content-Type: application/json' \
-d '{
"merchant_id": "MER-SA-0041888",
"merchant_business_description": "",
"risk_tier": "B",
"credit_limit": 78000,
"current_exposure": 246293,
"transaction_amount": 5000,
"monthly_purchase_volume": 152320,
"inventory_level": {}
}'{
"decision": "DECLINED",
"approved_amount": 0,
"interest_rate": null,
"repayment_terms": null,
"reason": "Current exposure already equals or exceeds the credit limit."
}Forty sample merchants ship with the service across eight behavioural archetypes. GET /merchants/{id}/profile shows what the engine knows about any of them; data/merchants.json lists them all.
| Method | Path | Purpose |
|---|---|---|
POST |
/decision |
Evaluate a transaction. The endpoint. |
POST |
/decision/{reservationId}/confirm |
Order shipped — commit the reserved exposure. |
POST |
/decision/{reservationId}/release |
Order cancelled — return the headroom. |
GET |
/decisions/{decisionId} |
Full audit reconstruction of one decision. |
GET |
/decisions?limit=20 |
Recent decisions, newest first. |
GET |
/audit/verify |
Recompute the audit hash chain. |
GET |
/merchants/{id}/profile |
The precomputed feature vector. |
GET |
/ledger/stats |
Reserved and committed exposure. |
GET |
/policy |
The active credit policy. |
GET |
/health · /ready · /version |
Liveness, readiness, versions in force. |
| Field | Type | Required | Notes |
|---|---|---|---|
merchant_id |
string | — | May be empty. An unknown merchant is routed to the thin-file path, not rejected. |
merchant_business_description |
string | — | Free text. Parsed for tenure, outlet type and city, and used only where structured data is absent. Always treated as unverified. |
risk_tier |
A|B|C|D |
yes | The merchant's standing tier. |
credit_limit |
number | yes | Standing limit from the nightly limit run. |
current_exposure |
number | yes | Outstanding balance as the partner sees it. |
transaction_amount |
number | yes | The order being decided. |
monthly_purchase_volume |
number | yes | Used only when we have no observed history. |
inventory_level |
object | — | SKU → units on hand. Feeds the round-tripping check. |
line_items |
array | — | Optional extension. Basket detail enables whole-line approval. Must sum to transaction_amount. |
Unknown fields are rejected rather than ignored.
A decision is a promise of credit, not a drawdown. An approval reserves exposure for 15 minutes; confirm it when the order ships, release it when the order is cancelled. Without this, every approved-but-never-shipped order would silently consume a merchant's limit until the TTL expired.
DECISION=$(curl -s -X POST localhost:3000/decision -H 'Content-Type: application/json' -d @order.json)
RSV=$(echo "$DECISION" | node -pe 'JSON.parse(require("fs").readFileSync(0)).reservation.reservation_id')
curl -s -X POST "localhost:3000/decision/$RSV/confirm"Pass Idempotency-Key. A retry replays the stored response and does not reserve a second time. Reusing a key with a different body returns 409 rather than a plausible-looking wrong answer.
curl -X POST localhost:3000/decision -H 'Idempotency-Key: order-9931' ...| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
Listen port. |
HOST |
0.0.0.0 |
Bind address. |
LOG_LEVEL |
info |
pino level; silent in tests. |
POLICY_FILE |
policy.v1.json |
Which policy in config/ to activate. |
POLICY_DIR |
./config |
Where policies live. |
DATA_DIR |
./data |
Merchant master and transaction history. |
AUDIT_LOG_FILE |
(unset) | JSONL sink for the decision log. Unset keeps it in memory only. |
Credit policy lives in config/policy.v1.json, outside the application code. Limit multipliers, tier ceilings, haircut bands, pricing, fraud thresholds and gates are all editable there and take effect on restart. If changing a limit multiplier required a deployment, the risk team would be blocked on the engineering team — which is the wrong dependency at the moment speed matters most.
Both scripts are deterministic; committed outputs are already in data/.
npm run data:generate # 40 merchants, 4,663 orders over 24 months
npm run data:calibrate # point-in-time feature analysis -> data/calibration.jsoncalibrate prints the information-value ranking that the scorecard weights follow:
Scored 1302 point-in-time observations (47 bad, 3.6%)
on_time_rate IV 1.5040 review_for_leakage
max_dpd_ever IV 1.0986 review_for_leakage
capacity_ratio IV 0.8788 very_strong
utilisation_ratio IV 0.5826 very_strong
staples_share_of_basket IV 0.3421 strong
order_interval_stretch IV 0.2793 medium
...
npm run bench, 20,000 in-process decisions on a 40-merchant store:
boot (policy + 40 merchant profiles) 40 ms
throughput ~10,000/s (single-threaded)
mean 0.099 ms
p50 0.056 ms
p95 0.197 ms
p99 0.327 ms
max 1.125 ms
Nothing is computed on the request path: the policy is frozen and every merchant profile is precomputed at boot, so a decision is one Map lookup and pure arithmetic. Against a sub-100 ms budget that leaves roughly three orders of magnitude of headroom for mTLS, network and the real datastore that would replace the in-memory ledger.
npm test160 tests across nine suites. The ones worth looking at:
exposureLedger.test.js— 25 concurrent reservations against one merchant grant exactly the credit limit and not one riyal more. This is the failure that nothing downstream can repair.featureStore.test.js— point-in-time correctness: an order is outstanding before it was paid and settled after, judged as of the evaluation date rather than from its final outcome.decisionLog.test.js— tampering with a stored record and removing one both fail chain verification.engine.test.js— the brief's example payload, and the arithmetic behind its answer.api.test.js— an idempotent retry does not reserve twice.
config/policy.v1.json versioned credit policy — the file risk edits
data/ 40 merchants, 4,663 orders, calibration output
src/
app.js wiring; everything expensive happens at boot
engine/
index.js the 9-stage decision pipeline
featureStore.js precomputed profiles, point-in-time correct
scorecard.js expert scorecard, 1,000 points, banded
limits.js limit arithmetic and the capacity haircut
partialApproval.js minimum viable proportion, whole-line selection
pricing.js Murabaha cost-plus markup and tenor
fraud.js cheap deterministic checks: clear/review/block
reasonCodes.js structured reasons, Arabic and English
ledger/ exposure reservation, serialised per merchant
audit/ hash-chained decision log
scripts/
generateHistory.js deterministic sample data
calibrate.js weight-of-evidence feature analysis
bench.js latency profile
In scope for a production system, out of scope for a 4–6 hour exercise, and called out so the omissions read as choices rather than oversights: no persistent datastore (the ledger and audit log are in-memory with a JSONL sink), no authentication or mTLS, no ZATCA clearance API integration (invoice-cleared status is a data field, not a live check), and no champion/challenger model routing. ARCHITECTURE.md § What I would do next sets out the order I would build them in.