Skip to content

Auction Amount & amountMinor — Frontend Integration

Scope: how every auction amount is stored, transported, validated and rendered. Covers the canonical amount vs amountMinor contract, the shared money.ts helpers, and full validation matrices for AuctionBidMode and AuctionClosingMode.

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 amountMinor string via decimalToMinor and never touch amount again. The server re-derives amount from amountMinor with minorToDecimal.

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: bidAmount was new Decimal(minorString).toFixed(2) without /100 → 14000 displayed as 14000.00 instead of 140.00. Now div(100).toFixed(2) in submit-bid-command-helpers.ts, auction-bid-activity-helpers.ts (toMoney), and auction-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)

  1. Limits configured: minimumBidAmount and totalAmount non-null else INVALID_AMOUNT ("Auction limits are not configured").
  2. Idempotency: commandId (id) → getBidByCommandId → if exists & existing.amountMinor !== proposedMinor → DUPLICATE_COMMAND; else replay.
  3. Auction enabled / cycle active / window open: program.bid is AUCTION/AUCTION_AND_LOT, cycle ACTIVE, auctionStatus === LIVE and now < auctionEndAt (derived from startedAt/auctionStartAt + auctionDurationSeconds + accumulatedPausedMs).
  4. Eligibility: not disqualified (LIVE phase), invoice PAID for cycle, not already DECLARED winner 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. leading 150000 (1500.00) → next 150001 (1500.01)
  • TOTAL_VALUE_DECREASING: current - 1 → leading 150000 → next 149999 (1499.99)
  • null when 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 BidPolicy exactly; duplicate-amount is not checked client-side — rely on server BID_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: id is the command id. Re-send same id + same amountMinor on reconnect — server replays original events, no double bid. Changing amountMinor under same id → 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.place max:100 per websocket.rateLimitWindowMs (see rate-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 * 100 float math with decimalToMinor/minorToMoney.
  • WS auction.bid.place sends amountMinor: string (/^[1-9]\d*$/), not amount.
  • Render feedItem.bidAmount as-is; REST leadingBidAmount as Number(...).toFixed(2); never Decimal(minor).toFixed without /100.
  • Pre-validate with mode-aware validateClientSide above; show nextMinor hint.
  • Keep id stable 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 respect pausedAt/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 (hasActiveLiveBidAmount with BIGINT direct compare, minorToDecimal).
  • Schema: prisma/schema.prisma AuctionBid.amountMinor BigInt, Auction.leadingBidAmountMinor, AuctionBidMode, AuctionClosingMode, AuctionClosingPhase.
  • Migration: 20260826000000_fix_auction_bid_amount_unique (partial unique on active amount).
  • Config: AuctionBidMode / AuctionClosingMode fold default < platform < company < program < auction via auctionSettings/AUCTION_RUNTIME_CONFIG_SQL.