Files
hedena_gold/apps/api/scripts/test-accounting-tahesab.ts
T
2026-10-07 20:20:27 +03:30

255 lines
13 KiB
TypeScript

/**
* Accounting integration (D27) — end to end against Tahesab's PUBLIC TEST
* server (https://api-test.tahesab.ir), on a THROWAWAY database.
*
* Runs the real services (Nest application context), not mocks:
* 1. config: provider switch, token sealed, generic `const` routes fenced off
* 2. connection test
* 3. F1 verified customer → pushed (searched by mobile first, so re-runs reuse
* the same test account) → linked
* 4. F1 customer pull (one group only) imports accounts into the app
* 5. F2 balance pull + the figure /customer/me now shows
* 6. F5 a product with no image is not in the catalog and cannot be ordered
* 7. F3 an order reaching DELIVERED_TO_AGENT queues a document; the worker
* posts it as TEMPORARY; the rows in Tahesab move exactly the order's
* gold (karat-with-charge rule)
* 8. F6 «گزارش» reads the Tahesab statement
* 9. F3 cancelling the order deletes the (still temporary) document
* 10. F4 item pull: Tahesab items become hidden products
*
* The document is created temporary and deleted again; the only lasting trace
* on the test server is one test account (mobile 09990001111) that later runs
* reuse.
*
* Usage (database name must contain "test" or "e2e" — it is written freely):
* DATABASE_URL=postgresql://…/goldbank_acc_e2e TAHESAB_TEST_TOKEN=<token from the docs page> \
* ACCOUNTING_SECRET_KEY=x npx ts-node --transpile-only scripts/test-accounting-tahesab.ts
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { OrderStatus, Prisma, PrismaClient, VerificationStep } from '@prisma/client';
process.env.ACCOUNTING_WORKER = 'off';
process.env.IAM_ENABLED = 'false';
const DB = process.env.DATABASE_URL ?? '';
const TOKEN = process.env.TAHESAB_TEST_TOKEN ?? '';
if (!/test|e2e/i.test(new URL(DB.replace(/^postgresql/, 'http')).pathname)) {
console.error('Refusing: DATABASE_URL must point to a throwaway database (name containing "test" or "e2e").');
process.exit(2);
}
if (!TOKEN) {
console.error('TAHESAB_TEST_TOKEN is required (the bearer token shown on https://tahesab.ir/APiDoc/).');
process.exit(2);
}
let pass = 0;
let fail = 0;
const chk = (c: unknown, m: string) => {
if (c) {
pass += 1;
console.log(` PASS ${m}`);
} else {
fail += 1;
console.log(` FAIL ${m}`);
}
};
const step = (t: string) => console.log(`\n${t}`);
async function main() {
const prisma0 = new PrismaClient();
const { runMigrations } = await import('../src/prisma/migrate');
await runMigrations(prisma0);
await prisma0.$disconnect();
const { AppModule } = await import('../src/app.module');
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const { PrismaService } = await import('../src/prisma/prisma.service');
const { AccountingConfigService } = await import('../src/modules/accounting-sync/accounting-config.service');
const { AccountingAdminService } = await import('../src/modules/accounting-sync/accounting-admin.service');
const { AccountingSyncService, factorCodeFor } = await import('../src/modules/accounting-sync/accounting-sync.service');
const { AccountingWorkerService } = await import('../src/modules/accounting-sync/accounting-worker.service');
const { AdminCustomersService } = await import('../src/modules/admin-customers/admin-customers.service');
const { AdminMiscService } = await import('../src/modules/admin-misc/admin-misc.service');
const { OrdersService } = await import('../src/modules/orders/orders.service');
const { CatalogService } = await import('../src/modules/catalog/catalog.service');
const { CustomersService } = await import('../src/modules/customers/customers.service');
const { CustomerReportService } = await import('../src/modules/customer-report/customer-report.service');
const prisma = app.get(PrismaService);
const config = app.get(AccountingConfigService);
const admin = app.get(AccountingAdminService);
const sync = app.get(AccountingSyncService);
const worker = app.get(AccountingWorkerService);
const customersAdmin = app.get(AdminCustomersService);
const misc = app.get(AdminMiscService);
const orders = app.get(OrdersService);
const catalog = app.get(CatalogService);
const customers = app.get(CustomersService);
const report = app.get(CustomerReportService);
// ── 1 ──
step('1. configuration');
await config.save({
provider: 'tahesab',
tahesab: { baseUrl: 'https://api-test.tahesab.ir', dbName: 'TahesabDB', token: TOKEN, timeoutMs: 30000 },
features: { customers: true, balances: true, report: true, products: true, documents: true },
mapping: { importCustomerGroups: [{ id: '0', name: 'مشتري' }], newCustomerGroup: { id: '2', name: 'بنکدار' }, karat: 750 },
});
const pub = await config.getPublic();
chk(pub.provider === 'tahesab', 'provider switch saved');
chk(pub.tahesab.tokenSet && pub.tahesab.tokenReadable, 'token stored and readable by the server');
chk(!JSON.stringify(pub).includes(TOKEN), 'the admin view never contains the token');
const row = await prisma.setting.findUnique({ where: { key: 'accounting.config' } });
chk(!JSON.stringify(row?.value).includes(TOKEN), 'the token is not stored in plain text');
const consts = await misc.listConsts();
chk(!consts.some((c: { key: string }) => c.key.startsWith('accounting.')), 'GET const does not list accounting.* rows');
// ── 2 ──
step('2. connection test');
const t = await admin.test('tahesab');
chk(t.ok, `health: ${JSON.stringify(t).slice(0, 160)}`);
// ── 3 ──
step('3. F1 push a verified customer');
const mobile = '09990001111';
const customer = await prisma.customer.upsert({
where: { mobile },
update: {},
create: { fullName: 'gold-bank smoke', firstName: 'gold-bank', lastName: 'smoke', mobile, nationalCode: null, passwordHash: '!OTP_ONLY_NO_PASSWORD_LOGIN' },
});
await prisma.customer.update({ where: { id: customer.id }, data: { verificationStep: VerificationStep.STEP_TWO_SUBMITTED } });
await customersAdmin.verify({ customer_id: customer.id, sts_verify_step_two: 2 });
const queued = await prisma.accountingOutbox.findFirst({ where: { kind: 'CUSTOMER_PUSH', entityId: customer.id } });
chk(queued?.status === 'PENDING', 'VERIFIED queues a CUSTOMER_PUSH in the same transaction');
await worker.tick('outbox');
const pushed = await prisma.accountingOutbox.findUnique({ where: { id: queued!.id } });
chk(pushed?.status === 'SENT', `push sent (${pushed?.status} ${pushed?.lastError ?? ''})`);
const link = await prisma.accountingLink.findFirst({ where: { entityType: 'CUSTOMER', entityId: customer.id } });
chk(!!link?.externalCode, `customer linked to Tahesab account ${link?.externalCode} (${link?.origin})`);
// ── 4 ──
step('4. F1 pull (GID 0 «مشتري» only)');
const before = await prisma.customer.count();
const pulled = await sync.pullCustomers((await config.active())!);
const after = await prisma.customer.count();
chk(pulled.scanned > 0, `scanned ${pulled.scanned} accounts, ${pulled.inScope} in scope`);
chk(after - before === pulled.imported, `imported ${pulled.imported}, linked ${pulled.linked}, issues ${pulled.issues}`);
const imported = await prisma.accountingLink.findFirst({ where: { origin: 'IMPORTED' } });
if (imported) {
const c = await prisma.customer.findUnique({ where: { id: imported.entityId } });
chk(c?.mobile?.startsWith('09') && c.passwordHash === '!OTP_ONLY_NO_PASSWORD_LOGIN' && c.goldCreditLimitGrams.eq(0), 'imported customer keyed by mobile, OTP-only, credit 0');
}
// ── 5 ──
step('5. F2 balances');
const b = await sync.pullBalances((await config.active())!);
chk(b.updated > 0, `balances updated for ${b.updated}/${b.linked} linked customers`);
const me = await customers.getOrThrow(customer.id);
chk(me.balanceSource === 'accounting', `/customer/me shows the accounting balance (${String(me.goldDebtGrams)} g)`);
// ── 6 ──
step('6. F5 no image, no app');
const bare = await prisma.product.create({
data: { title: 'gb-e2e-noimage', variants: { create: { title: 'v', rashWeightGrams: 1, defaultWagePercent: 10, qty: 5 } } },
include: { variants: true },
});
chk(!(await catalog.listProducts()).some((p) => p.id === bare.id), 'imageless product is not in the catalog');
let refused = false;
try {
await catalog.getVariantOrThrow(bare.variants[0].id);
} catch {
refused = true;
}
chk(refused, 'imageless product cannot be ordered');
// ── 7 ──
step('7. F3 order document');
const product = await prisma.product.create({
data: {
title: 'gb-e2e-انگشتر',
images: { create: { url: '/uploads/e2e.jpg' } },
variants: {
create: [
{ title: '4.2g', rashWeightGrams: '4.2', defaultWagePercent: 10, qty: 10 },
{ title: '10g', rashWeightGrams: '10', defaultWagePercent: 18.5, qty: 10 },
],
},
},
include: { variants: true },
});
chk((await catalog.listProducts()).some((p) => p.id === product.id), 'product with an image is in the catalog');
const order = await orders.createOrder(
{
items: [
{ productVariantId: product.variants[0].id, quantity: 2, wagePercent: 10 },
{ productVariantId: product.variants[1].id, quantity: 1, wagePercent: 18.5, flatRialCharge: 500000 },
],
} as never,
customer.id,
);
await orders.updateStatus(order.id, OrderStatus.DELIVERED_TO_AGENT);
const docJob = await prisma.accountingOutbox.findFirst({ where: { kind: 'ORDER_DOCUMENT', entityId: order.id } });
chk(docJob?.status === 'PENDING', 'delivery queued the document with the debt');
await worker.tick('outbox');
const docSent = await prisma.accountingOutbox.findUnique({ where: { id: docJob!.id } });
chk(docSent?.status === 'SENT', `document posted (${docSent?.status} ${docSent?.lastError ?? ''})`);
const a = (await config.active())!;
const factorCode = factorCodeFor(order.id);
// Whatever happens below, the test document must not stay on the shared server.
cleanup = async () => {
await a.provider.deleteDocument(a.settings, { factorCode, ref: null, customerCode: link!.externalCode }).catch(() => undefined);
};
chk(await a.provider.documentExists(a.settings, { factorCode, ref: null }), `Tahesab finds ${factorCode}`);
const st = await a.provider.getStatement(a.settings, { code: link!.externalCode, from: toJ(), to: toJ() });
const rows = st.filter((r) => r.documentRef === factorCode);
chk(rows.length === 2 && rows.every((r) => r.temporary), `2 temporary rows in Tahesab (${rows.length})`);
const moved = rows.reduce((s, r) => s.add(r.goldOut), new Prisma.Decimal(0));
const total = new Prisma.Decimal(order.totalGoldWeightGrams);
chk(moved.sub(total).abs().lte('0.001'), `rows move ${moved.toFixed(3)} g = order total ${total.toFixed(3)} g`);
// ── 8 ──
step('8. F6 report from Tahesab');
const sum = (await report.summary(customer.id, 1)) as { source?: string; result: { factor_Code: string }[] };
chk(sum.source === 'accounting', 'summary comes from the accounting software');
chk(sum.result.some((r) => r.factor_Code === factorCode), 'the new document is in the statement');
const other = (await report.detail(customer.id, 'not-mine-000000000000')) as { result: unknown[] };
chk(other.result.length === 0, 'SEC-16: an id outside the account matches nothing');
// ── 9 ──
step('9. F3 cancellation removes the temporary document');
await orders.updateStatus(order.id, OrderStatus.CANCELLED);
await worker.tick('outbox');
const del = await prisma.accountingOutbox.findFirst({ where: { kind: 'ORDER_DOCUMENT_DELETE', entityId: order.id } });
chk(del?.status === 'SENT', `delete job sent (${del?.status} ${del?.lastError ?? ''})`);
chk(!(await a.provider.documentExists(a.settings, { factorCode, ref: null })), 'document gone from Tahesab');
// ── 10 ──
step('10. F4 item pull');
const items = await sync.pullProducts(a);
chk(items.items > 0, `items ${items.items}: imported ${items.imported}, linked ${items.linked}, updated ${items.updated}`);
const itemLink = await prisma.accountingLink.findFirst({ where: { entityType: 'PRODUCT', origin: 'IMPORTED' } });
if (itemLink) {
chk(!(await catalog.listProducts()).some((p) => p.id === itemLink.entityId), 'an imported item stays hidden until it gets an image');
}
await cleanup();
await app.close();
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
}
let cleanup: () => Promise<void> = async () => undefined;
function toJ() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { toJalali } = require('../src/modules/accounting-sync/jalali');
return toJalali(new Date());
}
main().catch(async (err) => {
Logger.error(err);
await cleanup();
process.exit(1);
});