Files
tehran_taj/apps/api/src/modules/accounting/ledger.service.ts
T
mahmoodandClaude Sonnet 5.5 6514b05d18 fix(money,orders): wage/gram-debt parity, atomic document confirm, expert edit, made-to-order, QA fixes
- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync
- Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency)
- Settlement document and expert inputs validated (400 instead of 500)
- Expert: edit / activate-deactivate own customers, adjust raw weights only
- Made-to-order flow in the app, PreOrder.quantity migration
- Order grids: customer column, sort keys; panel select search; pre-order quantity save
- Rebuilt release artifacts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:09:02 +03:30

385 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 type RepaymentInstrument = 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
/**
* The raw figure of a repayment document → the gold grams it takes off the
* debt. One place, used both when the document is filed and when an unconfirmed
* one is edited, so the two can never disagree (legacy CashController::store
* and ::update).
*
* MELTED_GOLD value = original_value × ayar / 750
* CASH value = price / (price_per_gram / 4.331802)
* TRANSFER / CHEQUE the grams as typed
*/
export function repaymentToGrams(
instrument: RepaymentInstrument,
rawValue: Prisma.Decimal.Value,
karatPerMille?: number | null,
pricePerGramRial?: Prisma.Decimal.Value | null,
): Prisma.Decimal {
const raw = new Prisma.Decimal(rawValue);
switch (instrument) {
case 'MELTED_GOLD':
if (!karatPerMille) throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
return raw.mul(karatPerMille).div(STANDARD_KARAT_PER_MILLE);
case 'CASH': {
if (!pricePerGramRial || new Prisma.Decimal(pricePerGramRial).isZero()) {
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.
return raw.div(new Prisma.Decimal(pricePerGramRial).div(GRAMS_PER_MESGHAL));
}
case 'TRANSFER':
case 'CHEQUE':
return raw;
}
}
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) {
return (await this.setConfirmed(entryId, true, adminUserId, tx)).entry;
}
/**
* Sets an entry's confirmed flag and moves the cached balance by exactly that
* change, once. Un-confirming reverses the entry: the same amount, the other way.
*
* The flip is a compare-and-swap (`updateMany` while the flag still has its old
* value) and only the request that wins it touches the balance. Reading the flag
* and then updating let N simultaneous requests all pass the read and all move
* the balance, leaving the cache N times off the book. The flip and the balance
* move commit together.
*
* `changed` is false when someone else already put the entry in that state.
*/
async setConfirmed(
entryId: string,
confirmed: boolean,
adminUserId?: string,
tx: Tx = this.prisma,
): Promise<{ entry: Prisma.LedgerEntryGetPayload<object>; changed: boolean }> {
if (tx === this.prisma) {
return this.prisma.$transaction((inner) => this.setConfirmed(entryId, confirmed, adminUserId, inner));
}
const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } });
if (!entry) throw new NotFoundException('Ledger entry not found');
const claimed = await tx.ledgerEntry.updateMany({
where: { id: entryId, confirmed: !confirmed },
data: confirmed
? { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId }
: { confirmed: false, confirmedAt: null },
});
const current = (await tx.ledgerEntry.findUnique({ where: { id: entryId } })) ?? entry;
if (claimed.count === 0) return { entry: current, changed: false };
const direction = confirmed
? entry.direction
: entry.direction === LedgerDirection.DEBIT
? LedgerDirection.CREDIT
: LedgerDirection.DEBIT;
await this.applyToBalance(entry.customerId, entry.currency, direction, entry.amount, tx);
return { entry: current, changed: true };
}
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
* CREDIT entry against the customer's gold debt. Formulas preserved verbatim
* from legacy CashController::store.
*
* `confirmed` follows the reference: a document an ADMIN files
* (CashController::store, `confirmed = 1`) counts at once; one a customer
* submits from the app (ApiMobileController::setFindoc, `confirmed = 0`) waits
* for an admin. The default stays pending — the safe state for a caller that
* does not say who is filing — and the admin documents screen passes true.
*/
async postGoldRepayment(
params: {
customerId: string;
instrument: RepaymentInstrument;
rawValue: Prisma.Decimal.Value;
karatPerMille?: number;
pricePerGramRial?: Prisma.Decimal.Value;
note?: string;
postedByAdminId?: string;
confirmed?: boolean;
},
tx: Tx = this.prisma,
) {
const goldGramsValue = repaymentToGrams(
params.instrument,
params.rawValue,
params.karatPerMille,
params.pricePerGramRial,
);
const sourceType = {
MELTED_GOLD: LedgerSourceType.REPAYMENT_MELTED_GOLD,
CASH: LedgerSourceType.REPAYMENT_CASH,
TRANSFER: LedgerSourceType.REPAYMENT_TRANSFER,
CHEQUE: LedgerSourceType.REPAYMENT_CHEQUE,
}[params.instrument];
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: params.confirmed ?? false,
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);
}
}