- 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>
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
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);
|
||
}
|
||
}
|