feat/checkout-orders #3

Merged
mihir merged 5 commits from feat/checkout-orders into main 2026-09-11 00:40:00 +05:30
11 changed files with 365 additions and 2 deletions
Showing only changes of commit 9ac40531b1 - Show all commits

View File

@ -1,3 +1,4 @@
import { CheckoutModule } from './checkout/checkout.module';
import { CatalogModule } from './catalog/catalog.module'; import { CatalogModule } from './catalog/catalog.module';
import { AddressesModule } from './addresses/addresses.module'; import { AddressesModule } from './addresses/addresses.module';
import { InventoryModule } from './inventory/inventory.module'; import { InventoryModule } from './inventory/inventory.module';
@ -14,6 +15,7 @@ import { HealthModule } from './health/health.module';
CatalogModule, CatalogModule,
AddressesModule, AddressesModule,
InventoryModule, InventoryModule,
CheckoutModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { CURRENCIES } from '../common/currency';
import { text, ids } from '../common/input'; import { text, ids } from '../common/input';
import { pageSchema } from '../identity/identity.schemas'; import { pageSchema } from '../identity/identity.schemas';
@ -38,7 +39,7 @@ export const variantSchema = z
.string() .string()
.regex(/^(0|[1-9]\d{0,9})\.\d{2}$/) .regex(/^(0|[1-9]\d{0,9})\.\d{2}$/)
.refine((value) => value !== '0.00'), .refine((value) => value !== '0.00'),
currency: z.enum(['INR', 'USD', 'EUR', 'GBP']).default('INR'), currency: z.enum(CURRENCIES).default('INR'),
active: z.boolean().default(true), active: z.boolean().default(true),
attributes: z attributes: z
.record( .record(

View File

@ -0,0 +1,39 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Put,
} from '@nestjs/common';
import { CurrentPrincipal } from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { SchemaPipe } from '../common/validation.pipe';
import { cartLineSchema, cartVersionSchema } from './checkout.schemas';
import { CartStore } from './cart.store';
@Controller('cart')
export class CartController {
constructor(private readonly carts: CartStore) {}
@Get()
get(@CurrentPrincipal() actor: Principal) {
return this.carts.get(actor);
}
@Put('lines/:variantId')
set(
@CurrentPrincipal() actor: Principal,
@Param('variantId', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(cartLineSchema))
input: { quantity: number; version: number },
) {
return this.carts.set(actor, id, input.version, input.quantity);
}
@Delete('lines/:variantId')
remove(
@CurrentPrincipal() actor: Principal,
@Param('variantId', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(cartVersionSchema)) input: { version: number },
) {
return this.carts.set(actor, id, input.version, 0);
}
}

View File

@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { AccessStore } from '../identity/access.store';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
@Injectable()
export class CartStore {
constructor(private readonly access: AccessStore) {}
get(actor: Principal) {
return this.access.mutate(actor, null, async (tx) => {
const cart = await tx.cart.upsert({
where: {
userId_organizationId: {
userId: actor.userId,
organizationId: actor.organizationId,
},
},
create: { userId: actor.userId, organizationId: actor.organizationId },
update: {},
});
const lines = await tx.cartLine.findMany({
where: { cartId: cart.id },
orderBy: { id: 'asc' },
select: {
variantId: true,
quantity: true,
variant: {
select: {
name: true,
sku: true,
price: true,
currency: true,
active: true,
product: { select: { name: true, status: true } },
},
},
},
});
return { version: cart.version, lines };
});
}
set(actor: Principal, variantId: string, version: number, quantity: number) {
return this.access.mutate(actor, null, async (tx) => {
const cart = await tx.cart.upsert({
where: {
userId_organizationId: {
userId: actor.userId,
organizationId: actor.organizationId,
},
},
create: { userId: actor.userId, organizationId: actor.organizationId },
update: {},
});
if (cart.version !== version) throw new AppError('CART_CHANGED');
if (quantity === 0) {
await tx.cartLine.deleteMany({ where: { cartId: cart.id, variantId } });
} else {
const variant = await tx.productVariant.findFirst({
where: {
id: variantId,
organizationId: actor.organizationId,
active: true,
product: { status: 'PUBLISHED' },
},
});
if (!variant) throw new AppError('CART_ITEM_UNAVAILABLE');
const lines = await tx.cartLine.findMany({
where: { cartId: cart.id },
include: { variant: true },
});
if (lines.some((line) => line.variant.currency !== variant.currency))
throw new AppError('CART_CURRENCY');
if (
lines.length >= 20 &&
!lines.some((line) => line.variantId === variantId)
)
throw new AppError('CART_LIMIT');
await tx.cartLine.upsert({
where: { cartId_variantId: { cartId: cart.id, variantId } },
create: {
cartId: cart.id,
organizationId: actor.organizationId,
variantId,
quantity,
},
update: { quantity },
});
}
await tx.cart.update({
where: { id: cart.id },
data: { version: { increment: 1 } },
});
return { version: version + 1 };
});
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { CartStore } from './cart.store';
import { CartController } from './cart.controller';
@Module({
imports: [DatabaseModule, IdentityModule],
controllers: [CartController],
providers: [CartStore],
})
export class CheckoutModule {}

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
export const couponCode = z
.string()
.trim()
.toUpperCase()
.regex(/^[A-Z0-9][A-Z0-9_-]{0,39}$/);
export const cartLineSchema = z
.object({
quantity: z.number().int().min(1).max(100),
version: z.number().int().min(0).max(2147483646),
})
.strict();
export const cartVersionSchema = cartLineSchema.pick({ version: true });
export const checkoutSchema = z
.object({
cartVersion: z.number().int().min(0).max(2147483646),
addressId: z.uuid(),
couponCode: couponCode.optional(),
idempotencyKey: z.uuid(),
})
.strict();
export type CheckoutInput = z.infer<typeof checkoutSchema>;

1
src/common/currency.ts Normal file
View File

@ -0,0 +1 @@
export const CURRENCIES = ['INR', 'USD', 'EUR', 'GBP'] as const;

View File

@ -0,0 +1,50 @@
export const CHECKOUT_ERRORS = {
CART_EMPTY: [
409,
'Add items before checkout',
'Empty cart checkout rejected',
],
CART_CHANGED: [
409,
'Cart version has changed',
'Stale cart command rejected',
],
CART_LIMIT: [409, 'Cart line limit reached', 'Cart resource quota exceeded'],
CART_ITEM_UNAVAILABLE: [
409,
'Cart item is no longer available',
'Non-sellable cart variant rejected',
],
CART_CURRENCY: [
409,
'Cart items must use one currency',
'Mixed currency cart rejected',
],
COUPON_NOT_FOUND: [404, 'Coupon not found', 'Scoped coupon lookup failed'],
COUPON_INELIGIBLE: [
409,
'Coupon is not eligible for this checkout',
'Coupon eligibility rule rejected checkout',
],
ORDER_NOT_FOUND: [404, 'Order not found', 'Scoped order lookup failed'],
ORDER_LIMIT: [
409,
'Too many active orders',
'Account active order quota exceeded',
],
ORDER_RESERVATION_MANAGED: [
409,
'Manage this reservation through its order',
'Standalone order reservation mutation rejected',
],
STOCK_ALLOCATION_LIMIT: [
409,
'Too many stock locations for one checkout',
'Checkout stock allocation bound exceeded',
],
MONEY_RANGE: [
409,
'Order amount exceeds the supported limit',
'Checkout arithmetic bound exceeded',
],
} as const;

View File

@ -1,4 +1,9 @@
import { CHECKOUT_ERRORS } from './checkout-errors';
import { PLATFORM_ERRORS } from './platform-errors'; import { PLATFORM_ERRORS } from './platform-errors';
import { COMMERCE_ERRORS } from './commerce-errors'; import { COMMERCE_ERRORS } from './commerce-errors';
export const ERRORS = { ...PLATFORM_ERRORS, ...COMMERCE_ERRORS } as const; export const ERRORS = {
...PLATFORM_ERRORS,
...COMMERCE_ERRORS,
...CHECKOUT_ERRORS,
} as const;
export type ErrorCode = keyof typeof ERRORS; export type ErrorCode = keyof typeof ERRORS;

93
test/cart.spec.ts Normal file
View File

@ -0,0 +1,93 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture } from './helpers/checkout';
import { secondActor, seedProduct } from './helpers/commerce';
describe('private versioned carts', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
});
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('isolates carts, rejects stale edits and removes lines', async () => {
const f = await checkoutFixture(ctx);
const other = await secondActor(ctx);
const own = await ctx
.api()
.get('/api/v1/cart')
.auth(f.actor.token, { type: 'bearer' })
.expect(200);
expect(own.body.lines).toHaveLength(1);
const empty = await ctx
.api()
.get('/api/v1/cart')
.auth(other.token, { type: 'bearer' })
.expect(200);
expect(empty.body).toEqual({ version: 0, lines: [] });
const stale = await ctx
.api()
.put('/api/v1/cart/lines/' + f.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ quantity: 3, version: 0 })
.expect(409);
expect(stale.body.code).toBe('CART_CHANGED');
await ctx
.api()
.put('/api/v1/cart/lines/' + f.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ quantity: 3, version: 1 })
.expect(200);
await ctx
.api()
.delete('/api/v1/cart/lines/' + f.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ version: 2 })
.expect(200);
const result = await ctx
.api()
.get('/api/v1/cart')
.auth(f.actor.token, { type: 'bearer' })
.expect(200);
expect(result.body).toEqual({ version: 3, lines: [] });
await ctx.api().get('/api/v1/cart').expect(401);
});
it('rejects draft, foreign and mixed-currency items and mass assignment', async () => {
const f = await checkoutFixture(ctx);
const draft = await seedProduct(ctx);
await ctx
.api()
.put('/api/v1/cart/lines/' + draft.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ quantity: 1, version: 1 })
.expect(409);
const foreign = await secondActor(ctx, false);
await ctx
.api()
.put('/api/v1/cart/lines/' + f.variant.id)
.auth(foreign.token, { type: 'bearer' })
.send({ quantity: 1, version: 0 })
.expect(409);
const usd = await seedProduct(ctx, true);
await ctx.db.productVariant.update({
where: { id: usd.variant.id },
data: { currency: 'USD' },
});
const response = await ctx
.api()
.put('/api/v1/cart/lines/' + usd.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ quantity: 1, version: 1 })
.expect(409);
expect(response.body.code).toBe('CART_CURRENCY');
await ctx
.api()
.put('/api/v1/cart/lines/' + f.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ quantity: 1, version: 1, price: '0.01' })
.expect(400);
});
});

