drop-fx-transparency-spec
Drop FX Rate Transparency & Fee Breakdown Specification
Task: MC #1193 Created: 2026-02-17 Author: John (Architect Agent) Status: DRAFT — Awaiting Alem approval Product: Drop — Fintech Payment App (Pass-through PSD2 PISP model)
Executive Summary
This specification defines the architecture for real-time exchange rate transparency and fee breakdown for Drop's remittance payment service. Drop operates as a PSD2 PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money.
Regulatory Requirement: PSD2 Directive 2015/2366/EU Article 45 mandates that payment service providers disclose:
- All charges payable by the payment service user with a breakdown
- The actual or reference exchange rate to be applied to the payment transaction
- Maximum execution time for the payment service
Current State: Drop uses mocked/seeded exchange rates from database (exchange_rates table). Rates refresh hourly from EXCHANGE_RATE_API_URL (env var, not set). Fee calculation is hardcoded at 0.5% for remittances.
Goal: Live FX rates (Norges Bank official data), transparent fee breakdown shown BEFORE user confirms transfer, full PSD2 compliance.
1. Business Context
1.1 Norwegian Context
- Base Currency: NOK (Norwegian Krone)
- Target Users: ALL residents in Norway (NOT limited to diaspora)
- Corridors: 30+ countries (seeded in
recipientstable — RSD, EUR, USD, GBP, SEK, DKK, PLN, etc.) - Regulatory Body: Finanstilsynet (Norwegian Financial Supervisory Authority)
- Reference Rate: Norges Bank publishes daily exchange rates at ~16:00 CET for 40+ currencies
1.2 Competitive Landscape
Wise (market leader):
- Uses mid-market rate with 0.5-1% transparent fee
- Full breakdown shown BEFORE transfer: amount + fee + FX rate + total + "You send X, they get Y"
- Calculator on homepage (no login required)
Remitly:
- Variable FX markup 0.5-3% (not transparent, hidden in rate)
- Fee varies by speed (Express vs Economy), payment method, new vs returning user
- Less transparent than Wise — users complain about hidden fees
Drop's Positioning:
- Transparent like Wise — show mid-market reference rate + markup + fee separately
- Norwegian-first — NOK base, Norges Bank as reference
- Cheaper than traditional banks (DNB, Nordea charge 2-5%)
1.3 Pass-Through Model Implications
Drop does NOT:
- Hold customer money
- Perform FX conversion itself (that's the bank's job via PISP)
- Need forex license (we're PISP, not EMI)
Drop DOES:
- Show user the expected FX rate BEFORE transfer
- Charge a transparent service fee (0.5% for remittance)
- Display what recipient will receive (calculated estimate)
- Send payment instruction to user's bank via Open Banking
Key Point: FX rate shown to user is reference/estimate. Actual conversion happens at user's bank. Drop must disclose this clearly (PSD2 Article 45).
2. Current Implementation Analysis
2.1 What Exists (Good Foundation)
Database Schema:
-- exchange_rates table (seeded with 30+ currencies)
CREATE TABLE exchange_rates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_currency TEXT NOT NULL,
to_currency TEXT NOT NULL,
rate REAL NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(from_currency, to_currency)
);
Service Layer: /src/lib/services/rates.ts
getExchangeRates()— returns all rates from DBgetRate(from, to)— returns single raterefreshRatesIfStale()— fetches fromEXCHANGE_RATE_API_URLif last update > 1 hour- Graceful degradation — falls back to cached rates on API failure
API Endpoints:
GET /api/rates— returns all ratesGET /api/rates/[currency]— returns single ratePOST /api/transactions/disclosure— calculates fee + FX preview (BEFORE user confirms)
UI Component: /src/components/pre-payment-disclosure.tsx
- Shows breakdown: amount + fee + FX rate + receive amount + total + ETA
- Modal dialog — user must confirm before transaction submits
- Norwegian language, good UX (matches Wise pattern)
Fee Calculation: Hardcoded in /src/app/api/transactions/remittance/route.ts
const feePercent = 0.005; // 0.5%
const fee = Math.round(amount * feePercent * 100) / 100;
2.2 What's Missing (Critical Gaps)
❌ Live FX Rate Source: EXCHANGE_RATE_API_URL not configured, no real provider
❌ Norges Bank Integration: Not using official Norwegian reference rates
❌ FX Markup Transparency: No distinction between mid-market rate and Drop's rate
❌ Fee Configuration: Fees hardcoded, no corridor-specific fees, no volume tiers
❌ Rate Locking: No guarantee user gets the rate they saw (can change between preview and execution)
❌ Stale Rate Detection: 1-hour threshold too long for high-volume corridors (EUR, USD)
❌ Rate Drift Monitoring: No alerts when rates deviate significantly from Norges Bank reference
❌ Historical Rate Tracking: No log of which rate was used for completed transactions
❌ PSD2 Disclosure Language: Component exists but needs exact regulatory wording
3. Data Sources
3.1 Norges Bank API (Primary Reference)
Official Source: Norges Bank Data Warehouse
API Base URL: https://data.norges-bank.no/api/data/EXR/
Example Request:
# Daily exchange rates for EUR, USD, GBP against NOK
# B = Business day frequency, SP = Spot (daily reference rate)
curl "https://data.norges-bank.no/api/data/EXR/B..NOK.SP?startPeriod=2026-02-17&endPeriod=2026-02-17&format=sdmx-json&locale=en"
Key Features:
- Free: No API key required, fully open
- Authoritative: Official central bank rates used by Norwegian businesses
- Daily Updates: Published ~16:00 CET
- 40+ Currencies: Covers all major Drop corridors
- SDMX Standard: Industry-standard format for statistical data
Limitations:
- Daily Frequency: Not real-time (updated once per business day)
- No Weekends: Last Friday rate used on weekends
- Spot Rate Only: No forward rates, no historical intraday
Cost: $0 (completely free)
3.2 Commercial FX Provider (Real-Time Fallback)
For high-volume corridors (EUR, USD, GBP) where users expect near-real-time rates:
Option A: ExchangeRate-API.com
- Free Tier: 1,500 requests/month, hourly updates
- Paid Tier: $9/month for 100k requests, 10-min updates
- Currencies: 160+
- Format: Simple JSON:
{ "NOK": { "EUR": 0.0867 } } - Reliability: 99.9% uptime SLA (paid tier)
Recommendation: Start with Norges Bank (free) + ExchangeRate-API.com free tier (1,500 requests/month = 50/day, enough for MVP). Migrate to paid tier when transaction volume > 50/day.
Hybrid Strategy:
- Norges Bank: Daily reference rate for all corridors (free, authoritative)
- ExchangeRate-API: Real-time updates for EUR, USD, GBP (high volume)
- Fallback: Cached DB rates (stale threshold: 4 hours for major corridors, 24 hours for minor)
Cost Estimate: $0-9/month (depending on traffic)
4. Database Schema
4.1 Enhanced exchange_rates Table
-- Add columns to existing table
ALTER TABLE exchange_rates ADD COLUMN source TEXT DEFAULT 'seed';
-- 'norges_bank', 'exchangerate_api', 'fixer', 'seed'
ALTER TABLE exchange_rates ADD COLUMN rate_type TEXT DEFAULT 'spot';
-- 'spot', 'mid_market', 'buy', 'sell'
ALTER TABLE exchange_rates ADD COLUMN markup REAL DEFAULT 0.0;
-- Drop's markup percentage (0.0 = no markup, 0.005 = 0.5%)
ALTER TABLE exchange_rates ADD COLUMN external_id TEXT;
-- Provider's ID for this rate (if applicable)
ALTER TABLE exchange_rates ADD COLUMN is_stale INTEGER DEFAULT 0;
-- 0 = fresh, 1 = stale (triggers refresh)
ALTER TABLE exchange_rates ADD COLUMN last_refresh_attempt TEXT;
-- Timestamp of last API fetch attempt (for monitoring)
CREATE INDEX idx_rates_stale ON exchange_rates(is_stale, updated_at);
CREATE INDEX idx_rates_source ON exchange_rates(source);
4.2 New Table: fx_rate_history
Track which rate was shown to user AND which rate was actually used by bank:
CREATE TABLE fx_rate_history (
id TEXT PRIMARY KEY,
transaction_id TEXT REFERENCES transactions(id) ON DELETE CASCADE,
from_currency TEXT NOT NULL,
to_currency TEXT NOT NULL,
shown_rate REAL NOT NULL, -- Rate displayed to user at disclosure
shown_markup REAL NOT NULL, -- Markup at disclosure time
shown_source TEXT NOT NULL, -- 'norges_bank', 'exchangerate_api', etc.
actual_rate REAL, -- Actual bank rate (if known from bank statement)
rate_locked_at TEXT, -- Timestamp when rate was locked (if locking enabled)
rate_locked_until TEXT, -- Expiry time for locked rate
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_fx_history_tx ON fx_rate_history(transaction_id);
CREATE INDEX idx_fx_history_locked ON fx_rate_history(rate_locked_at);
Purpose:
- Compliance: PSD2 requires we log what rate we showed user
- Transparency: User can compare promised rate vs actual rate
- Dispute Resolution: Evidence for customer complaints ("you said 10.5, I got 10.3")
- Rate Locking (Future): Store locked rate + expiry
4.3 New Table: fee_configs
Make fees configurable instead of hardcoded:
CREATE TABLE fee_configs (
id TEXT PRIMARY KEY,
corridor TEXT NOT NULL UNIQUE, -- 'NOK-RSD', 'NOK-EUR', '*' (default)
fee_type TEXT NOT NULL CHECK(fee_type IN ('percentage', 'flat', 'tiered')),
fee_percentage REAL, -- For percentage type (0.005 = 0.5%)
fee_flat REAL, -- For flat type (25 NOK)
fee_tiers TEXT, -- JSON for tiered: [{"max":1000,"rate":0.01},{"max":null,"rate":0.005}]
min_fee REAL DEFAULT 0, -- Minimum fee (NOK)
max_fee REAL, -- Maximum fee cap (NOK, NULL = no cap)
effective_from TEXT NOT NULL DEFAULT (datetime('now')),
effective_until TEXT, -- NULL = indefinite
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Seed default config
INSERT INTO fee_configs (id, corridor, fee_type, fee_percentage, min_fee)
VALUES ('fee_default', '*', 'percentage', 0.005, 10.0); -- 0.5%, min 10 NOK
-- Corridor-specific example (cheaper EUR corridor)
INSERT INTO fee_configs (id, corridor, fee_type, fee_percentage, min_fee)
VALUES ('fee_eur', 'NOK-EUR', 'percentage', 0.003, 5.0); -- 0.3%, min 5 NOK
Benefits:
- A/B Testing: Change fees without code deploy
- Promotional Pricing: Set temporary fee discounts (effective_from/until)
- Corridor Optimization: Cheaper fees for high-volume corridors (EUR, USD)
- Tiered Pricing: Lower fee percentage for large transfers (e.g., >10k NOK)
4.4 New Table: fx_rate_alerts
Monitor rate drift and API failures:
CREATE TABLE fx_rate_alerts (
id TEXT PRIMARY KEY,
alert_type TEXT NOT NULL CHECK(alert_type IN ('stale_rate', 'rate_drift', 'api_failure', 'missing_rate')),
severity TEXT NOT NULL CHECK(severity IN ('low', 'medium', 'high', 'critical')),
from_currency TEXT NOT NULL,
to_currency TEXT, -- NULL for API failure alerts
details TEXT, -- JSON: {"expected":10.5,"actual":11.2,"drift_pct":6.7}
resolved INTEGER DEFAULT 0, -- 0 = open, 1 = resolved
created_at TEXT DEFAULT (datetime('now')),
resolved_at TEXT
);
CREATE INDEX idx_fx_alerts_unresolved ON fx_rate_alerts(alert_type, resolved, created_at);
Alert Examples:
stale_rate: EUR/NOK not updated in 6 hours (should refresh hourly)rate_drift: Norges Bank shows 11.5, ExchangeRate-API shows 11.9 (3.5% drift — investigate)api_failure: ExchangeRate-API returned 503 for 3 consecutive attemptsmissing_rate: User tried to transfer to THB, no rate in DB
5. API Endpoints
5.1 Enhanced GET /api/rates
Current: Returns all rates from DB New: Add metadata, source attribution, staleness indicator
Request:
GET /api/rates?base=NOK&symbols=EUR,USD,RSD
Response:
{
"base": "NOK",
"rates": {
"EUR": {
"rate": 0.0867,
"markup": 0.005,
"effectiveRate": 0.0871,
"source": "norges_bank",
"updatedAt": "2026-02-17T16:00:00Z",
"isStale": false
},
"USD": {
"rate": 0.0923,
"markup": 0.005,
"effectiveRate": 0.0928,
"source": "exchangerate_api",
"updatedAt": "2026-02-17T14:30:00Z",
"isStale": false
}
},
"updatedAt": "2026-02-17T14:30:00Z"
}
Fields:
rate: Mid-market reference rate (from Norges Bank or provider)markup: Drop's markup percentageeffectiveRate:rate * (1 + markup)— what user actually payssource: Which API provided this rateisStale: True if rate hasn't refreshed within threshold
Caching: Cache-Control: public, max-age=300 (5 minutes)
5.2 Enhanced POST /api/transactions/disclosure
Current: Calculates fee + FX preview
New: Return full PSD2-compliant disclosure, log to fx_rate_history
Request:
{
"type": "remittance",
"amount": 5000,
"recipientId": "rec_abc123"
}
Response:
{
"sendAmount": 5000,
"sendCurrency": "NOK",
"fee": 25,
"feePercentage": 0.5,
"totalCost": 5025,
"exchangeRate": {
"reference": 10.1700,
"source": "Norges Bank (16:00 CET)",
"markup": 0.5,
"effectiveRate": 10.2209,
"updatedAt": "2026-02-17T16:00:00Z"
},
"receiveAmount": 51104.50,
"receiveCurrency": "RSD",
"estimatedDelivery": "1-2 business days",
"rateValidUntil": "2026-02-17T17:00:00Z",
"psd2Disclosure": {
"en": "This is an estimate. Your bank will apply its own exchange rate at the time of transfer. The final amount received may differ.",
"no": "Dette er et estimat. Din bank vil bruke sin egen valutakurs ved overføring. Endelig beløp mottatt kan avvike."
},
"disclosureId": "disc_xyz789"
}
6. Rate Refresh Strategy
6.1 Refresh Schedule
| Corridor | Source | Refresh Frequency | Stale Threshold | Fallback |
|---|---|---|---|---|
| NOK-EUR | ExchangeRate-API | 10 min | 1 hour | Norges Bank daily |
| NOK-USD | ExchangeRate-API | 10 min | 1 hour | Norges Bank daily |
| NOK-GBP | ExchangeRate-API | 10 min | 1 hour | Norges Bank daily |
| NOK-RSD | Norges Bank | Daily (16:00 CET) | 24 hours | Cached DB |
| NOK-SEK | Norges Bank | Daily (16:00 CET) | 24 hours | Cached DB |
| All Others | Norges Bank | Daily (16:00 CET) | 48 hours | Cached DB |
Rationale:
- High-Volume Corridors (EUR, USD, GBP): Users expect near-real-time rates. 10-min refresh acceptable.
- Regional Corridors (RSD, SEK, PLN): Daily Norges Bank rate sufficient (low volatility).
- Long-Tail Corridors: 48-hour stale threshold (rarely used, rate changes minimal).
6.2 Refresh Triggers
Automatic:
- Cron Job: Every 10 min, check stale rates and refresh high-volume corridors
- API Request: When user requests
/api/transactions/disclosure, check if corridor rate is stale → refresh synchronously (max 5s timeout) - Daily Batch: At 16:30 CET (30 min after Norges Bank publishes), fetch all 40 currencies
Manual:
- Admin endpoint:
POST /api/admin/fx/refresh(force refresh all rates)
7. Fee Calculation Engine
7.1 Fee Structure
Fee Types:
- Percentage:
amount * fee_percentage(most common) - Flat: Fixed amount (e.g., 25 NOK for all transfers)
- Tiered: Different percentage based on amount brackets
Example Tiered Fee (NOK-RSD):
[
{ "max": 1000, "rate": 0.01 }, // 0-1000 NOK: 1%
{ "max": 5000, "rate": 0.007 }, // 1001-5000 NOK: 0.7%
{ "max": null, "rate": 0.005 } // >5000 NOK: 0.5%
]
Implementation:
// /lib/services/fees.ts
export interface FeeConfig {
corridor: string;
feeType: 'percentage' | 'flat' | 'tiered';
feePercentage?: number;
feeFlat?: number;
feeTiers?: { max: number | null; rate: number }[];
minFee: number;
maxFee?: number;
}
export async function calculateFee(
amount: number,
fromCurrency: string,
toCurrency: string
): Promise<{ fee: number; config: FeeConfig }> {
const corridor = `${fromCurrency}-${toCurrency}`;
// 1. Get corridor-specific config (or default '*')
let config = await getOne<FeeConfig>(
`SELECT * FROM fee_configs
WHERE corridor = ?
AND (effective_from <= datetime('now'))
AND (effective_until IS NULL OR effective_until > datetime('now'))
ORDER BY effective_from DESC LIMIT 1`,
[corridor]
);
if (!config) {
config = await getOne<FeeConfig>(
`SELECT * FROM fee_configs WHERE corridor = '*' LIMIT 1`,
[]
);
}
if (!config) {
throw new Error('No fee configuration found');
}
// 2. Calculate fee based on type
let fee = 0;
if (config.feeType === 'percentage') {
fee = amount * (config.feePercentage || 0);
} else if (config.feeType === 'flat') {
fee = config.feeFlat || 0;
} else if (config.feeType === 'tiered') {
const tiers = JSON.parse(config.feeTiers || '[]');
for (const tier of tiers) {
if (tier.max === null || amount <= tier.max) {
fee = amount * tier.rate;
break;
}
}
}
// 3. Apply min/max caps
if (config.minFee && fee < config.minFee) {
fee = config.minFee;
}
if (config.maxFee && fee > config.maxFee) {
fee = config.maxFee;
}
// 4. Round to 2 decimals
fee = Math.round(fee * 100) / 100;
return { fee, config };
}
8. UI Component Spec
8.1 Pre-Payment Disclosure (Enhanced)
Location: /src/components/pre-payment-disclosure.tsx
Required Changes:
interface PrePaymentDisclosureProps {
amount: number;
fee: number;
feeConfig: FeeConfig; // NEW: show fee structure
exchangeRate: {
reference: number;
source: string; // "Norges Bank (16:00 CET)"
markup: number;
effectiveRate: number;
updatedAt: string;
};
receiveAmount: number;
receiveCurrency: string;
estimatedDelivery: string;
psd2Disclosure: { no: string; en: string }; // NEW: regulatory text
rateValidUntil: string; // NEW: countdown timer
onConfirm: () => void;
onCancel: () => void;
}
Layout Additions:
- Exchange Rate Breakdown Section:
<div className="bg-[#F8FAFC] rounded-xl p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-[#64748B]">Referansekurs (Norges Bank)</span>
<span className="text-sm font-medium text-[#1E293B]">1 NOK = 10.1700 RSD</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-[#64748B]">Drop's påslag (0.5%)</span>
<span className="text-sm font-medium text-[#1E293B]">+ 0.0509 RSD</span>
</div>
<div className="pt-2 border-t border-[#E2E8F0] flex items-center justify-between">
<span className="text-sm font-bold text-[#0F172A]">Effektiv kurs</span>
<span className="text-sm font-bold text-[#0B6E35]">1 NOK = 10.2209 RSD</span>
</div>
</div>
- PSD2 Regulatory Disclosure:
<div className="p-3 bg-[#FFFBEB] border border-[#FCD34D] rounded-xl">
<p className="text-xs text-[#92400E] leading-relaxed">
<strong>Viktig informasjon:</strong> Dette er et estimat basert på dagens valutakurs.
Din bank vil bruke sin egen valutakurs ved overføring. Endelig beløp mottatt kan avvike.
</p>
</div>
9. PSD2 Compliance Checklist
9.1 Article 45: Information Before Payment Execution
Requirements (from PSD2 Directive):
✅ Maximum execution time:
- Drop: "1-2 business days" (EEA) or "2-4 business days" (non-EEA)
- Shown in disclosure modal + transaction receipt
✅ All charges payable with breakdown:
- Drop: "Gebyr (0.5%): 25 NOK" + "Total kostnad: 5025 NOK"
- Breakdown: base amount + fee = total
✅ Actual or reference exchange rate:
- Drop: Shows Norges Bank reference rate + markup + effective rate
- Disclosure: "Referansekurs (Norges Bank): 1 NOK = 10.1700 RSD, Drop's påslag (0.5%): + 0.0509 RSD, Effektiv kurs: 1 NOK = 10.2209 RSD"
9.2 Disclosure Language (Norwegian + English)
PSD2-Compliant Text:
Norwegian (Primary):
VIKTIG INFORMASJON OM VALUTAVEKSLING
Dette er et estimat basert på dagens valutakurs fra Norges Bank (oppdatert [TIMESTAMP]).
Din bank vil bruke sin egen valutakurs ved gjennomføring av betalingen.
Det endelige beløpet mottaker får kan avvike fra dette estimatet.
Drop legger til et påslag på [X]% på referansekursen. Dette er inkludert i "Effektiv kurs" ovenfor.
Gebyret på [FEE] NOK er fast og vil ikke endres.
Ved å bekrefte godtar du at totalkostnaden på [TOTAL] NOK trekkes fra din bankkonto.
English (Secondary):
IMPORTANT INFORMATION ABOUT CURRENCY CONVERSION
This is an estimate based on today's exchange rate from Norges Bank (updated [TIMESTAMP]).
Your bank will apply its own exchange rate when executing the payment.
The final amount received may differ from this estimate.
Drop applies a markup of [X]% on the reference rate. This is included in the "Effective rate" above.
The fee of [FEE] NOK is fixed and will not change.
By confirming, you agree that the total cost of [TOTAL] NOK will be debited from your bank account.
10. Monitoring & Alerting
10.1 Metrics to Track
| Metric | Threshold | Alert If | Action |
|---|---|---|---|
| Stale rate count | 0 | > 5 | Investigate API provider |
| Rate drift (vs Norges Bank) | <1% | >3% | Check provider, notify admin |
| API failure rate | <1% | >5% | Switch to fallback source |
| Rate refresh latency | <5s | >10s | Optimize API calls |
| Fee revenue (daily) | N/A | Sudden drop >50% | Check fee config changes |
10.2 Slack Alerts
Webhook URL: SLACK_WEBHOOK_URL env var
Alert Conditions:
// /lib/services/fx-alerts.ts
export async function checkRateDrift(
from: string,
to: string,
norgesBankRate: number,
providerRate: number
) {
const driftPercent = Math.abs((providerRate - norgesBankRate) / norgesBankRate) * 100;
if (driftPercent > 3) {
const alertId = randomId('alert');
await run(`
INSERT INTO fx_rate_alerts (
id, alert_type, severity, from_currency, to_currency, details
) VALUES (?, 'rate_drift', 'high', ?, ?, ?)
`, [
alertId,
from,
to,
JSON.stringify({ norgesBankRate, providerRate, driftPercent })
]);
await sendSlackAlert({
severity: 'high',
title: `Rate drift detected: ${from}-${to}`,
message: `Norges Bank: ${norgesBankRate}, Provider: ${providerRate} (${driftPercent.toFixed(2)}% drift)`,
alertId,
});
}
}
11. Implementation Phases
Phase 1: Foundation (Week 1) — Core Infrastructure
Deliverables:
- Enhanced
exchange_ratestable schema (source, markup, is_stale columns) fee_configstable + seeded datafx_rate_historytablefx_rate_alertstable- Fee calculation engine (
/lib/services/fees.ts) - Enhanced rate service with staleness detection
- Unit tests for fee calculation + rate retrieval
Acceptance Criteria:
calculateFee()correctly handles percentage, flat, tiered feesgetRate()detects stale rates and flags them- Database migrations run successfully on SQLite + PostgreSQL
Effort: 3 days (1 builder agent)
Phase 2: Norges Bank Integration (Week 1) — Primary Data Source
Deliverables:
- Norges Bank API client (
/lib/providers/norges-bank.ts) - SDMX-JSON parser for Norges Bank response format
- Daily refresh cron job (16:30 CET)
- Seed all 40 currencies from Norges Bank
- Integration tests (mock Norges Bank API responses)
Acceptance Criteria:
- All 30+ Drop corridors have rates from Norges Bank
- Rates update automatically every business day at 16:30 CET
- Parser handles SDMX-JSON format correctly
- Graceful handling of Norges Bank API downtime (use cached rates)
Effort: 2 days (1 builder agent)
Phase 3: Commercial Provider Integration (Week 2) — Real-Time Fallback
Deliverables:
- ExchangeRate-API client (
/lib/providers/exchangerate-api.ts) - Hybrid refresh strategy (Norges Bank daily + ExchangeRate-API 10-min for EUR/USD/GBP)
- 10-minute cron job for high-volume corridors
- Rate drift detection (compare Norges Bank vs ExchangeRate-API)
- Admin alert on >3% drift
Acceptance Criteria:
- EUR, USD, GBP rates refresh every 10 minutes
- Other corridors use Norges Bank daily rate
- Drift alerts appear in
fx_rate_alertstable - Slack webhook fires for high drift (>3%)
Effort: 2 days (1 builder agent)
Cost: $0 (free tier: 1,500 requests/month = 50/day, sufficient for MVP)
Phase 4: Enhanced Disclosure UI (Week 2) — Frontend
Deliverables:
- Enhanced
pre-payment-disclosure.tsxcomponent- Rate breakdown section (reference + markup + effective)
- Rate validity countdown timer
- PSD2 regulatory text
- Fee breakdown (tiered if applicable)
- Homepage rate calculator widget
- Updated
/api/transactions/disclosureendpoint (return full breakdown)
Acceptance Criteria:
- Disclosure modal shows: reference rate, markup, effective rate, source, last updated
- Countdown timer shows "Rate valid for X minutes"
- PSD2 text in Norwegian + English
- Calculator works without login (public API route)
Effort: 3 days (1 builder + 1 designer agent)
Phase 5: Admin Tools & Monitoring (Week 3) — Ops Dashboard
Deliverables:
/admin/fx-monitoringdashboard pageGET /api/admin/fx/alertsendpointPOST /api/admin/fx/refreshmanual refresh endpoint- Slack webhook integration for alerts
- Rate drift monitoring (background job every 10 min)
- Stale rate detection (background job every 10 min)
Acceptance Criteria:
- Dashboard shows all rates, staleness, drift, alerts
- Manual refresh button works (triggers immediate API fetch)
- Slack alerts received for high/critical issues
- Unresolved alerts badge in admin nav
Effort: 2 days (1 builder agent)
Phase 6: Rate Locking (Future — Deferred) — Advanced Feature
Deliverables:
POST /api/fx/lock-rateendpoint- Lock expiry background job (expire after 10 min)
- UI: "Lock this rate" button in disclosure modal (for transfers >10k NOK)
- Backend: Verify
lockIdduring transaction submission
Acceptance Criteria:
- User can lock rate for 10 minutes
- Locked rate guaranteed during checkout
- Expired locks show error message
- Lock usage logged in
fx_rate_history
Effort: 2 days (1 builder agent)
Deferral Reason: Not MVP-critical. Can ship without rate locking. Add when user feedback indicates need.
12. Cost Analysis
12.1 Infrastructure Costs
| Component | Provider | Cost | Notes |
|---|---|---|---|
| Norges Bank API | Norges Bank | $0 | Free, open API |
| ExchangeRate-API (Free Tier) | ExchangeRate-API.com | $0 | 1,500 requests/month (50/day) |
| ExchangeRate-API (Paid Tier) | ExchangeRate-API.com | $9/month | 100k requests (when traffic grows) |
| Cron Jobs (Vercel) | Vercel | $0 | Included in Hobby/Pro plan |
| Slack Webhook | Slack | $0 | Free tier sufficient |
Total Monthly Cost (MVP): $0 Total Monthly Cost (Production >50 txs/day): $9
12.2 Revenue Impact
Fee Revenue Projection:
| Scenario | Avg Transfer | Fee % | Txs/Month | Monthly Revenue |
|---|---|---|---|---|
| MVP (Beta) | 2,000 NOK | 0.5% | 100 | 1,000 NOK ($95) |
| Growth | 3,000 NOK | 0.5% | 500 | 7,500 NOK ($710) |
| Scale | 4,000 NOK | 0.5% | 2,000 | 40,000 NOK ($3,800) |
Break-Even: 10 transactions/month covers $9 API cost (at 0.5% fee on 2,000 NOK avg)
13. Acceptance Criteria
13.1 Functional Requirements
- Live Rates: EUR, USD, GBP refresh every 10 minutes from ExchangeRate-API
- Official Rates: All other corridors use Norges Bank daily rate (16:00 CET)
- Fee Transparency: User sees breakdown: amount + fee (%) + total BEFORE confirming
- FX Transparency: User sees: reference rate (Norges Bank) + markup + effective rate
- Source Attribution: Disclosure shows "Rate from Norges Bank, updated X minutes ago"
- Rate Validity: User told "Rate valid for 10 minutes" with countdown timer
- PSD2 Compliance: Disclosure text matches Article 45 requirements (Norwegian + English)
- Configurable Fees: Admin can change fees via
fee_configstable (no code deploy) - Corridor-Specific Fees: Different fees for different corridors (e.g., EUR cheaper than RSD)
13.2 Non-Functional Requirements
- Performance: Disclosure endpoint responds in <2s (including rate fetch)
- Availability: Graceful degradation if API fails (use cached rates + show staleness warning)
- Accuracy: Rate shown to user logged in
fx_rate_historyfor audit - Monitoring: Slack alerts for rate drift >3%, API failures, stale rates >6h
- Security: API keys stored in env vars (never hardcoded), admin endpoints require auth
13.3 Compliance Requirements
- PSD2 Article 45: All charges + exchange rate disclosed BEFORE payment execution
- PSD2 Record-Keeping:
fx_rate_historyretained for 5+ years (never auto-deleted) - Finanstilsynet Compliance: Norges Bank used as official reference (Norwegian requirement)
- Consumer Protection: Clear language ("This is an estimate, your bank may use different rate")
14. Open Questions (For Alem)
Q1: Rate Locking Priority
Question: Should we implement rate locking in Phase 1 (MVP) or defer to Phase 2?
Recommendation: Defer. Rate locking adds complexity and is not required for PSD2 compliance. Ship MVP faster, validate user demand first.
Q2: Commercial FX Provider Choice
Question: Which paid FX API should we use?
Recommendation: ExchangeRate-API.com — Best price/performance ratio ($9/month), simplest integration, Norwegian NOK supported as base currency.
Q3: Fee Structure
Question: What fee structure should we launch with?
Recommendation: Flat 0.5% for MVP, then A/B test tiered pricing after 100 transactions. Simple beats clever for launch.
Q4: Homepage Calculator
Question: Should we show fee breakdown in homepage calculator (public, no login)?
Recommendation: Yes. Full transparency, matches Wise UX. Show: "Total cost: 5025 NOK (includes 25 NOK fee)" even before login.
15. Next Steps
- Review this spec with Alem (approve/request changes)
- Answer Open Questions (Q1-Q4 above)
- Prioritize phases (which to implement first?)
- Assign Phase 1 to builder agent (schema changes + fee calculation engine)
- Validate after Phase 1 (validator agent checks DB schema + tests)
- Iterate through Phases 2-5 (one phase at a time, validate each)
Estimated Timeline:
- Phase 1: 3 days (schema + fee engine)
- Phase 2: 2 days (Norges Bank integration)
- Phase 3: 2 days (ExchangeRate-API integration)
- Phase 4: 3 days (UI + disclosure enhancements)
- Phase 5: 2 days (admin dashboard + monitoring)
Total: 12 days (~2.5 weeks) for full PSD2-compliant FX transparency system
Sources
- Norges Bank Exchange Rates
- Norges Bank Data Warehouse
- PSD2 Directive 2015/2366/EU
- PSD2 Article 45 Information Requirements
- Wise Remitly Fee Comparison
- ExchangeRate-API Documentation
- Remitly vs Wise Transparency Analysis
End of Specification
No comments to display
No comments to display