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:
co-authored by
Claude Sonnet 5.5
parent
959a272227
commit
6514b05d18
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user