feat(cart): add private versioned carts with bounded inputs
This commit is contained in:
parent
6401d2c1c2
commit
9ac40531b1
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export const CURRENCIES = ['INR', 'USD', 'EUR', 'GBP'] as const;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue