parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]

Ports "گزارش" end to end (D24), the last missing app page.

In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.

API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
  after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
  Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
  balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
  جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
  the row rather than rendering a fabricated value (REP-01)

SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.

Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
  modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
  the reference's hasMCode() gate has no target equivalent

Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 01:49:49 +03:30
co-authored by Claude Opus 5
parent 5ad1083aee
commit 3f549f1749
99 changed files with 2230 additions and 104 deletions
@@ -0,0 +1,59 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { CustomerReportService } from './customer-report.service';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
/**
* "گزارش" — the customer's own account statement.
*
* Legacy endpoints, all inside the `auth:api-customer` group
* (henza_server routes/api.php:108-110):
* GET tahesab/me -> TahesabController::mandeh
* GET fin_tahesab/table -> TahesabController::index
* GET fin_tahesab/my_detail/{n} -> TahesabController::my_detail
* GET fin_tahesab/detail/{id} -> TahesabController::detail
*
* Every one of those proxied an external accounting system keyed on
* `Customer.mcode`. These read the target's own ledger instead (option الف,
* D24) — no external call, no `mcode`, and the customer always comes from the
* token. `fin_tahesab/detail/{id}` took the factor code straight from the URL
* with **no ownership check at all** (`detail()` never looks at `auth()`), so
* any signed-in customer could read any other customer's document by guessing
* a code. Here the id is only ever a filter on top of a customerId scope, so
* someone else's id returns nothing. Recorded as SEC-16.
*/
@Controller('customer/me/report')
export class CustomerReportController {
constructor(private readonly report: CustomerReportService) {}
/** "کلیات حساب" — the running statement. */
@CustomerAuth()
@Get()
summary(@CurrentCustomer() me: AuthenticatedCustomer, @Query('page') page?: string) {
return this.report.summary(me.id, Number(page) || 1);
}
/** "جزئیات حساب" — the whole detail list (the second tab). */
@CustomerAuth()
@Get('detail')
detailList(@CurrentCustomer() me: AuthenticatedCustomer, @Query('page') page?: string) {
return this.report.detail(me.id, undefined, Number(page) || 1);
}
/** The current balances, for the statement header. */
@CustomerAuth()
@Get('balances')
balances(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.report.balances(me.id);
}
/**
* One document. Declared last so the literal routes above are matched first —
* Nest resolves in declaration order.
*/
@CustomerAuth()
@Get(':id')
detail(@Param('id') id: string, @CurrentCustomer() me: AuthenticatedCustomer) {
return this.report.detail(me.id, id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { CustomerReportController } from './customer-report.controller';
import { CustomerReportService } from './customer-report.service';
/** "گزارش" — the customer's own account statement, from the internal ledger (D24). */
@Module({
imports: [PrismaModule],
controllers: [CustomerReportController],
providers: [CustomerReportService],
})
export class CustomerReportModule {}
@@ -0,0 +1,244 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/**
* "گزارش" — 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 {
constructor(private readonly prisma: PrismaService) {}
/** 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) {
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 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) {
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: 'اصلاحیه',
};