43
test/helpers/checkout.ts Normal file
View File

@ -0,0 +1,43 @@
import { randomUUID } from 'node:crypto';
import type { IdentityApp } from './identity-app';
import { addressInput, secondActor, seedStock } from './commerce';
export async function checkoutFixture(ctx: IdentityApp, stock = 10) {
const item = await seedStock(ctx, stock);
const actor = await secondActor(ctx);
const address = await ctx
.api()
.post('/api/v1/addresses')
.auth(actor.token, { type: 'bearer' })
.send(addressInput)
.expect(201);
await ctx
.api()
.put('/api/v1/cart/lines/' + item.variant.id)
.auth(actor.token, { type: 'bearer' })
.send({ quantity: 2, version: 0 })
.expect(200);
return {
...item,
actor,
address: address.body,
input: {
cartVersion: 1,
addressId: address.body.id,
idempotencyKey: randomUUID(),
},
};
}
export function couponInput() {
return {
code: 'SAVE-' + randomUUID().slice(0, 8),
kind: 'PERCENT',
percentBps: 1000,
currency: 'INR',
minimumSubtotal: '100.00',
maxUses: 10,
perUserLimit: 1,
startsAt: new Date(Date.now() - 60000).toISOString(),
endsAt: new Date(Date.now() + 3600000).toISOString(),
};
}