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>
This commit is contained in:
mahmood
2026-10-29 18:09:02 +03:30
co-authored by Claude Sonnet 5.5
parent 959a272227
commit 6514b05d18
321 changed files with 6984 additions and 2170 deletions
@@ -10,6 +10,43 @@ 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;
@@ -77,17 +114,50 @@ export class LedgerService {
* 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');
if (entry.confirmed) return entry;
const updated = await tx.ledgerEntry.update({
where: { id: entryId },
data: { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId },
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 };
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
return updated;
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(
@@ -166,58 +236,40 @@ export class LedgerService {
/**
* 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.
* 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: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
instrument: RepaymentInstrument;
rawValue: Prisma.Decimal.Value;
karatPerMille?: number;
pricePerGramRial?: Prisma.Decimal.Value;
note?: string;
postedByAdminId?: string;
confirmed?: boolean;
},
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;
}
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(
{
@@ -229,7 +281,7 @@ export class LedgerService {
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
confirmed: params.confirmed ?? false,
postedByAdminId: params.postedByAdminId,
},
tx,