/** * "گزارش" — the account statement (D24). * * The reference proxied an external accounting system * (henza_server Customer/TahesabController). The owner chose option الف: build * the same UI and flow over the target's OWN ledger, with no external call. * * Covers what the owner asked for per page — opening, the empty state, the * error state and the main flow — plus what makes a statement trustworthy: * * 1. empty state: a customer with no entries gets an empty result, not a 500 * 2. main flow: entries appear with the right in/out columns * 3. the RUNNING BALANCE is correct, and its last value agrees with the * cached Customer.goldDebtGrams / fiatDebtRial the rest of the app shows * 4. UNCONFIRMED entries are excluded, exactly as they are from the cached * balance * 5. the detail list and the single-document read * 6. SEC-16: another customer's document id returns NOTHING. The reference's * detail() took the factor code from the URL with no ownership check at * all, so any signed-in customer could read anyone's document. * 7. no token is a 401 on every route * 8. balances endpoint agrees with the customer row * * Every Persian string is a literal here and travels as UTF-8 JSON (D15). * * Requires the API running WITH OTP_MASTER_CODE set. * Usage: node scripts/test-report.cjs [apiBaseUrl] [masterCode] */ const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, ''); const MASTER = process.argv[3] || '1379'; let pass = 0, fail = 0; const failures = []; const chk = (c, m) => { if (c) { pass++; console.log(' PASS ' + m); } else { fail++; failures.push(m); console.log(' FAIL ' + m); } }; const step = (t) => console.log('\n' + t); const call = async (method, p, body, token) => { const res = await fetch(`${API}/${p}`, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await res.text(); let payload = null; try { payload = JSON.parse(text); } catch { /* empty */ } return { status: res.status, ok: res.ok, body: payload, raw: text }; }; const signIn = (mobile) => call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER }); const made = { customers: [], entries: [] }; const uniq = String(Date.now()).slice(-7); (async () => { step('SETUP — two customers; one with a ledger, one with none'); const mkCustomer = async (suffix, name) => { const c = await prisma.customer.create({ data: { fullName: name, firstName: name.split(' ')[0], lastName: name.split(' ').slice(1).join(' '), mobile: `0919${uniq.slice(0, 3)}${suffix}`, nationalCode: `nc${uniq}${suffix}`, passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true, goldCreditLimitGrams: '500', }, }); made.customers.push(c.id); return c; }; const owner = await mkCustomer('11', 'مشتری گزارش'); const other = await mkCustomer('22', 'مشتری دیگر'); const empty = await mkCustomer('33', 'مشتری بدون سند'); console.log(` owner: ${owner.fullName} · other: ${other.fullName} · empty: ${empty.fullName}`); // ── 7. NO TOKEN ───────────────────────────────────────────────────────── step('7 — every route requires a session'); for (const path of ['customer/me/report', 'customer/me/report/detail', 'customer/me/report/balances']) { const r = await call('GET', path); chk(r.status === 401, `GET ${path} with no token -> ${r.status} (expected 401)`); } // ── 1. EMPTY STATE ────────────────────────────────────────────────────── step('1 — EMPTY STATE: a customer with no ledger entries'); await call('POST', 'customer/auth/request-otp', { username: empty.mobile }); const emptyLogin = await signIn(empty.mobile); chk(emptyLogin.ok, `the empty customer signs in -> ${emptyLogin.status}`); const emptyToken = emptyLogin.body?.token; const emptyReport = await call('GET', 'customer/me/report', undefined, emptyToken); chk(emptyReport.ok, `GET report -> ${emptyReport.status}`); chk(Array.isArray(emptyReport.body?.result) && emptyReport.body.result.length === 0, 'the statement is an empty list, not an error'); chk(emptyReport.body?.count === 0, `count is 0 (got ${emptyReport.body?.count})`); const emptyDetail = await call('GET', 'customer/me/report/detail', undefined, emptyToken); chk(emptyDetail.ok && emptyDetail.body?.result?.length === 0, `the detail tab is empty too -> ${emptyDetail.status}`); // ── BUILD A LEDGER ────────────────────────────────────────────────────── step('SETUP — a ledger for the owner: gold out, gold in, rial in, plus one UNCONFIRMED'); const mkEntry = async (data) => { const e = await prisma.ledgerEntry.create({ data: { customerId: owner.id, ...data } }); made.entries.push(e.id); return e; }; // +12.5 gold owed await mkEntry({ currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK', amount: '12.5', confirmed: true, confirmedAt: new Date(), note: 'سفارش اول', }); // -4.25 gold (a melted-gold repayment, with the document metadata) await mkEntry({ currency: 'GOLD_GRAM', direction: 'CREDIT', sourceType: 'REPAYMENT_MELTED_GOLD', amount: '4.25', confirmed: true, confirmedAt: new Date(), karatPerMille: 750, originalValue: '4.3', labName: 'آزمایشگاه مرکزی', hallmark: 'A-1001', note: 'طلای آبشده', }); // +1,000,000 rial owed await mkEntry({ currency: 'RIAL', direction: 'DEBIT', sourceType: 'ADJUSTMENT', amount: '1000000', confirmed: true, confirmedAt: new Date(), note: 'اصلاحیه ریالی', }); // An UNCONFIRMED entry that must NOT appear anywhere. const pending = await mkEntry({ currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK', amount: '99.999', confirmed: false, note: 'سند تاییدنشده', }); // The cached balances the rest of the app shows. await prisma.customer.update({ where: { id: owner.id }, data: { goldDebtGrams: '8.25', fiatDebtRial: '1000000' }, }); // One document for the OTHER customer, for the cross-read test. const foreign = await prisma.ledgerEntry.create({ data: { customerId: other.id, currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK', amount: '7', confirmed: true, confirmedAt: new Date(), note: 'سند مشتری دیگر', }, }); made.entries.push(foreign.id); // ── 2/3/4. MAIN FLOW ──────────────────────────────────────────────────── step('2 — MAIN FLOW: the statement'); await call('POST', 'customer/auth/request-otp', { username: owner.mobile }); const login = await signIn(owner.mobile); chk(login.ok, `the owner signs in -> ${login.status}`); const token = login.body?.token; const rep = await call('GET', 'customer/me/report', undefined, token); chk(rep.ok, `GET report -> ${rep.status}`); const rows = rep.body?.result ?? []; chk(rows.length === 3, `three CONFIRMED entries came back (got ${rows.length})`); // Newest first, as the reference displayed it. chk(rows[0]?.note === 'اصلاحیه ریالی', `newest first: top row is "${rows[0]?.note}"`); const gold = rows.find((r) => r.note === 'سفارش اول'); chk(gold?.geram_kh === '12.500', `a gold DEBIT lands in geram_kh (${gold?.geram_kh})`); chk(gold?.geram_v === '0.000', 'and not in geram_v'); const back = rows.find((r) => r.note === 'طلای آبشده'); chk(back?.geram_v === '4.250', `a gold CREDIT lands in geram_v (${back?.geram_v})`); const rialRow = rows.find((r) => r.note === 'اصلاحیه ریالی'); chk(rialRow?.rial_kh === '1000000.00', `a rial DEBIT lands in rial_kh (${rialRow?.rial_kh})`); step('4 — UNCONFIRMED entries are excluded'); chk(!rows.some((r) => r.note === 'سند تاییدنشده'), 'the unconfirmed 99.999g entry does NOT appear in the statement'); step('3 — the RUNNING BALANCE'); // Oldest first: +12.5, then -4.25 => 8.25. Rows come back newest-first. chk(gold?.tahesab_T === '12.500', `after the first entry the gold balance is 12.500 (got ${gold?.tahesab_T})`); chk(back?.tahesab_T === '8.250', `after the repayment it is 8.250 (got ${back?.tahesab_T})`); const cached = await prisma.customer.findUnique({ where: { id: owner.id } }); chk(String(Number(back.tahesab_T)) === String(Number(cached.goldDebtGrams)), `and it agrees with the cached Customer.goldDebtGrams (${cached.goldDebtGrams})`); chk(rialRow?.tahesab_P === '1000000.00', `the rial balance is 1000000.00 (got ${rialRow?.tahesab_P})`); chk(String(Number(rialRow.tahesab_P)) === String(Number(cached.fiatDebtRial)), `and it agrees with the cached Customer.fiatDebtRial (${cached.fiatDebtRial})`); step('8 — the balances endpoint agrees with the customer row'); const bal = await call('GET', 'customer/me/report/balances', undefined, token); chk(bal.ok, `GET balances -> ${bal.status}`); const goldBal = bal.body?.result?.find((r) => r.arz === 'gold'); const rialBal = bal.body?.result?.find((r) => r.arz === 'IRR'); chk(goldBal?.mandeh === '8.250', `gold balance ${goldBal?.mandeh}`); chk(rialBal?.mandeh === '1000000.00', `rial balance ${rialBal?.mandeh}`); // ── 5. DETAIL ─────────────────────────────────────────────────────────── step('5 — the detail tab and one document'); const det = await call('GET', 'customer/me/report/detail', undefined, token); chk(det.ok, `GET detail -> ${det.status}`); chk(det.body?.result?.length === 3, `three documents (got ${det.body?.result?.length})`); chk(!det.body?.result?.some((d) => d.note === 'سند تاییدنشده'), 'the unconfirmed entry is excluded here too'); const melted = det.body?.result?.find((d) => d.note === 'طلای آبشده'); chk(melted?.ayar === 750, `the melted-gold document carries its عیار (${melted?.ayar})`); chk(melted?.name_az === 'آزمایشگاه مرکزی', `and its lab name (${melted?.name_az})`); chk(melted?.sh_sharti === 'A-1001', `and its hallmark (${melted?.sh_sharti})`); chk(melted?.vazn === '4.300', `وزن خالص is the PRE-normalization weight, as a paper document states (${melted?.vazn})`); chk(melted?.nO_kar === null && melted?.jens_felez === null, 'fields the target cannot know are null, never invented (REP-01)'); const one = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, token); chk(one.ok, `GET one document -> ${one.status}`); chk(one.body?.result?.length === 1, `exactly one row came back (got ${one.body?.result?.length})`); chk(one.body?.result?.[0]?.factor_Code === back.factor_Code, 'and it is the one asked for'); // ── 6. SEC-16 ─────────────────────────────────────────────────────────── step("6 — SEC-16: another customer's document is NOT readable"); const stolen = await call('GET', `customer/me/report/${foreign.id}`, undefined, token); chk(stolen.ok, `the request itself succeeds -> ${stolen.status}`); chk(stolen.body?.result?.length === 0, "but it returns NOTHING — the id is only a filter on top of the token's scope"); // And the reverse direction, so the scope is not accidentally one-way. await call('POST', 'customer/auth/request-otp', { username: other.mobile }); const otherLogin = await signIn(other.mobile); const otherToken = otherLogin.body?.token; const reverse = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, otherToken); chk(reverse.body?.result?.length === 0, "and the other customer cannot read the owner's document either"); const otherRep = await call('GET', 'customer/me/report', undefined, otherToken); chk(otherRep.body?.result?.length === 1, `the other customer sees only their OWN one entry (got ${otherRep.body?.result?.length})`); // ── CLEANUP ───────────────────────────────────────────────────────────── step('CLEANUP'); await prisma.ledgerEntry.deleteMany({ where: { id: { in: made.entries } } }); await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } }); await prisma.customer.deleteMany({ where: { id: { in: made.customers } } }); console.log( ` deleted ${made.entries.length} ledger entr(ies) and ${made.customers.length} customer(s). ` + 'All were created by this run; no pre-existing data was touched.', ); void pending; console.log(`\n${'='.repeat(60)}`); console.log(`REPORT: passed ${pass} failed ${fail}`); if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); } console.log('='.repeat(60)); await prisma.$disconnect(); process.exit(fail ? 1 : 0); })().catch(async (e) => { console.error('\nABORTED:', e); await prisma.$disconnect(); process.exit(1); });