First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
245 lines
9.8 KiB
JavaScript
245 lines
9.8 KiB
JavaScript
/**
|
|
* B2B admin WRITE paths (D19 step 2) — the money-touching operations the
|
|
* wholesale desk performs daily, which a read-only scan cannot cover:
|
|
*
|
|
* place an order for a customer from the admin panel
|
|
* record a payment document (cash) and confirm it
|
|
*
|
|
* Both move a customer's gold/rial debt, so each assertion checks the LEDGER,
|
|
* not just the HTTP status. Everything created here is removed again, and the
|
|
* customer's balances are compared before and after to prove nothing was left
|
|
* behind.
|
|
*
|
|
* Usage: node scripts/smoke-admin-b2b-writes.cjs [apiBaseUrl]
|
|
*/
|
|
const { PrismaClient } = require('@prisma/client');
|
|
const prisma = new PrismaClient();
|
|
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
|
|
|
|
let pass = 0;
|
|
let 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);
|
|
|
|
let H = {};
|
|
const call = async (method, p, body) => {
|
|
const res = await fetch(`${API}/${p}`, {
|
|
method,
|
|
headers: H,
|
|
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 createdOrders = [];
|
|
const createdEntries = [];
|
|
|
|
(async () => {
|
|
const login = await fetch(`${API}/admin/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
|
|
});
|
|
const auth = await login.json();
|
|
if (!auth.api_token) {
|
|
console.log('login failed');
|
|
process.exit(1);
|
|
}
|
|
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
|
|
|
|
// A WHOLESALE customer — retail is out of scope (D13).
|
|
const customer = await prisma.customer.findFirst({
|
|
where: { kind: { not: 'RETAIL' }, isActive: true, isArchived: false },
|
|
select: { id: true, fullName: true, goldDebtGrams: true, fiatDebtRial: true },
|
|
});
|
|
if (!customer) {
|
|
console.log('no wholesale customer available');
|
|
process.exit(1);
|
|
}
|
|
console.log(`Customer under test: ${customer.fullName}`);
|
|
console.log(` gold debt before: ${customer.goldDebtGrams} rial debt before: ${customer.fiatDebtRial}`);
|
|
|
|
const variant = await prisma.productVariant.findFirst({
|
|
where: { OR: [{ qty: { gt: 0 } }, { isPreOrder: true }] },
|
|
select: { id: true, title: true, rashWeightGrams: true, defaultWagePercent: true },
|
|
});
|
|
if (!variant) {
|
|
console.log('no orderable variant available');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── 1. ADMIN PLACES AN ORDER ────────────────────────────────────────────
|
|
step('1 — PLACE A WHOLESALE ORDER FROM THE ADMIN PANEL');
|
|
const wage = Number(variant.defaultWagePercent);
|
|
const order = await call('POST', 'admin/orders/create', {
|
|
customer_id: customer.id,
|
|
order_items: [{ vid: variant.id, qty: 2, percent: wage }],
|
|
note: 'سفارش آزمایشی تحویل',
|
|
});
|
|
chk(order.ok, `POST admin/orders/create -> ${order.status}` + (order.ok ? '' : ` ${order.raw.slice(0, 200)}`));
|
|
|
|
const orderId = order.body?.id ?? order.body?.order?.id;
|
|
if (orderId) createdOrders.push(orderId);
|
|
chk(Boolean(orderId), 'an order id came back');
|
|
|
|
if (orderId) {
|
|
const row = await prisma.order.findUnique({
|
|
where: { id: orderId },
|
|
include: { items: true },
|
|
});
|
|
chk(row?.customerId === customer.id, 'the order is attached to the chosen customer');
|
|
chk(row?.items.length === 1, 'the line item was saved');
|
|
chk(row?.items[0]?.quantity === 2, 'the quantity (2) was saved');
|
|
|
|
// The reference formula, per unit: rash * (1 + (wage - discount)/100)
|
|
const rash = Number(variant.rashWeightGrams);
|
|
const expectedPerUnit = rash * (1 + wage / 100);
|
|
const actualPerUnit = Number(row?.items[0]?.computedWeightGrams ?? 0);
|
|
chk(
|
|
Math.abs(actualPerUnit - expectedPerUnit) < 0.00005,
|
|
`making charge per unit: ${rash}g +${wage}% = ${expectedPerUnit.toFixed(4)} (got ${actualPerUnit})`,
|
|
);
|
|
chk(
|
|
Math.abs(Number(row?.totalGoldWeightGrams ?? 0) - expectedPerUnit * 2) < 0.00005,
|
|
`the order total is the per-unit weight x2 (${(expectedPerUnit * 2).toFixed(4)})`,
|
|
);
|
|
|
|
step('1b — the order appears in the wholesale grid');
|
|
const grid = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 50 });
|
|
chk(grid.ok, `POST admin/orders/wholesale/list -> ${grid.status}`);
|
|
chk(
|
|
(grid.body?.rows ?? []).some((r) => r.id === orderId),
|
|
'the new order is listed',
|
|
);
|
|
|
|
step('1c — its detail opens');
|
|
const detail = await call('GET', `admin/orders/${orderId}`);
|
|
chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`);
|
|
chk(Array.isArray(detail.body?.activity), 'the status timeline came back');
|
|
}
|
|
|
|
// ── 2. RECORD A PAYMENT DOCUMENT ────────────────────────────────────────
|
|
step('2 — RECORD A CASH PAYMENT DOCUMENT');
|
|
const before = await prisma.customer.findUnique({
|
|
where: { id: customer.id },
|
|
select: { goldDebtGrams: true, fiatDebtRial: true },
|
|
});
|
|
|
|
// type 4 = «پولی» (cash), which takes `price` + `price_per_gram`
|
|
// (addFinDoc.vue:33,104-115). Type 2 is «حواله» and takes a weight in
|
|
// `value` — sending cash fields with type 2 yields a zero amount and a 400.
|
|
const doc = await call('POST', 'admin/documents', {
|
|
type: 4,
|
|
customer_id: customer.id,
|
|
price: 10000000,
|
|
price_per_gram: 50000000,
|
|
note: 'پرداخت آزمایشی تحویل',
|
|
});
|
|
chk(doc.ok, `POST admin/documents -> ${doc.status}` + (doc.ok ? '' : ` ${doc.raw.slice(0, 200)}`));
|
|
const docId = doc.body?.id;
|
|
if (docId) createdEntries.push(docId);
|
|
chk(Boolean(docId), 'a document id came back');
|
|
|
|
if (docId) {
|
|
const entry = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
|
|
chk(Boolean(entry), 'a ledger entry was written');
|
|
chk(entry?.customerId === customer.id, 'it is attached to the right customer');
|
|
chk(entry?.confirmed === false, 'it starts UNCONFIRMED, as the reference does');
|
|
|
|
step('2b — it appears in the documents grid');
|
|
const dlist = await call('POST', 'admin/documents/list', { page: 1, limit: 50 });
|
|
chk(dlist.ok, `POST admin/documents/list -> ${dlist.status}`);
|
|
chk((dlist.body?.rows ?? []).some((r) => r.id === docId), 'the new document is listed');
|
|
|
|
step('2c — CONFIRM it, and the customer balance moves');
|
|
const conf = await call('POST', `admin/documents/${docId}/confirm`);
|
|
chk(conf.ok, `POST admin/documents/:id/confirm -> ${conf.status}`);
|
|
const afterConfirm = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
|
|
chk(afterConfirm?.confirmed === true, 'the entry is now confirmed');
|
|
|
|
const balAfter = await prisma.customer.findUnique({
|
|
where: { id: customer.id },
|
|
select: { goldDebtGrams: true, fiatDebtRial: true },
|
|
});
|
|
const moved =
|
|
String(balAfter?.goldDebtGrams) !== String(before?.goldDebtGrams) ||
|
|
String(balAfter?.fiatDebtRial) !== String(before?.fiatDebtRial);
|
|
chk(moved, `confirming moved the balance (gold ${before?.goldDebtGrams} -> ${balAfter?.goldDebtGrams})`);
|
|
|
|
step('2d — confirm is a TOGGLE, as the reference has it');
|
|
const unconf = await call('POST', `admin/documents/${docId}/confirm`);
|
|
chk(unconf.ok, `second confirm -> ${unconf.status}`);
|
|
const toggled = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
|
|
chk(toggled?.confirmed === false, 'it toggled back to unconfirmed');
|
|
|
|
const balBack = await prisma.customer.findUnique({
|
|
where: { id: customer.id },
|
|
select: { goldDebtGrams: true, fiatDebtRial: true },
|
|
});
|
|
chk(
|
|
String(balBack?.goldDebtGrams) === String(before?.goldDebtGrams) &&
|
|
String(balBack?.fiatDebtRial) === String(before?.fiatDebtRial),
|
|
'un-confirming restored the balance exactly',
|
|
);
|
|
}
|
|
|
|
// ── CLEANUP ─────────────────────────────────────────────────────────────
|
|
step('CLEANUP');
|
|
if (createdEntries.length) {
|
|
await prisma.ledgerEntry.deleteMany({ where: { id: { in: createdEntries } } });
|
|
}
|
|
if (createdOrders.length) {
|
|
await prisma.orderItem.deleteMany({ where: { orderId: { in: createdOrders } } });
|
|
await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: createdOrders } } });
|
|
await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: createdOrders } } });
|
|
await prisma.order.deleteMany({ where: { id: { in: createdOrders } } });
|
|
}
|
|
|
|
const finalBal = await prisma.customer.findUnique({
|
|
where: { id: customer.id },
|
|
select: { goldDebtGrams: true, fiatDebtRial: true },
|
|
});
|
|
chk(
|
|
String(finalBal?.goldDebtGrams) === String(customer.goldDebtGrams) &&
|
|
String(finalBal?.fiatDebtRial) === String(customer.fiatDebtRial),
|
|
`the customer's balances are exactly as they started ` +
|
|
`(gold ${finalBal?.goldDebtGrams}, rial ${finalBal?.fiatDebtRial})`,
|
|
);
|
|
console.log(
|
|
` deleted ${createdOrders.length} order(s) and ${createdEntries.length} ledger entry/entries ` +
|
|
'created by this run. No pre-existing data was removed.',
|
|
);
|
|
|
|
console.log(`\n${'='.repeat(60)}`);
|
|
console.log(`B2B WRITE SMOKE: 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);
|
|
});
|