From 9ac40531b11c63b1c68eb14635468cdd48731d8e Mon Sep 17 00:00:00 2001 From: mihir Date: Fri, 11 Sep 2026 00:28:44 +0530 Subject: [PATCH] feat(cart): add private versioned carts with bounded inputs --- src/app.module.ts | 2 + src/catalog/catalog.schemas.ts | 3 +- src/checkout/cart.controller.ts | 39 +++++++++++ src/checkout/cart.store.ts | 96 ++++++++++++++++++++++++++++ src/checkout/checkout.module.ts | 11 ++++ src/checkout/checkout.schemas.ts | 22 +++++++ src/common/currency.ts | 1 + src/common/errors/checkout-errors.ts | 50 +++++++++++++++ src/common/errors/error-catalog.ts | 7 +- test/cart.spec.ts | 93 +++++++++++++++++++++++++++ test/helpers/checkout.ts | 43 +++++++++++++ 11 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 src/checkout/cart.controller.ts create mode 100644 src/checkout/cart.store.ts create mode 100644 src/checkout/checkout.module.ts create mode 100644 src/checkout/checkout.schemas.ts create mode 100644 src/common/currency.ts create mode 100644 src/common/errors/checkout-errors.ts create mode 100644 test/cart.spec.ts create mode 100644 test/helpers/checkout.ts diff --git a/src/app.module.ts b/src/app.module.ts index 21ce864..8710228 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,4 @@ +import { CheckoutModule } from './checkout/checkout.module'; import { CatalogModule } from './catalog/catalog.module'; import { AddressesModule } from './addresses/addresses.module'; import { InventoryModule } from './inventory/inventory.module'; @@ -14,6 +15,7 @@ import { HealthModule } from './health/health.module'; CatalogModule, AddressesModule, InventoryModule, + CheckoutModule, ], }) export class AppModule {} diff --git a/src/catalog/catalog.schemas.ts b/src/catalog/catalog.schemas.ts index 6b2108f..66d999e 100644 --- a/src/catalog/catalog.schemas.ts +++ b/src/catalog/catalog.schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { CURRENCIES } from '../common/currency'; import { text, ids } from '../common/input'; import { pageSchema } from '../identity/identity.schemas'; @@ -38,7 +39,7 @@ export const variantSchema = z .string() .regex(/^(0|[1-9]\d{0,9})\.\d{2}$/) .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), attributes: z .record( diff --git a/src/checkout/cart.controller.ts b/src/checkout/cart.controller.ts new file mode 100644 index 0000000..4f4d0d9 --- /dev/null +++ b/src/checkout/cart.controller.ts @@ -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); + } +} diff --git a/src/checkout/cart.store.ts b/src/checkout/cart.store.ts new file mode 100644 index 0000000..b580654 --- /dev/null +++ b/src/checkout/cart.store.ts @@ -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 }; + }); + } +} diff --git a/src/checkout/checkout.module.ts b/src/checkout/checkout.module.ts new file mode 100644 index 0000000..362d6c3 --- /dev/null +++ b/src/checkout/checkout.module.ts @@ -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 {} diff --git a/src/checkout/checkout.schemas.ts b/src/checkout/checkout.schemas.ts new file mode 100644 index 0000000..aa3c0f8 --- /dev/null +++ b/src/checkout/checkout.schemas.ts @@ -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; diff --git a/src/common/currency.ts b/src/common/currency.ts new file mode 100644 index 0000000..6ad4bc3 --- /dev/null +++ b/src/common/currency.ts @@ -0,0 +1 @@ +export const CURRENCIES = ['INR', 'USD', 'EUR', 'GBP'] as const; diff --git a/src/common/errors/checkout-errors.ts b/src/common/errors/checkout-errors.ts new file mode 100644 index 0000000..6d91099 --- /dev/null +++ b/src/common/errors/checkout-errors.ts @@ -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; diff --git a/src/common/errors/error-catalog.ts b/src/common/errors/error-catalog.ts index d8d1774..8bcdbcd 100644 --- a/src/common/errors/error-catalog.ts +++ b/src/common/errors/error-catalog.ts @@ -1,4 +1,9 @@ +import { CHECKOUT_ERRORS } from './checkout-errors'; import { PLATFORM_ERRORS } from './platform-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; diff --git a/test/cart.spec.ts b/test/cart.spec.ts new file mode 100644 index 0000000..eeb4275 --- /dev/null +++ b/test/cart.spec.ts @@ -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); + }); +}); diff --git a/test/helpers/checkout.ts b/test/helpers/checkout.ts new file mode 100644 index 0000000..e3f437e --- /dev/null +++ b/test/helpers/checkout.ts @@ -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(), + }; +}