← All recipes
Nightly Reconciliation
≈ 1 day
A partner that moves client money needs an independent nightly check that its internal ledger agrees with the platform. The reconciliation report gives you a dated snapshot of every balance and position; tax lots and realized gains feed CGT statements from the same run.
The flow
| Step | Endpoint / Event | What it does |
|---|---|---|
| 1 | GET /report/reconciliation?asOf=YYYY-MM-DD | Dated snapshot: accounts, cash ledger, securities, unsettled trades, fees, dividends, corporate actions, custody positions. |
| 2 | Compare cash | Match aggregate closing cash to summary.closingCashBalance, then match cashLedger transaction movements by userId and transactionId. |
| 3 | Compare positions | Match units per symbol per sub-account against the securities section; pooled custody vs the sum of holdings is in the custody section. |
| 4 | Explain the gaps | The unsettled section is the legitimate difference between wallet cash and bank cash until T+N — everything else is an exception. |
| 5 | GET /users/{id}/tax-lots · /gains | FIFO open lots and realized disposals for capital-gains statements, from the same nightly run. |
| 6 | ?format=csv§ion=cash|securities|… | Pull any section as CSV straight into your warehouse or finance tooling. |
| 7 | GET /client-activity · GET /audit | API-level activity trail to investigate any exception you flagged. |
Implementation
javascript
const BASE = process.env.MYSTOCKS_BASE_URL ?? 'https://mystocks.africa/api/v1/partner';
const API_KEY = process.env.MYSTOCKS_API_KEY;
if (!API_KEY) throw new Error('Set MYSTOCKS_API_KEY before running this recipe');
const IS_SANDBOX = BASE.includes('/sandbox/');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function logicalKey(...parts) {
return parts.map(part => String(part).trim().replace(/[^a-zA-Z0-9_-]/g, '-')).join('_');
}
async function api(path, { method = 'GET', body, idempotencyKey, headers = {}, maxAttempts = 4 } = {}) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(BASE + path, {
method,
headers: {
'x-api-key': API_KEY,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const contentType = res.headers.get('content-type') || '';
const payload = contentType.includes('json') ? await res.json() : await res.text();
if (res.ok) return payload;
const envelope = payload && typeof payload === 'object' ? payload.error : null;
const code = envelope && typeof envelope === 'object'
? envelope.code
: payload && typeof payload === 'object' && payload.code || 'HTTP_' + res.status;
const message = envelope && typeof envelope === 'object'
? envelope.message
: typeof envelope === 'string' ? envelope
: payload && typeof payload === 'object' && payload.message || res.statusText;
const retryable = (res.status === 429 || res.status >= 500) && (method === 'GET' || idempotencyKey);
if (!retryable || attempt === maxAttempts - 1) {
throw Object.assign(new Error(code + ': ' + message), {
status: res.status, code, details: envelope?.details ?? payload?.details ?? null, payload,
retryAfterSeconds: Number(res.headers.get('Retry-After')) || null,
});
}
const retryAfter = Number(res.headers.get('Retry-After'));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(1000 * (2 ** attempt), 30_000);
await sleep(delayMs + Math.floor(Math.random() * 250));
}
}
// Production-only endpoint. Do not point this recipe at the sandbox base.
async function nightlyRecon(asOf /* '2026-07-09' — yesterday, UTC */) {
if (IS_SANDBOX) throw new Error('Reconciliation is production-only');
const report = await api('/report/reconciliation?asOf=' + encodeURIComponent(asOf));
const exceptions = [];
// 2. Cash: compare aggregate closing cash, then match each cash-ledger movement.
const internalClosingCash = await myLedger.totalCashBalanceUsd(asOf);
if (Math.abs(internalClosingCash - report.summary.closingCashBalance) > 0.01) {
exceptions.push({
kind: 'CASH_BALANCE', internal: internalClosingCash,
platform: report.summary.closingCashBalance,
});
}
for (const row of report.cashLedger) {
const internal = await myLedger.transaction(row.transactionId);
if (!internal || Math.abs(internal.amount - row.amount) > 0.01) {
exceptions.push({ kind: 'CASH_MOVEMENT', userId: row.userId, transactionId: row.transactionId,
internal: internal?.amount ?? null, platform: row.amount });
}
}
// 3. Positions: units per symbol per sub-account
for (const pos of report.securitiesLedger) {
const internal = await myLedger.units(pos.userId, pos.symbol, asOf);
if (Math.abs(internal - pos.units) > 1e-6) {
exceptions.push({ kind: 'POSITION', ...pos, internal });
}
}
// 4. Unsettled trades explain cash-vs-bank timing — record, don't alert.
const unsettledUsd = report.summary.unsettledSellAmount + report.summary.unsettledBuyAmount;
if (exceptions.length) await alertFinance(asOf, exceptions);
await warehouse.store('mystocks_recon', { asOf, summary: report.summary, exceptions, unsettledUsd });
}Common mistakes
- —Reconciling against live endpoints instead of the asOf snapshot — balances move while you iterate; the report is internally consistent for its date.
- —Alerting on unsettled amounts. Unsettled SELL proceeds are a timing difference by design (T+N), not a break — record them, expect them to clear by settlement date.
- —Comparing floats for equality. Use a cent threshold for cash and a dust threshold (1e-6 units) for fractional positions.
- —Running at local midnight. Use UTC dates consistently — asOf is a UTC business date.
- —Only reconciling cash. Position breaks (missed corporate action, unapplied fill) are rarer but far more expensive to unwind late.
Prove it in sandbox first
- 1.The reconciliation endpoint is production-only. Develop matching logic against a saved production-shaped fixture, then validate it with controlled pilot credentials.
- 2.CSV reconciliation exports are also production-only; use a saved cash or securities CSV fixture when designing warehouse imports.