Skip to content

Repository files navigation

TradePay Decision Engine

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.


Quick start

Docker Compose (recommended)

docker compose up --build

The 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/ready

Stop and clean up:

docker compose down          # stop
docker compose down -v       # stop and delete the audit log volume

Verified 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 without Compose

docker build -t tradepay/decision-engine .
docker run --rm -p 3000:3000 tradepay/decision-engine

Locally, without Docker

Requires 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 profile

Try it

The 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.

A known merchant with a clean record, approved in full

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.

A merchant already past their limit, declined

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.


API

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.

Request

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.

Reservations

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"

Idempotency

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' ...

Configuration

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.


Regenerating the sample data

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.json

calibrate 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
  ...

Performance

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.


Tests

npm test

160 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.

Layout

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

Deliberately not built

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages