import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { AccountingSyncService } from '../accounting-sync/accounting-sync.service'; import { jalaliToGregorian, parseJalali } from '../accounting-sync/jalali'; import type { StatementRow } from '../accounting-sync/providers'; /** * "گزارش" — the customer's account statement. * * ── Why this is built on the internal ledger ─────────────────────────────── * * In the reference this whole feature is a PROXY. `Customer\TahesabController` * (henza_server app/Http/Controllers/Customer/TahesabController.php) forwards * every call to an external accounting system — `hesab.henzagold.com` and a * bare IP at `31.7.68.147:3030` — with `Http::withoutVerifying()` (TLS * verification off), a bearer token hardcoded in the source, and a URL-embedded * `sfx` secret. It computes nothing: the PWA renders whatever that system * returns, keyed on `Customer.mcode`, its id in that other system. * * The target has no such system and no `mcode`. The owner chose option الف: * build the reference's UI and flow over OUR OWN ledger, with no external * connection. So this service reproduces the statement's SHAPE — the same field * names the PWA binds to (`henza_pwa/src/type/accounting.ts`) — from * `LedgerEntry`, which is the target's system of record for the same facts. * * Nothing here is invented: every figure is computed from confirmed ledger * entries. Where the external system carried a column the target has no * equivalent for (ارز/foreign currency, وزن پلاستیک, and the internal * bookkeeping ids `gid`/`rec`/`reg`), the field is present and ZERO/null rather * than fabricated, so the UI binds without pretending to know something it does * not. Recorded as REP-01 in ISSUES.md. */ @Injectable() export class CustomerReportService { private readonly logger = new Logger(CustomerReportService.name); constructor( private readonly prisma: PrismaService, private readonly accounting: AccountingSyncService, ) {} /** * The accounting statement, or null to fall back to the internal ledger — * when the integration is off, the customer is not linked, or the software * cannot be reached (the report must still open). */ private async accountingStatement(customerId: string) { try { return await this.accounting.customerStatement(customerId); } catch (err) { this.logger.warn(`Accounting statement unavailable for ${customerId}, using the ledger: ${(err as Error).message}`); return null; } } /** The reference's page size (report/index.vue:96). */ private static readonly LIMIT = 50; /** * "کلیات حساب" — one row per ledger entry, oldest first, each carrying the * running balance AFTER it. * * The running balance is why this cannot be a plain paginated query: a row's * `tahesab_T` depends on every entry before it. Entries are therefore folded * in chronological order from the beginning of the account, and the requested * page is sliced out afterwards. At the row counts this app deals with * (one customer's own documents) that is cheap; if a customer ever * accumulates enough entries for it to matter, the fold should move into a * stored running total. Noted in ISSUES.md as REP-02. * * The reference's own list arrives oldest-first and is then `.reverse()`d for * display (report/index.vue:191), so the app shows newest first. Same here. */ async summary(customerId: string, page = 1) { // D27 — from the accounting software when the integration covers this // customer, so the statement agrees with the balance they are shown. const statement = await this.accountingStatement(customerId); if (statement) { const rows = statementToSummary(statement.rows).reverse(); const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT; return { result: rows.slice(start, start + CustomerReportService.LIMIT), count: rows.length, page: Math.max(1, page), limit: CustomerReportService.LIMIT, source: 'accounting', }; } const entries = await this.prisma.ledgerEntry.findMany({ // Only confirmed entries move a balance — the same rule the cached // Customer.goldDebtGrams / fiatDebtRial follow, so the statement's final // balance agrees with the figure shown everywhere else in the app. where: { customerId, confirmed: true }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], include: { order: { select: { orderNumber: true } } }, }); let goldRunning = new Prisma.Decimal(0); let rialRunning = new Prisma.Decimal(0); const rows = entries.map((e, index) => { const amount = new Prisma.Decimal(e.amount); const isGold = e.currency === 'GOLD_GRAM'; // DEBIT increases what the customer owes; CREDIT reduces it. The same // convention the ledger itself documents (schema.prisma:94-97). const signed = e.direction === 'DEBIT' ? amount : amount.negated(); if (isGold) goldRunning = goldRunning.plus(signed); else rialRunning = rialRunning.plus(signed); return { id: index + 1, // The reference keyed the detail lookup on factor_Code; here it is the // entry's own id, so the detail call needs nothing else from the client. factor_Code: e.id, sh_factor: e.order?.orderNumber ?? e.id.slice(-8), tarikh: e.createdAt.toISOString(), // Gold in / out, as separate positive columns — the reference showed // them on two lines (Accounting.vue:21-22). geram_v: isGold && e.direction === 'CREDIT' ? amount.toFixed(3) : '0.000', geram_kh: isGold && e.direction === 'DEBIT' ? amount.toFixed(3) : '0.000', rial_v: !isGold && e.direction === 'CREDIT' ? amount.toFixed(2) : '0.00', rial_kh: !isGold && e.direction === 'DEBIT' ? amount.toFixed(2) : '0.00', // Running balances. The reference colours these by sign // (Accounting.vue:29, :46), so the sign must be preserved. tahesab_T: goldRunning.toFixed(3), tahesab_P: rialRunning.toFixed(2), // Present so the UI binds, but the target has no foreign-currency // ledger — always zero rather than invented. REP-01. rial_V2: '0.00', rial_Kh2: '0.00', tahesabP2: '0.00', sourceType: e.sourceType, note: e.note, }; }); // Newest first, as the reference displayed it. rows.reverse(); const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT; return { result: rows.slice(start, start + CustomerReportService.LIMIT), count: rows.length, page: Math.max(1, page), limit: CustomerReportService.LIMIT, }; } /** * "جزئیات حساب" — the per-document detail. * * With no id, this is the whole list the second tab renders (the reference's * `my_detail`); with one, it is the single document behind the «جزئیات» link * (its `GetHesabDetailByFactorCode`). * * Scoped on customerId in the WHERE clause, so an id belonging to someone * else simply returns nothing — ids stay unenumerable, and no id is trusted * from the client beyond being a filter. */ async detail(customerId: string, entryId?: string, page = 1) { const statement = await this.accountingStatement(customerId); if (statement) { // SEC-16 holds: the rows are already limited to this customer's own // account (AccountingSyncService.customerStatement), and the id is only a // filter over them — another customer's document id matches nothing. let rows = statement.rows.map(statementToDetail).reverse(); if (entryId) rows = rows.filter((r) => r.factor_Code === entryId); const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT; return { result: entryId ? rows : rows.slice(start, start + CustomerReportService.LIMIT), page: Math.max(1, page), limit: CustomerReportService.LIMIT, source: 'accounting', }; } const where: Prisma.LedgerEntryWhereInput = { customerId, confirmed: true, ...(entryId ? { id: entryId } : {}), }; const entries = await this.prisma.ledgerEntry.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], ...(entryId ? {} : { skip: (Math.max(1, page) - 1) * CustomerReportService.LIMIT, take: CustomerReportService.LIMIT, }), include: { order: { select: { orderNumber: true, totalGoldWeightGrams: true, wageWeightGrams: true, }, }, }, }); const result = entries.map((e) => { const amount = new Prisma.Decimal(e.amount); const isGold = e.currency === 'GOLD_GRAM'; return { factor_Code: e.id, sh_factor: e.order?.orderNumber ?? e.id.slice(-8), tarikh: e.createdAt.toISOString(), /// Net weight. For a melted-gold repayment the ledger keeps the weight /// as handed over, BEFORE karat normalization (schema.prisma:685), which /// is what a paper document states; `amount` is the normalized figure. vazn: isGold ? (e.originalValue ?? amount).toFixed(3) : '0.000', /// Purity, for the documents that carry one. ayar: e.karatPerMille ?? null, no: SOURCE_LABELS[e.sourceType] ?? e.sourceType, /// Hallmark and lab — the target ledger carries both, from the same /// legacy `ang` / `lab` columns the reference's document form had. sh_sharti: e.hallmark, name_az: e.labName, /// Gold in / out for this document, normalized to 750 as the reference's /// tabdil750 columns were. tabdil750_v: isGold && e.direction === 'CREDIT' ? amount.toFixed(3) : '0.000', tabdil750_kh: isGold && e.direction === 'DEBIT' ? amount.toFixed(3) : '0.000', /// Unit price at the time, for the documents that have one. fi: e.pricePerGramRialAtEntry ? e.pricePerGramRialAtEntry.toFixed(2) : null, fiF: e.priceRial ? e.priceRial.toFixed(2) : null, /// The making charge the order carried, where this entry came from one. Darsad_Talayee: e.order?.wageWeightGrams ? e.order.wageWeightGrams.toFixed(3) : null, accountName: e.accountName, bankName: e.bankName, transferTo: e.transferTo, receivedAt: e.receivedAt ? e.receivedAt.toISOString() : null, note: e.note, /// No equivalent in the target — shown as null, never invented. REP-01. nO_kar: null, jens_felez: null, Darsad: null, maliatDarsad: null, }; }); return { result, page: Math.max(1, page), limit: CustomerReportService.LIMIT }; } /** * "مانده" — the current balances. * * Ports the shape of TahesabController::mandeh, which answered a `result` * array of `{arz, mandeh}`. The reference multiplied the external system's * figures by -1 to flip its sign convention; here the cached customer * balances are already in the app's own convention (positive = owes), so no * flip is applied and the figure matches every other screen. */ async balances(customerId: string) { // D27 — the accounting software's balance when the integration covers // this customer; the same figure /customer/me shows. const accounting = await this.accounting.customerBalance(customerId); if (accounting) { return { status: 200, message: '', source: 'accounting', at: accounting.at, result: [ { arz: 'IRR', mandeh: accounting.rial.toFixed(2) }, { arz: 'gold', mandeh: accounting.gold.toFixed(3) }, ], }; } const c = await this.prisma.customer.findUnique({ where: { id: customerId }, select: { goldDebtGrams: true, fiatDebtRial: true }, }); return { status: 200, message: '', result: [ { arz: 'IRR', mandeh: (c?.fiatDebtRial ?? new Prisma.Decimal(0)).toFixed(2) }, { arz: 'gold', mandeh: (c?.goldDebtGrams ?? new Prisma.Decimal(0)).toFixed(3) }, ], }; } } /** The reference's `no` column held a document-type label. */ const SOURCE_LABELS: Record = { ORDER_STOCK: 'سفارش', ORDER_PRE_ORDER: 'پیش‌سفارش', REPAYMENT_MELTED_GOLD: 'طلای آبشده', REPAYMENT_TRANSFER: 'حواله', REPAYMENT_CHEQUE: 'چک', REPAYMENT_CASH: 'نقدی', WALLET_BONUS: 'هدیه', ADJUSTMENT: 'اصلاحیه', }; // ── D27: accounting statement → the report's existing shape ───────────── // // The apps bind the reference's field names (henza_pwa/src/type/accounting.ts) // and format `tarikh` as an ISO instant, so the accounting rows are mapped onto // exactly what the ledger-based report already returns. /** `1405/07/12` + `20:20:41` → ISO in Tehran time, which the apps' jdate() reads. */ function isoFromJalali(date: string, time: string | null): string { const j = parseJalali(date); if (!j) return date; const g = jalaliToGregorian(j.jy, j.jm, j.jd); const pad = (n: number) => String(n).padStart(2, '0'); const t = time && /^\d{1,2}:\d{2}(:\d{2})?$/.test(time) ? (time.length === 5 ? `${time}:00` : time) : '12:00:00'; return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}T${t.padStart(8, '0')}+03:30`; } const fixed = (d: Prisma.Decimal | null, places: number) => (d ?? new Prisma.Decimal(0)).toFixed(places); /** * Oldest first, with a running balance on every row. The software's own * running balance wins where it reports one; a row without one (a temporary * Tahesab document, which moves nothing yet) carries the previous figure. */ function statementToSummary(rows: StatementRow[]) { let gold = new Prisma.Decimal(0); let rial = new Prisma.Decimal(0); return rows.map((r, index) => { if (r.goldBalance) gold = r.goldBalance; else if (!r.temporary) gold = gold.add(r.goldOut).sub(r.goldIn); if (r.rialBalance) rial = r.rialBalance; else if (!r.temporary) rial = rial.add(r.rialOut).sub(r.rialIn); return { id: index + 1, factor_Code: r.documentRef || r.key, sh_factor: r.documentNo, tarikh: isoFromJalali(r.date, r.time), geram_v: fixed(r.goldIn, 3), geram_kh: fixed(r.goldOut, 3), rial_v: fixed(r.rialIn, 2), rial_kh: fixed(r.rialOut, 2), tahesab_T: gold.toFixed(3), tahesab_P: rial.toFixed(2), rial_V2: '0.00', rial_Kh2: '0.00', tahesabP2: '0.00', sourceType: 'ACCOUNTING', note: [r.title, r.description, r.temporary ? '(موقت — هنوز در مانده اثر ندارد)' : null].filter(Boolean).join(' — '), }; }); } function statementToDetail(r: StatementRow) { return { factor_Code: r.documentRef || r.key, sh_factor: r.documentNo, tarikh: isoFromJalali(r.date, r.time), vazn: fixed(r.weight, 3), ayar: r.karat, no: r.title, sh_sharti: null, name_az: null, tabdil750_v: fixed(r.goldIn, 3), tabdil750_kh: fixed(r.goldOut, 3), fi: r.unitPrice ? r.unitPrice.toFixed(2) : null, fiF: r.rialIn.gt(0) ? r.rialIn.toFixed(2) : r.rialOut.gt(0) ? r.rialOut.toFixed(2) : null, Darsad_Talayee: r.wagePercent ? r.wagePercent.toString() : null, accountName: null, bankName: null, transferTo: null, receivedAt: null, note: [r.description, r.temporary ? 'سند موقت' : null].filter(Boolean).join(' — ') || null, // REP-01: the accounting software does carry these. nO_kar: r.itemName, jens_felez: r.metal, Darsad: null, maliatDarsad: null, }; }