255 lines
13 KiB
TypeScript
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);
|
|
});
|