Files
s.esmaeiliandClaude Opus 4.6 19d490e790 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>
2026-10-06 16:48:36 +03:30

393 lines
16 KiB
TypeScript

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<string, string> = {
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,
};
}