import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { LedgerCurrency, LedgerDirection, LedgerSourceType, OrderStatus, Prisma, PrismaClient } from '@prisma/client'; import { GRAMS_PER_MESGHAL, STANDARD_KARAT_PER_MILLE } from '@gold-bank/shared-types'; import { PrismaService } from '../../prisma/prisma.service'; type Tx = Prisma.TransactionClient | PrismaClient; const ZERO = new Prisma.Decimal(0); /** Legacy `sts > 4`: delivered to the expert (5) or to the customer (6). */ const OWING_STATUSES: OrderStatus[] = [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER]; export interface PostEntryInput { customerId: string; currency: LedgerCurrency; direction: LedgerDirection; sourceType: LedgerSourceType; amount: Prisma.Decimal.Value; orderId?: string; karatPerMille?: number; pricePerGramRialAtEntry?: Prisma.Decimal.Value; note?: string; /** Order-delivery debt entries are self-confirming; repayments start pending. */ confirmed?: boolean; postedByAdminId?: string; } /** * The dual-currency accounting engine. LedgerEntry is append-only and is the * sole source of truth for customer debt; Customer.goldDebtGrams/fiatDebtRial * are denormalized caches kept in sync here and self-healed by reconcileBalance. */ @Injectable() export class LedgerService { constructor(private readonly prisma: PrismaService) {} /** Posts a ledger entry and, if confirmed, applies it to the cached customer balance. */ async postEntry(input: PostEntryInput, tx: Tx = this.prisma) { const amount = new Prisma.Decimal(input.amount); if (amount.lte(0)) { throw new BadRequestException('Ledger entry amount must be positive'); } const confirmed = input.confirmed ?? false; const entry = await tx.ledgerEntry.create({ data: { customerId: input.customerId, currency: input.currency, direction: input.direction, sourceType: input.sourceType, amount, orderId: input.orderId, karatPerMille: input.karatPerMille, pricePerGramRialAtEntry: input.pricePerGramRialAtEntry !== undefined ? new Prisma.Decimal(input.pricePerGramRialAtEntry) : undefined, note: input.note, confirmed, confirmedAt: confirmed ? new Date() : null, postedByAdminId: input.postedByAdminId, }, }); if (confirmed) { await this.applyToBalance(input.customerId, input.currency, input.direction, amount, tx); } return entry; } /** * Marks a pending repayment entry confirmed. Legacy's confirmed toggle * (Adm\CashController::toggleConfiremCash) is bidirectional and used for * post-hoc correction/rejection — legacy Cash rows are auto-confirmed * (confirmed=1) at creation, not created pending. This system intentionally * requires an explicit confirmation step before a repayment counts (see * postGoldRepayment for the full rationale) — that is a deliberate design * change from legacy, not a faithful mirror of it. */ async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) { const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } }); if (!entry) throw new NotFoundException('Ledger entry not found'); if (entry.confirmed) return entry; const updated = await tx.ledgerEntry.update({ where: { id: entryId }, data: { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId }, }); await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx); return updated; } private async applyToBalance( customerId: string, currency: LedgerCurrency, direction: LedgerDirection, amount: Prisma.Decimal, tx: Tx, ) { const signedDelta = direction === LedgerDirection.DEBIT ? amount : amount.neg(); await tx.customer.update({ where: { id: customerId }, data: currency === LedgerCurrency.GOLD_GRAM ? { goldDebtGrams: { increment: signedDelta } } : { fiatDebtRial: { increment: signedDelta } }, }); } /** * Brings an order's debt in line with its status — legacy's rule exactly. * * Legacy never "posted" order debt; it RECOMPUTED it * (CustomerController::updateRemain): Σ orders.input WHERE sts > 4, i.e. an * order owes its full weight (and its rial charge) while it is delivered — * to the expert (5) or to the customer (6) — and nothing otherwise. So: * - moving into a delivered status twice never counts twice (ACC-03 / * MON-04: the old guard on `deliveredAt` let a second DELIVERED_TO_AGENT * post the debt again); * - going straight to DELIVERED_TO_CUSTOMER still creates the debt (the * old trigger fired only on DELIVERED_TO_AGENT, so that order owed nothing); * - moving back below delivered, or cancelling, takes it off again. * * It compares what the order SHOULD owe with the confirmed ORDER_STOCK * entries already on it and posts only the difference, as a confirmed * DEBIT or CREDIT — so it is idempotent and nothing is ever edited or * deleted (the ledger stays append-only). Entries imported from legacy carry * their orderId, so imported orders reconcile the same way. */ async syncOrderDebt(orderId: string, tx: Tx = this.prisma) { const order = await tx.order.findUnique({ where: { id: orderId } }); if (!order) throw new NotFoundException('Order not found'); const owes = OWING_STATUSES.includes(order.status); const targets: [LedgerCurrency, Prisma.Decimal, string][] = [ [LedgerCurrency.GOLD_GRAM, owes ? new Prisma.Decimal(order.totalGoldWeightGrams) : ZERO, 'Gold-weight debt'], [LedgerCurrency.RIAL, owes ? new Prisma.Decimal(order.flatRialCharge) : ZERO, 'Flat Rial charge (e.g. stones)'], ]; for (const [currency, should, label] of targets) { const posted = await tx.ledgerEntry.groupBy({ by: ['direction'], where: { orderId, currency, sourceType: LedgerSourceType.ORDER_STOCK, confirmed: true }, _sum: { amount: true }, }); const sum = (d: LedgerDirection) => posted.find((p) => p.direction === d)?._sum.amount ?? ZERO; const current = new Prisma.Decimal(sum(LedgerDirection.DEBIT)).sub(sum(LedgerDirection.CREDIT)); const diff = should.sub(current); if (diff.isZero()) continue; await this.postEntry( { customerId: order.customerId, currency, direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT, sourceType: LedgerSourceType.ORDER_STOCK, amount: diff.abs(), orderId: order.id, confirmed: true, note: owes ? `${label} — order delivered (${order.status})` : `${label} reversed — order moved to ${order.status}`, }, tx, ); } } /** * Normalizes a physical repayment instrument into gold grams and posts a * pending (unconfirmed) CREDIT entry against the customer's gold debt. * Formulas preserved verbatim from legacy CashController::store. Note: * legacy auto-confirms (confirmed=1) every Cash row at creation — posting * this as pending instead is an intentional design change, not a mirror of * legacy's default. */ async postGoldRepayment( params: { customerId: string; instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'; rawValue: Prisma.Decimal.Value; karatPerMille?: number; pricePerGramRial?: Prisma.Decimal.Value; note?: string; postedByAdminId?: string; }, tx: Tx = this.prisma, ) { const raw = new Prisma.Decimal(params.rawValue); let goldGramsValue: Prisma.Decimal; let sourceType: LedgerSourceType; switch (params.instrument) { case 'MELTED_GOLD': { if (!params.karatPerMille) { throw new BadRequestException('karatPerMille is required for a melted-gold repayment'); } // legacy: value = original_value * ayar / 750 goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE); sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD; break; } case 'CASH': { if (!params.pricePerGramRial) { throw new BadRequestException('pricePerGramRial is required for a cash repayment'); } // legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim; // flagged in migration analysis as a formula worth stakeholder review. const pricePerGram = new Prisma.Decimal(params.pricePerGramRial); goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL)); sourceType = LedgerSourceType.REPAYMENT_CASH; break; } case 'TRANSFER': goldGramsValue = raw; sourceType = LedgerSourceType.REPAYMENT_TRANSFER; break; case 'CHEQUE': goldGramsValue = raw; sourceType = LedgerSourceType.REPAYMENT_CHEQUE; break; } return this.postEntry( { customerId: params.customerId, currency: LedgerCurrency.GOLD_GRAM, direction: LedgerDirection.CREDIT, sourceType, amount: goldGramsValue, karatPerMille: params.karatPerMille, pricePerGramRialAtEntry: params.pricePerGramRial, note: params.note, confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation postedByAdminId: params.postedByAdminId, }, tx, ); } /** * Direct Rial-debt repayment. Not present in the legacy system (its * rial_remain had no repayment offset at all — a gap flagged during * migration analysis) but supported here since the new ledger is symmetric. */ async postRialRepayment( params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminId?: string }, tx: Tx = this.prisma, ) { return this.postEntry( { customerId: params.customerId, currency: LedgerCurrency.RIAL, direction: LedgerDirection.CREDIT, sourceType: LedgerSourceType.REPAYMENT_CASH, amount: params.amount, note: params.note, confirmed: false, postedByAdminId: params.postedByAdminId, }, tx, ); } async postAdjustment( params: { customerId: string; currency: LedgerCurrency; direction: LedgerDirection; amount: Prisma.Decimal.Value; note: string; postedByAdminId: string; }, tx: Tx = this.prisma, ) { return this.postEntry( { customerId: params.customerId, currency: params.currency, direction: params.direction, sourceType: LedgerSourceType.ADJUSTMENT, amount: params.amount, note: params.note, confirmed: true, postedByAdminId: params.postedByAdminId, }, tx, ); } async getBalance(customerId: string) { const customer = await this.prisma.customer.findUnique({ where: { id: customerId }, select: { id: true, goldDebtGrams: true, fiatDebtRial: true, goldCreditLimitGrams: true }, }); if (!customer) throw new NotFoundException('Customer not found'); return { customerId: customer.id, goldDebtGrams: customer.goldDebtGrams, fiatDebtRial: customer.fiatDebtRial, goldCreditLimitGrams: customer.goldCreditLimitGrams, }; } /** * Recomputes cached balances from confirmed LedgerEntry rows and overwrites * the cache — a self-healing reconciliation, run periodically or on demand, * unlike legacy's recompute-on-every-write (Customer::updateRemain). */ async reconcileBalance(customerId: string) { const [goldDebits, goldCredits, rialDebits, rialCredits] = await Promise.all([ this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.DEBIT), this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.CREDIT), this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.DEBIT), this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.CREDIT), ]); return this.prisma.customer.update({ where: { id: customerId }, data: { goldDebtGrams: goldDebits.sub(goldCredits), fiatDebtRial: rialDebits.sub(rialCredits), }, }); } private async sumConfirmed(customerId: string, currency: LedgerCurrency, direction: LedgerDirection) { const result = await this.prisma.ledgerEntry.aggregate({ where: { customerId, currency, direction, confirmed: true }, _sum: { amount: true }, }); return result._sum.amount ?? new Prisma.Decimal(0); } }