2026-09-23 01:49:49 +03:30
|
|
|
/**
|
|
|
|
|
* "گزارش" — 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}`,
|
2026-10-07 20:20:27 +03:30
|
|
|
passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN', kind: 'APP_SHOPKEEPER', isActive: true,
|
2026-09-23 01:49:49 +03:30
|
|
|
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);
|
|
|
|
|
});
|