import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { TableService } from '../admin-table/table.service'; import { LedgerService } from '../accounting/ledger.service'; import { SmsService } from '../sms/sms.service'; import type { TableQueryDto } from '../admin-table/table-query.dto'; /** * Financial documents ("اسناد مالی") — legacy views/finical/*, served by * CashController (`POST cash/table`, `POST cash`, `PUT cash/{id}`, * `GET cash/{id}`, `DELETE cash/{id}`). * * A legacy "cash" row IS a ledger entry: a customer repayment recorded as melted * gold, a transfer, a cheque or cash. The four legacy `type` codes map onto the * existing LedgerSourceType enum: * * 1 آب شده -> REPAYMENT_MELTED_GOLD (karat-normalized: value * ayar / 750) * 2 حواله -> REPAYMENT_TRANSFER * 3 چک -> REPAYMENT_CHEQUE * 4 پولی -> REPAYMENT_CASH (normalized via the gold price per gram) * * Posting delegates to LedgerService, which owns the karat and price-per-gram * conversions and the confirm/balance rules. Re-implementing them here would let * the admin path drift from the customer-facing one. */ const TYPE_BY_CODE: Record = { 1: LedgerSourceType.REPAYMENT_MELTED_GOLD, 2: LedgerSourceType.REPAYMENT_TRANSFER, 3: LedgerSourceType.REPAYMENT_CHEQUE, 4: LedgerSourceType.REPAYMENT_CASH, }; /** The instrument name LedgerService.postGoldRepayment expects. */ const INSTRUMENT_BY_TYPE: Record = { REPAYMENT_MELTED_GOLD: 'MELTED_GOLD', REPAYMENT_TRANSFER: 'TRANSFER', REPAYMENT_CHEQUE: 'CHEQUE', REPAYMENT_CASH: 'CASH', }; const CODE_BY_TYPE: Partial> = { REPAYMENT_MELTED_GOLD: 1, REPAYMENT_TRANSFER: 2, REPAYMENT_CHEQUE: 3, REPAYMENT_CASH: 4, }; const TYPE_LABELS: Record = { ORDER_STOCK: 'سفارش', ORDER_PRE_ORDER: 'سفارش ساختنی', REPAYMENT_MELTED_GOLD: 'آب شده', REPAYMENT_TRANSFER: 'حواله', REPAYMENT_CHEQUE: 'چک', REPAYMENT_CASH: 'پولی', WALLET_BONUS: 'هدیه کیف پول', ADJUSTMENT: 'اصلاح', }; @Injectable() export class AdminDocumentsService { constructor( private readonly prisma: PrismaService, private readonly table: TableService, private readonly ledger: LedgerService, private readonly sms: SmsService, ) {} private static readonly ALLOWED = [ 'id', 'sourceType', 'currency', 'direction', 'amount', 'confirmed', 'createdAt', 'karatPerMille', 'labName', 'customer.fullName', // legacy filter keys 'type_str', 'ayar', 'lab', 'caj', ]; private static readonly FIELD_ALIASES: Record = { type_str: 'sourceType', ayar: 'karatPerMille', lab: 'labName', caj: 'createdAt', uaj: 'createdAt', 'customer.name': 'customer.fullName', value: 'amount', price: 'priceRial', original_value: 'originalValue', account_name: 'accountName', bank_name: 'bankName', to: 'transferTo', recive_date: 'receivedAt', price_per_gram: 'pricePerGramRialAtEntry', ang: 'hallmark', }; /** Legacy `POST cash/table` — the documents grid. */ async list(query: TableQueryDto) { this.table.assertNotExport(query); const mapField = (f?: string) => (f ? (AdminDocumentsService.FIELD_ALIASES[f] ?? f) : f); const aliased: TableQueryDto = { ...query, filters: (query.filters ?? []).map((f) => { const mapped = { ...f, field: mapField(f.field) }; if (mapped.field === 'sourceType' && mapped.value !== undefined) { mapped.value = TYPE_BY_CODE[Number(mapped.value)] ?? mapped.value; } if (mapped.field === 'confirmed' && mapped.value !== undefined) { mapped.value = String(mapped.value) === '1'; } return mapped; }), order: query.order ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` : undefined, }; const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { searchable: ['note', 'customer.fullName', 'labName'], allowed: AdminDocumentsService.ALLOWED, defaultOrderBy: { createdAt: 'desc' }, }); // The grid is the REPAYMENT document list; order-generated debt entries are // not documents an operator files, so they are excluded as legacy's separate // `cash` table naturally did. const scoped: Prisma.LedgerEntryWhereInput = { AND: [ where as Prisma.LedgerEntryWhereInput, { sourceType: { in: Object.values(TYPE_BY_CODE) } }, ], }; const [rows, total] = await Promise.all([ this.prisma.ledgerEntry.findMany({ where: scoped, orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput, skip, take, include: { customer: true, postedByAdminUser: true }, }), this.prisma.ledgerEntry.count({ where: scoped }), ]); return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); } /** Legacy field names preserved so the ported table model binds unchanged. */ private toRow( e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>, ) { return { id: e.id, type: CODE_BY_TYPE[e.sourceType] ?? null, type_str: TYPE_LABELS[e.sourceType], sourceType: e.sourceType, currency: e.currency, direction: e.direction, // legacy `value` is the normalized final weight, `original_value` the raw one value: e.amount, original_value: e.originalValue, price: e.priceRial, price_per_gram: e.pricePerGramRialAtEntry, ayar: e.karatPerMille, lab: e.labName, ang: e.hallmark, account_name: e.accountName, bank_name: e.bankName, to: e.transferTo, recive_date: e.receivedAt, note: e.note, // legacy grid renders `confirmed` through a slot using 0/1 confirmed: e.confirmed ? 1 : 0, confirmedAt: e.confirmedAt, customer: { id: e.customer.id, name: e.customer.fullName, // D0 — real name parts fname: e.customer.firstName ?? e.customer.fullName, lname: e.customer.lastName ?? '', mobile: e.customer.mobile, }, posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null, caj: e.createdAt, uaj: e.createdAt, }; } /** Legacy `GET cash/{id}`. */ async findOne(id: string) { const e = await this.prisma.ledgerEntry.findUnique({ where: { id }, include: { customer: true, postedByAdminUser: true }, }); if (!e) throw new NotFoundException('سند یافت نشد'); return this.toRow(e); } /** * Legacy `POST cash` — file a repayment document. * * The gram/karat/price conversions are LedgerService's job; this method only * translates the legacy form shape and attaches the instrument metadata. */ async create(data: Record, actorAdminUserId: string) { const typeCode = Number(data.type ?? 2); const sourceType = TYPE_BY_CODE[typeCode]; if (!sourceType) throw new BadRequestException('نوع سند نامعتبر است'); const customerId = String(data.customer_id ?? ''); if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است'); // LedgerService takes the legacy instrument name and the RAW figure, then // applies the karat / price-per-gram conversion itself. const instrument = INSTRUMENT_BY_TYPE[sourceType]; // Which field carries the raw figure depends on the instrument, exactly as // the legacy form did: cash posts a Rial price, melted gold posts the // pre-normalization weight, transfer/cheque post grams directly. const rawValue = instrument === 'CASH' ? String(data.price ?? 0) : instrument === 'MELTED_GOLD' ? String(data.original_value ?? data.value ?? 0) : String(data.value ?? 0); const entry = await this.ledger.postGoldRepayment({ customerId, instrument, rawValue, karatPerMille: data.ayar ? Number(data.ayar) : undefined, pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined, note: (data.note as string) ?? undefined, postedByAdminUserId: actorAdminUserId, }); // Attach the instrument metadata the ledger service does not model. await this.prisma.ledgerEntry.update({ where: { id: entry.id }, data: this.metadataPatch(data), }); return this.findOne(entry.id); } private metadataPatch(data: Record): Prisma.LedgerEntryUpdateInput { const patch: Prisma.LedgerEntryUpdateInput = {}; if (data.lab !== undefined) patch.labName = (data.lab as string) || null; if (data.ang !== undefined) patch.hallmark = (data.ang as string) || null; if (data.account_name !== undefined) patch.accountName = (data.account_name as string) || null; if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null; if (data.to !== undefined) patch.transferTo = (data.to as string) || null; if (data.original_value !== undefined && data.original_value !== null) { patch.originalValue = new Prisma.Decimal(String(data.original_value)); } if (data.price !== undefined && data.price !== null) { patch.priceRial = new Prisma.Decimal(String(data.price)); } if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date)); return patch; } /** * Legacy `PUT cash/{id}` — edit a document. * * Only metadata and the note are editable. Changing the AMOUNT of a confirmed * entry would silently rewrite a customer's debt, so that is refused: the * correct accounting action is a reversing adjustment, not an edit. Legacy * allowed it. Recorded in docs/ADMIN-PORT-PLAN.md. */ async update(id: string, data: Record) { const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('سند یافت نشد'); const amountChanged = data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount); if (existing.confirmed && amountChanged) { throw new BadRequestException( 'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.', ); } const patch = this.metadataPatch(data); if (data.note !== undefined) patch.note = (data.note as string) || null; if (!existing.confirmed && amountChanged) { patch.amount = new Prisma.Decimal(String(data.value)); } await this.prisma.ledgerEntry.update({ where: { id }, data: patch }); return this.findOne(id); } /** * Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid * shows a tick on an unconfirmed row and a cross on a confirmed one, both * calling this same endpoint. * * Confirming delegates to LedgerService so the customer balance moves through * the one code path that owns it. Un-confirming reverses that same amount * rather than editing the balance directly, so the two stay consistent. */ async toggleConfirm(id: string, adminUserId: string) { const e = await this.prisma.ledgerEntry.findUnique({ where: { id } }); if (!e) throw new NotFoundException('سند یافت نشد'); if (!e.confirmed) { await this.ledger.confirmEntry(id, adminUserId); this.notifyDocumentConfirm(e.customerId, true); return this.findOne(id); } // Un-confirm: undo this entry's effect on the cached balance, then clear the // flag. A repayment is a CREDIT against gold debt, so reversing it adds the // amount back. await this.prisma.$transaction(async (tx) => { const delta = e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated(); const field = e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial'; await tx.customer.update({ where: { id: e.customerId }, data: { [field]: { increment: delta } }, }); await tx.ledgerEntry.update({ where: { id }, data: { confirmed: false, confirmedAt: null }, }); }); this.notifyDocumentConfirm(e.customerId, false); return this.findOne(id); } // Legacy CashController::toggleConfiremCash sent an SMS on both outcomes. private notifyDocumentConfirm(customerId: string, confirmed: boolean) { const text = confirmed ? 'هنزا گلد\nسند مالی شما توسط مدیر سیستم تایید شد.' : 'هنزا گلد\nسند مالی شما توسط مدیر سیستم رد شد.'; this.prisma.customer .findUnique({ where: { id: customerId }, select: { mobile: true } }) .then((c) => { if (c) this.sms.send(c.mobile, text); }) .catch(() => {}); } /** * Legacy `DELETE cash/{id}`. * * A confirmed entry has already moved the customer's balance; deleting it * would leave that balance overstated with no trace. Refused — legacy allowed * it. Unconfirmed entries never touched a balance, so they delete cleanly. */ async remove(id: string) { const e = await this.prisma.ledgerEntry.findUnique({ where: { id } }); if (!e) throw new NotFoundException('سند یافت نشد'); if (e.confirmed) { throw new BadRequestException( 'حذف سند تاییدشده ممکن نیست، چون در تراز مشتری اعمال شده است.', ); } await this.prisma.ledgerEntry.delete({ where: { id } }); return { success: true }; } typeOptions() { return Object.entries(TYPE_BY_CODE).map(([code, sourceType]) => ({ value: Number(code), title: TYPE_LABELS[sourceType], })); } // ── Wallet ───────────────────────────────────────────────────────── /** Legacy `POST wallet` grid — the list of granted wallet bonuses. */ async listWallet(query: TableQueryDto) { this.table.assertNotExport(query); const { where, orderBy, skip, take, page, limit } = this.table.build(query, { searchable: ['customer.fullName'], allowed: ['id', 'goldGrams', 'grantedAt', 'expiresAt', 'isActive', 'customer.fullName'], defaultOrderBy: { grantedAt: 'desc' }, }); const [rows, total] = await Promise.all([ this.prisma.walletBonus.findMany({ where: where as Prisma.WalletBonusWhereInput, orderBy: orderBy as Prisma.WalletBonusOrderByWithRelationInput, skip, take, include: { customer: true }, }), this.prisma.walletBonus.count({ where: where as Prisma.WalletBonusWhereInput }), ]); return this.table.respond( rows.map((w) => ({ id: w.id, value: w.goldGrams, grams: w.goldGrams, is_active: w.isActive, consumed: Boolean(w.consumedAt), caj: w.grantedAt, expires_at: w.expiresAt, customer: { id: w.customer.id, name: w.customer.fullName, mobile: w.customer.mobile, }, })), total, query, page, limit, ); } /** * Legacy `GET /wallet/config` and `/wallet/birthday/config`, plus their POST * counterparts — the wallet settings screen (views/manage/wallet.vue). * Stored in the Setting key/value table that replaces legacy `config_static`. */ async getSetting(key: string) { const row = await this.prisma.setting.findUnique({ where: { key } }); return row?.value ?? {}; } async setSetting(key: string, value: Record) { const row = await this.prisma.setting.upsert({ where: { key }, update: { value: value as Prisma.InputJsonValue }, create: { key, value: value as Prisma.InputJsonValue }, }); return row.value; } /** The customer picker used by the document form — legacy `GET query/customer`. */ async queryCustomers() { const rows = await this.prisma.customer.findMany({ where: { isActive: true }, orderBy: { fullName: 'asc' }, select: { id: true, fullName: true, firstName: true, lastName: true, mobile: true }, }); // D0 — real name parts. return rows.map((c) => ({ id: c.id, name: c.fullName, fname: c.firstName ?? c.fullName, lname: c.lastName ?? '', mobile: c.mobile, })); } }