Files
tehran_taj/apps/api/scripts/test-report.cjs
T
s.esmaeiliandClaude Opus 5 3f549f1749 parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]
Ports "گزارش" end to end (D24), the last missing app page.

In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.

API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
  after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
  Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
  balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
  جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
  the row rather than rendering a fabricated value (REP-01)

SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.

Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
  modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
  the reference's hasMCode() gate has no target equivalent

Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:49:49 +03:30

276 lines
14 KiB
JavaScript

/**
* "گزارش" — 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);
});