Fix error before M7

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 624a6402bf
commit 1e356a2578
11 changed files with 331 additions and 14 deletions
@@ -94,6 +94,18 @@ export class RedisService implements OnModuleDestroy {
return value;
}
/**
* Claims a key only if nobody holds it. Returns false when someone already does.
*
* `SET … NX` is atomic, which is the entire point: a read-then-write claim is
* exactly the race the claim exists to prevent. Two simultaneous retries of
* the same request must not both believe they are first.
*/
async setIfAbsent(key: string, value: unknown, ttlSeconds: number): Promise<boolean> {
const result = await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds, 'NX');
return result === 'OK';
}
/**
* Atomic fixed-window counter. Returns the count after increment so callers
* can decide to reject.
@@ -1,11 +1,23 @@
import { Body, Controller, Get, Inject, Param, Post, Query, Req, Res } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import type { Cart, Locale, Order } from '@sport/types';
import { placeOrderSchema, type PlaceOrderInput } from '@sport/validation';
import { Public } from '@/common/decorators/public.decorator';
import { AppException } from '@/common/errors/app.exception';
import { RequestLocale } from '@/common/i18n/locale.decorator';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
import { APP_CONFIG } from '@/config/app-config.module';
@@ -42,16 +54,34 @@ export class CheckoutController {
return this.service.quote(token, locale);
}
/**
* `Idempotency-Key` is required, not optional.
*
* This endpoint creates an order and reserves stock. A client that cannot be
* retried safely is a client that will eventually double-charge someone, and
* making the header optional means the one caller that forgets it is the one
* that does. Requiring it is a contract the storefront already honours.
*/
@Post('orders')
@ApiHeader({ name: 'Idempotency-Key', required: true, description: 'Unique per attempt' })
@ApiOperation({ summary: 'Place the order and reserve stock' })
async placeOrder(
@Body(new ZodValidationPipe(placeOrderSchema)) body: PlaceOrderInput,
@Headers('idempotency-key') idempotencyKey: string | undefined,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
@RequestLocale() locale: Locale,
): Promise<Order> {
const key = idempotencyKey?.trim();
if (!key || key.length < 8 || key.length > 128) {
throw AppException.badRequest(
'An Idempotency-Key header of 8–128 characters is required to place an order.',
);
}
const { token } = resolveCartToken(request);
const order = await this.service.placeOrder(token, body, locale);
const order = await this.service.placeOrder(token, key, body, locale);
// The bag is gone, so the cookie naming it should go too — otherwise the
// next visit reads an empty cart under a stale token forever.
@@ -5,9 +5,17 @@ import type { PlaceOrderInput } from '@sport/validation';
import { AppException } from '@/common/errors/app.exception';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { CartsService } from '@/modules/carts/public';
import { OrdersService } from '@/modules/orders/public';
/** What a claimed idempotency key holds while and after a placement runs. */
interface IdempotencyRecord {
/** Set once the order is durably committed; absent means still in flight. */
orderId?: string;
}
/**
* Turns a bag into an order.
*
@@ -25,6 +33,7 @@ export class CheckoutService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly carts: CartsService,
private readonly orders: OrdersService,
) {}
@@ -34,7 +43,61 @@ export class CheckoutService {
return this.carts.resolveForCheckout(cartToken, locale);
}
async placeOrder(cartToken: string, input: PlaceOrderInput, locale: Locale): Promise<Order> {
/**
* Places an order exactly once per idempotency key.
*
* The window this closes is real and unavoidable without it: the transaction
* commits, and then the response is lost to a dropped connection or a reload.
* The shopper sees a failure, presses the button again, and buys everything
* twice. Nothing inside the transaction can prevent that, because the problem
* happens after it succeeds.
*
* The key is claimed atomically before any work starts:
* - claim succeeds -> this is the first attempt; place the order and
* record its id against the key
* - claim fails, id recorded -> a retry of a request that already
* succeeded; return the same order rather than a new one
* - claim fails, no id yet -> the original attempt is still running;
* refuse instead of racing it
*
* On failure the claim is released, so a genuine retry after a genuine error
* is not locked out for the next 24 hours.
*/
async placeOrder(
cartToken: string,
idempotencyKey: string,
input: PlaceOrderInput,
locale: Locale,
): Promise<Order> {
const key = CACHE_KEYS.idempotency('checkout', idempotencyKey);
const claimed = await this.redis.setIfAbsent(key, {}, CACHE_TTL.idempotency);
if (!claimed) {
const existing = await this.redis.get<IdempotencyRecord>(key);
if (existing?.orderId) {
this.logger.log(`Replaying order ${existing.orderId} for idempotency key`);
return this.orders.getById(existing.orderId);
}
throw AppException.conflict('This order is already being placed. Give it a moment.');
}
try {
return await this.place(cartToken, key, input, locale);
} catch (error) {
// Release, so the shopper can fix whatever went wrong and try again.
await this.redis.delete(key);
throw error;
}
}
private async place(
cartToken: string,
idempotencyCacheKey: string,
input: PlaceOrderInput,
locale: Locale,
): Promise<Order> {
const cart = await this.carts.resolveForCheckout(cartToken, locale);
// A cart that had to correct itself is not one to charge against — the
@@ -134,9 +197,31 @@ export class CheckoutService {
return order.id;
});
// Only once the order is durably committed. Clearing first would lose a
// shopper's bag to a failed transaction.
await this.carts.clear(cartToken);
/**
* Record the id before anything else can fail.
*
* From here the order exists, so every later step has to be survivable. A
* retry that arrives after this point replays rather than duplicating.
*/
await this.redis.set(idempotencyCacheKey, { orderId }, CACHE_TTL.idempotency);
/**
* Clearing the bag must not fail the request.
*
* The order is committed. Throwing here would report failure for something
* that succeeded, and the shopper's retry would be the exact double-order
* this method exists to prevent. A bag that outlives its order is a cosmetic
* problem; an order the customer was told failed is not.
*/
try {
await this.carts.clear(cartToken);
} catch (error) {
this.logger.error(
`Order ${orderId} placed but its cart could not be cleared: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
this.logger.log(`Order ${orderId} placed with ${cart.lines.length} line(s)`);
return this.orders.getById(orderId);
@@ -0,0 +1,44 @@
import { FULFILLMENT_STATUSES, ORDER_STATUSES, type OrderStatus } from '@sport/types';
import { fulfillmentFor } from './orders.service';
/**
* `status` and `fulfillmentStatus` are separate columns because they answer
* separate questions — an order can be paid and unshipped, or shipped and
* unpaid. Two combinations are still contradictions, and this pins them.
*
* The bug this replaces was live: fulfilling an order shipped the reserved
* stock and wrote a `SALE` ledger entry while leaving `fulfillmentStatus` on
* UNFULFILLED, so the database held orders reading "FULFILLED / UNFULFILLED".
* Nothing failed loudly — it just quietly disagreed with the ledger.
*/
describe('fulfillment status derivation', () => {
it('marks anything that shipped as fulfilled', () => {
for (const status of [ORDER_STATUSES.FULFILLED, ORDER_STATUSES.COMPLETED]) {
expect(fulfillmentFor(status)).toEqual({
fulfillmentStatus: FULFILLMENT_STATUSES.FULFILLED,
});
}
});
it('leaves fulfilment alone for states where nothing shipped', () => {
for (const status of [
ORDER_STATUSES.PENDING,
ORDER_STATUSES.CONFIRMED,
// Cancelling releases a reservation; it never ships anything, so
// UNFULFILLED stays the truth rather than being overwritten.
ORDER_STATUSES.CANCELLED,
]) {
expect(fulfillmentFor(status)).toEqual({});
}
});
it('covers every status, so a new one cannot slip through untested', () => {
const all = Object.values(ORDER_STATUSES) as OrderStatus[];
expect(all).toHaveLength(5);
for (const status of all) {
expect(() => fulfillmentFor(status)).not.toThrow();
}
});
});
@@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
FULFILLMENT_STATUSES,
ORDER_STATUSES,
type FulfillmentStatus,
type OffsetPaginated,
type Order,
type OrderListItem,
@@ -149,6 +151,7 @@ export class OrdersService {
where: { id, status: from },
data: {
status: to,
...fulfillmentFor(to),
...(to === ORDER_STATUSES.CONFIRMED ? { confirmedAt: new Date() } : {}),
...(to === ORDER_STATUSES.CANCELLED
? { cancelledAt: new Date(), cancelReason: input.reason?.trim() ?? null }
@@ -251,3 +254,26 @@ export class OrdersService {
}
}
}
/**
* Keeps `fulfillmentStatus` honest about `status`.
*
* These are separate columns because they answer separate questions — an order
* can be paid and unshipped, or shipped and unpaid — but two of the values are
* not independent. Moving to FULFILLED physically ships the reserved stock and
* writes a `SALE` ledger entry; leaving `fulfillmentStatus` on UNFULFILLED
* afterwards produced orders reading "FULFILLED / UNFULFILLED", which is not an
* unusual state, it is a contradiction.
*
* CANCELLED is deliberately absent: cancelling releases a reservation and ships
* nothing, so UNFULFILLED remains the truth.
*/
export function fulfillmentFor(status: OrderStatus): { fulfillmentStatus?: FulfillmentStatus } {
switch (status) {
case ORDER_STATUSES.FULFILLED:
case ORDER_STATUSES.COMPLETED:
return { fulfillmentStatus: FULFILLMENT_STATUSES.FULFILLED };
default:
return {};
}
}
@@ -57,6 +57,17 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* One key per checkout attempt, generated once and kept for the life of this
* form.
*
* That lifetime is exactly right. Pressing "Place order" again after a
* dropped response reuses it, so the server replays the order it already
* created instead of making a second one. Reloading the page mints a new key,
* which is a genuinely new attempt.
*/
const [idempotencyKey] = useState(() => crypto.randomUUID());
function set(key: keyof Fields, value: string) {
setFields((current) => ({ ...current, [key]: value }));
}
@@ -67,7 +78,7 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
setError(null);
try {
const order = await browserApi.commerce.placeOrder(locale, {
const order = await browserApi.commerce.placeOrder(locale, idempotencyKey, {
email: fields.email,
shippingAddress: {
fullName: fields.fullName,