Files
tehran_taj/apps/api/src/modules/accounting/ledger.service.ts
T

333 lines
13 KiB
TypeScript
Raw Normal View History

2026-09-09 06:46:27 +03:30
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, OrderStatus, Prisma, PrismaClient } from '@prisma/client';
2026-09-09 06:46:27 +03:30
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];
2026-09-09 06:46:27 +03:30
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;
postedByAdminUserId?: 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,
postedByAdminUserId: input.postedByAdminUserId,
},
});
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(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId },
});
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.
2026-09-09 06:46:27 +03:30
*/
async syncOrderDebt(orderId: string, tx: Tx = this.prisma) {
2026-09-09 06:46:27 +03:30
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;
2026-09-09 06:46:27 +03:30
await this.postEntry(
{
customerId: order.customerId,
currency,
direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
2026-09-09 06:46:27 +03:30
sourceType: LedgerSourceType.ORDER_STOCK,
amount: diff.abs(),
2026-09-09 06:46:27 +03:30
orderId: order.id,
confirmed: true,
note: owes ? `${label} — order delivered (${order.status})` : `${label} reversed — order moved to ${order.status}`,
2026-09-09 06:46:27 +03:30
},
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;
postedByAdminUserId?: 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
postedByAdminUserId: params.postedByAdminUserId,
},
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; postedByAdminUserId?: 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,
postedByAdminUserId: params.postedByAdminUserId,
},
tx,
);
}
async postAdjustment(
params: {
customerId: string;
currency: LedgerCurrency;
direction: LedgerDirection;
amount: Prisma.Decimal.Value;
note: string;
postedByAdminUserId: 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,
postedByAdminUserId: params.postedByAdminUserId,
},
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);
}
}