Fix error before M7
This commit is contained in:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user