Auction Amount & amountMinor — Frontend Integration¶
Scope: how every auction amount is stored, transported, validated and rendered. Covers the canonical
amountvsamountMinorcontract, the sharedmoney.tshelpers, and full validation matrices forAuctionBidModeandAuctionClosingMode.
This is the authoritative reference for money handling in the auction system after the amount/amountMinor unification. Follow it verbatim — the 100× bug (rendering minor as major) and the rounding-divergence bug (JS mul(100).toDecimalPlaces vs SQL ROUND) are fixed by the canonical path described here.
Related: Auction frontend integration (strict lifecycle) · Bid activity feed · Bid acceptance events · WebSocket auction events · REST auction & prebid
1. Mental model — two representations, one truth¶
| Representation | Type (TS) | Example ₹ 1 500.00 | Where it lives |
|---|---|---|---|
amount (major) | Prisma.Decimal / number (toFixed(2)) | 1500.00 | Program config (minimumBidAmount, totalAmount), REST JSON numbers, leadingBidAmount display |
amountMinor (minor) | bigint in DB / string on wire | 150000 (= 1500.00 × 100) | auction_bids.amount_minor BIGINT, auctions.leading_bid_amount_minor, WS amountMinor: string |
Factor: MINOR_FACTOR = 100, MINOR_DECIMALS = 2 (paise). Never multiply/divide by 100 ad-hoc — import the canonical helpers.
// src/shared/utils/money.ts — canonical, used by every command & repository
import { Prisma } from '@prisma/client';
const FACTOR = 100;
export function decimalToMinor(d: Prisma.Decimal | number | string | bigint): bigint {
if (typeof d === 'bigint') return d;
const dec = d instanceof Prisma.Decimal ? d : new Prisma.Decimal(d.toString());
return BigInt(
dec.mul(FACTOR).toDecimalPlaces(0, Prisma.Decimal.ROUND_HALF_UP).toString()
);
}
export function minorToDecimal(m: bigint | number | string | null): Prisma.Decimal {
return new Prisma.Decimal((m ?? 0).toString()).div(FACTOR);
}
export function minorToMoney(m: bigint | number | string | null): string {
return minorToDecimal(m).toFixed(2); // "1500.00"
}
Rounding is always ROUND_HALF_UP (0.5 → up) via decimal.mul(100).toDecimalPlaces(0, HALF_UP). 10.005 → 1001 (10.01), 10.004 → 1000 (10.00). JS and SQL now use the same path — the old ROUND(amount*100) SQL path is removed; duplicate detection compares amount_minor directly.
Rule 1 — if you are holding a value the user typed, convert once to
amountMinorstring viadecimalToMinorand never touchamountagain. The server re-derivesamountfromamountMinorwithminorToDecimal.
Storage types¶
// Prisma
model Program {
minimumBidAmount Decimal? @db.Decimal(19,4) // major, e.g. 100.00
totalAmount Decimal? @db.Decimal(19,4) // major, e.g. 100000.00
}
model AuctionBid {
amountMinor BigInt? @map("amount_minor") // paise, e.g. 10000n for 100.00
}
model Auction {
leadingBidAmountMinor BigInt? @map("leading_bid_amount_minor")
}
Decimal(19,4) preserves 4 decimals on the program limits, but bids are always rounded to 2 decimals before storage. 19,4 → minor uses toDecimalPlaces(0, HALF_UP) after *100, so 100.009 → 10001 (100.01).
2. Transport contracts — what the frontend sees¶
2.1 REST (numbers, major)¶
REST never exposes amountMinor. All amounts are JSON number with 2 decimals:
// GET /v2/companies/auctions/:auctionId + GET /v2/subscribers/me/auctions/:auctionId
{
"minimumBidAmount": 100,
"totalAmount": 100000,
"leadingBidAmount": 1500, // null until first live bid, always live-bid-derived
"leadingBid": { "amount": 1500 }
}
// list endpoints also expose leadingBidAmountMin/Max filters as numbers
Use these numbers to render bounds and to bound client-side validation. Do not send them back — send amountMinor string instead.
2.2 WebSocket — bid place (minor string, the only write path)¶
// client → server
{
"type": "auction.bid.place",
"id": "req_01HQ...", // 1-128 chars, becomes idempotency key
"data": {
"auctionId": "auc_...",
"amountMinor": "150000", // regex /^[1-9]\d*$/ — positive integer, no leading zero
"enrolledSubscriberId": "enr_..." // only for staff offline bids
}
}
// server → client terminal ack
{ "type": "ack", "id": "req_01HQ...", "data": { "eventType": "auction.bid.place", "status": "accepted", "commandId": "req_01HQ...", "auctionId": "auc_...", "cycleId": "cyc_...", "bidId": "bid_...", "amountMinor": "150000", "stateVersion": 12 } }
amountMinor is string on the wire because BIGINT exceeds Number.MAX_SAFE_INTEGER. Staff offline (FLOOR) vs subscriber (ONLINE) — same shape; staff must supply enrolledSubscriberId and target must be offline when allowStaffOfflineBids is respected.
2.3 Realtime payloads (mixed)¶
| Source | Field | Type | Example |
|---|---|---|---|
auction.bid.created / updated → payload.bidAmount | major string | "1500.00" (already minor.div(100).toFixed(2)) | Render directly |
auction.state.changed → leadingBidAmount | major number | 1500 | Refresh leadingBidAmount |
auction.bid.deleted → leadingBidAmount | major number | 1400 | After moderation |
Past bug fixed:
bidAmountwasnew Decimal(minorString).toFixed(2)without/100→14000displayed as14000.00instead of140.00. Nowdiv(100).toFixed(2)insubmit-bid-command-helpers.ts,auction-bid-activity-helpers.ts(toMoney), andauction-bid-activity-repository.ts(makeActivityItemFromBid).
3. Validation — the single BidPolicy pipeline¶
All live bids (place-bid V2 and submit-bid activity path) now run the same minor-based pipeline. validateBidAmount (major) is no longer duplicated — the checklist below is the server truth.
3.1 Pre-flight (non-amount)¶
- Limits configured:
minimumBidAmountandtotalAmountnon-null elseINVALID_AMOUNT("Auction limits are not configured"). - Idempotency:
commandId (id)→getBidByCommandId→ if exists &existing.amountMinor !== proposedMinor→DUPLICATE_COMMAND; else replay. - Auction enabled / cycle active / window open:
program.bidisAUCTION/AUCTION_AND_LOT, cycleACTIVE,auctionStatus === LIVEandnow < auctionEndAt(derived fromstartedAt/auctionStartAt+auctionDurationSeconds+accumulatedPausedMs). - Eligibility: not disqualified (
LIVEphase), invoicePAIDfor cycle, not alreadyDECLAREDwinner in program.
3.2 Amount validation — bounds (both modes)¶
Performed by BidPolicy.validate (src/application/use-cases/auctions/bidding/bid-policy.ts):
proposedMinor <= 0n → INVALID_AMOUNT (next=null)
proposedMinor < minimumAllowedMinor → INVALID_AMOUNT (next=min)
proposedMinor > maximumAllowedMinor → INVALID_AMOUNT (next=max)
minimumAllowedMinor = nullableDecimalToMinor(minimumBidAmount) etc., using canonical rounding. Example: min=100.009 → 10001; a typed 100.00 (10000) is rejected with INVALID_AMOUNT + nextAllowed = 10001 (render as 100.01).
3.3 Mode-specific progression (AuctionBidMode)¶
| Mode | Meaning | First bid | Next bids (requires currentMinor !== null) | minimumStepMinor |
|---|---|---|---|---|
TOTAL_VALUE_DECREASING (default, reverse auction — lowest wins) | Each new bid must be lower than leading. Used for chit discount bidding. | Any proposedMinor within [min,max] passes. | proposedMinor >= currentMinor → BID_NOT_IMPROVING (next = current - step); proposedMinor > currentMinor - step → BID_DECREMENT_TOO_SMALL | 1n (1 paise = 0.01). Set in both commands; future configurable. |
DISCOUNT_INCREASING | Each new bid must be higher than leading. | Same first-bid rule. | proposedMinor <= currentMinor → BID_NOT_IMPROVING (next = current + step); proposedMinor < currentMinor + step → BID_INCREMENT_TOO_SMALL | 1n |
Special tie at minimum for TOTAL_VALUE_DECREASING: isWinningBidAmount allows current == min && proposed == min to be considered winning (prevents blocking equal-minimum bids when multiple bidders hit the floor). The bid-policy still enforces proposed < current for TOTAL_VALUE_DECREASING, so an equal-minimum rebid after a leading 100.00 would need a lower value and thus is correctly rejected — use the “minimum-tie” lot flow (record_lot) post-ENDED instead.
Next-allowed hint: on valid:false, server returns nextAllowedAmountMinor (bidPolicy.nextAllowedBid):
DISCOUNT_INCREASING:current + 1→ e.g. leading150000(1500.00) → next150001(1500.01)TOTAL_VALUE_DECREASING:current - 1→ leading150000→ next149999(1499.99)nullwhen no leading bid yet or when bounds failure.
Frontend should render nextAllowedAmountMinor as minorToMoney(next) when present.
3.4 Duplicate amount¶
hasActiveLiveBidAmount({ auctionId, amountMinor: bigint }) → boolean
// SQL: SELECT EXISTS(... WHERE amount_minor = $minor::BIGINT AND deleted/disqualified IS NULL)
Unique partial index enforces it even under race:
CREATE UNIQUE INDEX auction_bids_auction_id_amount_minor_unique_active_idx
ON auction_bids(auction_id, amount_minor)
WHERE deleted_at IS NULL AND disqualified_at IS NULL AND amount_minor IS NOT NULL;
On race the createBid write throws P2002 → mapped to DUPLICATE_COMMAND / BID_NOT_IMPROVING ("This bid amount has already been submitted."). Client should treat as BID_NOT_IMPROVING — suggest nextAllowed.
3.5 Full error code matrix¶
| Code | When | nextAllowedAmountMinor | Retry |
|---|---|---|---|
INVALID_AMOUNT | <=0, < min, > max, limits missing, malformed minor regex | min/max/null | Fix amount |
BID_NOT_IMPROVING | Wrong direction vs leading (or equal) | nextAllowed | Use hint |
BID_INCREMENT_TOO_SMALL | DISCOUNT_INCREASING & < current+step | current+step | Use hint |
BID_DECREMENT_TOO_SMALL | TOTAL_VALUE_DECREASING & > current-step | current-step | Use hint |
DUPLICATE_COMMAND | Same id reused with different amountMinor | — | New id |
AUCTION_NOT_LIVE / AUCTION_ENDED / SUBSCRIBER_NOT_ELIGIBLE | Pre-flight failures | — | Disable bid UI |
All map to the correlated terminal WebSocket error envelope.
4. AuctionClosingMode — what changes for the frontend¶
Amount validation is identical in both closing modes. Closing mode only affects timing and auto-transitions; the bid form logic does not branch on it except for countdown and extension handling.
| Mode | AUCTION_CLOSING_MODE value | Timing | Auto-extension | Call phases | Frontend handling |
|---|---|---|---|---|---|
DURATION_MODE (default) | DURATION_MODE | Fixed auctionDurationSeconds from startedAt/auctionStartAt. auctionEndAt = start + duration + pausedMs. | Yes — a bid within auctionExtensionSeconds of auctionEndAt pushes auctionEndAt by +auctionExtensionSeconds (read the replacement deadline from auction.state.changed.payload.state.auctionEndAt). | null | Render countdown to auctionEndAt; on extended reset timer. Do not allow bids after endedAt. |
CALL_MODE | CALL_MODE | The duration deadline starts FIRST_CALL; the worker then advances FIRST_CALL → SECOND_CALL → THIRD_CALL. | No | closingPhase / closingPhaseEndsAt drive the UI once calls start. Each call lasts auctionFirstCallSeconds / Second / Third seconds (from AuctionSettings). | Before calls, count down to auctionEndAt; during calls, count down to closingPhaseEndsAt. Disable bidding only at the active deadline or when no longer LIVE. |
Resolve auctionClosingMode from the snapshot/REST auction.settings (folds default < platform < company < program < auction via auction-settings.ts). Both modes share auctionStatus (LIVE/PAUSED/ENDED) and the same bid WS.
5. Frontend implementation — copy-pasteable¶
5.1 Convert & validate before send¶
import { Prisma } from '@prisma/client';
function toMinor(input: string): string {
// input: "1500", "1500.5", " 1500.00 " → "150000"
const trimmed = input.trim();
if (!/^[0-9]+(\.[0-9]{1,4})?$/.test(trimmed)) throw new Error('FORMAT');
const dec = new Prisma.Decimal(trimmed);
// canonical rounding
return dec.mul(100).toDecimalPlaces(0, Prisma.Decimal.ROUND_HALF_UP).toString();
}
function validateClientSide(
minorStr: string,
ctx: {
mode: 'DISCOUNT_INCREASING' | 'TOTAL_VALUE_DECREASING';
min: string;
max: string;
leading: string | null;
}
): { ok: boolean; code?: string; nextMinor?: string } {
const proposed = BigInt(minorStr);
if (proposed <= 0n) return { ok: false, code: 'INVALID_AMOUNT' };
const min = BigInt(ctx.min),
max = BigInt(ctx.max);
if (proposed < min) return { ok: false, code: 'INVALID_AMOUNT', nextMinor: ctx.min };
if (proposed > max) return { ok: false, code: 'INVALID_AMOUNT', nextMinor: ctx.max };
if (ctx.leading === null) return { ok: true };
const cur = BigInt(ctx.leading);
if (ctx.mode === 'DISCOUNT_INCREASING') {
if (proposed <= cur)
return { ok: false, code: 'BID_NOT_IMPROVING', nextMinor: (cur + 1n).toString() };
if (proposed < cur + 1n)
return {
ok: false,
code: 'BID_INCREMENT_TOO_SMALL',
nextMinor: (cur + 1n).toString()
};
} else {
if (proposed >= cur)
return { ok: false, code: 'BID_NOT_IMPROVING', nextMinor: (cur - 1n).toString() };
if (proposed > cur - 1n)
return {
ok: false,
code: 'BID_DECREMENT_TOO_SMALL',
nextMinor: (cur - 1n).toString()
};
}
return { ok: true };
}
// usage
const amountMinor = toMinor(userInput); // "1500.00" → "150000"
if (!/^[1-9]\d*$/.test(amountMinor)) throw new Error('INVALID_AMOUNT');
const v = validateClientSide(amountMinor, {
mode: auction.auctionBidMode,
min: decimalToMinor(auction.minimumBidAmount).toString(), // 100 → "10000"
max: decimalToMinor(auction.totalAmount).toString(),
leading: auction.leadingBidAmountMinor?.toString() ?? null
});
if (!v.ok)
showInline(v.code, v.nextMinor ? minorToMoney(BigInt(v.nextMinor)) : undefined);
else
ws.send({
type: 'auction.bid.place',
id: crypto.randomUUID(),
data: { auctionId, amountMinor }
});
Mirrors
BidPolicyexactly; duplicate-amount is not checked client-side — rely on serverBID_NOT_IMPROVING+ unique index.
5.2 Display¶
function formatMinor(m: string | bigint | null): string {
if (m === null) return '—';
return new Prisma.Decimal(m.toString()).div(100).toFixed(2); // "150000" → "1500.00"
}
// WS feed
feedItem.bidAmount; // already "1500.00" — render as-is, do not divide
// REST
auction.leadingBidAmount?.toFixed(2);
Common pitfall fixed: new Decimal(minorString).toFixed(2) without /100 → 14000 → 14000.00 instead of 140.00.
5.3 Transactional ordering & idempotency¶
- Idempotency:
idis the command id. Re-send sameid+ sameamountMinoron reconnect — server replays original events, no double bid. ChangingamountMinorunder sameid→DUPLICATE_COMMAND. - Concurrency: the server validates each request against the latest committed auction state under a PostgreSQL row lock. Client timestamps and versions do not establish bid priority.
- Rate limit:
auction.bid.placemax:100perwebsocket.rateLimitWindowMs(seerate-limits.md).
6. Worked examples¶
6.1 TOTAL_VALUE_DECREASING (chit, lowest wins — default)¶
Program: minimumBidAmount=100, totalAmount=100000, mode=TOTAL_VALUE_DECREASING.
| Step | Leading (minor / major) | Proposed input | Minor | Result | Next hint |
|---|---|---|---|---|---|
| 1 | null | 50000 | 5000000 | ✅ ACCEPTED | — |
| 2 | 5000000 (50000.00) | 50000 (equal) | 5000000 | ❌ BID_NOT_IMPROVING | 4999999 (49999.99) |
| 3 | same | 49999.995 → rounds 50000.00 | 5000000 | ❌ duplicate | 4999999 |
| 4 | same | 49999.99 | 4999999 | ✅ ACCEPTED (must be < leading & <= leading-1) | — |
| 5 | — | 99 | 9900 | ❌ INVALID_AMOUNT | 10000 (100.00) |
| 6 | — | 100001 | 10000100 | ❌ INVALID_AMOUNT | 10000000 (100000.00) |
6.2 DISCOUNT_INCREASING (higher discount wins)¶
| Step | Leading | Proposed | Minor | Result |
|---|---|---|---|---|
| 1 | null | 1000 | 100000 | ✅ |
| 2 | 100000 (1000.00) | 1000.00 | 100000 | ❌ BID_NOT_IMPROVING → next 100001 (1000.01) |
| 3 | same | 1000.005 → 100001 | 100001 | ✅ (>= cur+1 passes) |
6.3 Rounding to paise¶
User types 10.005 with min=10.00 → decimalToMinor 10.005*100=1000.5 → HALF_UP → 1001 (10.01) passes; 10.004 → 1000 (10.00) rejected as below min if min is 10.01.
Prebid create and update use the same conversion and mode-aware minimum/maximum policy as live bids. The server persists and renders the canonical two-decimal value. Because prebids are sealed, validation uses no current leading amount: one subscriber's prebid and the subscriber's previous editable prebid do not create an improvement-step requirement.
7. Checklist for frontend PR¶
- Replace any
amount * 100float math withdecimalToMinor/minorToMoney. - WS
auction.bid.placesendsamountMinor: string(/^[1-9]\d*$/), notamount. - Render
feedItem.bidAmountas-is; RESTleadingBidAmountasNumber(...).toFixed(2); neverDecimal(minor).toFixedwithout/100. - Pre-validate with mode-aware
validateClientSideabove; shownextMinorhint. - Keep
idstable when retrying an uncertain response with the same payload. - Handle the terminal correlated
error→ map its safe code to UI copy. - Countdown:
DURATION_MODE→auctionEndAt;CALL_MODE→closingPhase+auctionFirst/Second/ThirdCallSeconds. Both respectpausedAt/accumulatedPausedMs. - Duplicate amount race — rely on server unique index; do not assume client check prevents it.
8. References¶
- Helpers:
src/shared/utils/money.ts(canonical),src/application/use-cases/auctions/bidding/bid-policy.ts(DiscountIncreasingBidPolicy,TotalValueDecreasingBidPolicy),src/application/use-cases/auctions/auction-settings.ts(isCallClosingMode,getAutoExtensionMs),src/infrastructure/db/postgres/repositories/auctions-repository.ts(hasActiveLiveBidAmountwithBIGINTdirect compare,minorToDecimal). - Schema:
prisma/schema.prismaAuctionBid.amountMinor BigInt,Auction.leadingBidAmountMinor,AuctionBidMode,AuctionClosingMode,AuctionClosingPhase. - Migration:
20260826000000_fix_auction_bid_amount_unique(partial unique on active amount). - Config:
AuctionBidMode/AuctionClosingModefolddefault < platform < company < program < auctionviaauctionSettings/AUCTION_RUNTIME_CONFIG_SQL.