/** * 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= \ * 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: mobile, passwordHash: '' }, }); 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 === '' && 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 = 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); });