feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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: 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<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);
|
||||
});
|
||||
Reference in New Issue
Block a user