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:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
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.
@@ -30,7 +33,26 @@ import { PrismaService } from '../../prisma/prisma.service';
*/
@Injectable()
export class CustomerReportService {
constructor(private readonly prisma: PrismaService) {}
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;
@@ -51,6 +73,21 @@ export class CustomerReportService {
* 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
@@ -129,6 +166,22 @@ export class CustomerReportService {
* 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,
@@ -215,6 +268,22 @@ export class CustomerReportService {
* 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 },
@@ -242,3 +311,82 @@ const SOURCE_LABELS: Record<string, string> = {
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,
};
}