From 6401d2c1c2757fd52051046dcfb4295913795d57 Mon Sep 17 00:00:00 2001 From: mihir Date: Fri, 11 Sep 2026 00:28:42 +0530 Subject: [PATCH 1/5] feat(data): add append-only checkout migrations and snapshot integrity --- prisma/catalog.prisma | 4 +- prisma/checkout.prisma | 93 ++++++++++++ prisma/inventory.prisma | 4 +- prisma/migration-checksums.json | 6 +- .../migration.sql | 142 ++++++++++++++++++ .../migration.sql | 8 + .../migration.sql | 73 +++++++++ .../migration.sql | 23 +++ prisma/schema.prisma | 3 + 9 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 prisma/checkout.prisma create mode 100644 prisma/migrations/20260910182626_checkout_orders/migration.sql create mode 100644 prisma/migrations/20260910182800_checkout_integrity/migration.sql create mode 100644 prisma/migrations/20260910183016_checkout_snapshot_guards/migration.sql create mode 100644 prisma/migrations/20260910184357_order_reconciliation/migration.sql diff --git a/prisma/catalog.prisma b/prisma/catalog.prisma index 235a786..4829d90 100644 --- a/prisma/catalog.prisma +++ b/prisma/catalog.prisma @@ -35,6 +35,8 @@ model ProductVariant { attributes Json @default("{}") active Boolean @default(true) stockItems StockItem[] + cartLines CartLine[] + orderLines OrderLine[] product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Restrict) @@unique([organizationId, sku]) @@unique([id, organizationId]) @@ -63,5 +65,3 @@ model ProductGroup { @@index([groupId, organizationId]) @@map("product_groups") } - - diff --git a/prisma/checkout.prisma b/prisma/checkout.prisma new file mode 100644 index 0000000..5e93729 --- /dev/null +++ b/prisma/checkout.prisma @@ -0,0 +1,93 @@ +enum OrderStatus { + PENDING_PAYMENT + CANCELLED +} +enum CouponKind { + FIXED + PERCENT +} +model Cart { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + userId String @map("user_id") @db.Uuid + version Int @default(0) + user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict) + lines CartLine[] + @@unique([userId, organizationId]) + @@unique([id, organizationId]) + @@map("carts") +} +model CartLine { + id String @id @default(uuid()) @db.Uuid + cartId String @map("cart_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + variantId String @map("variant_id") @db.Uuid + quantity Int + cart Cart @relation(fields: [cartId, organizationId], references: [id, organizationId], onDelete: Cascade) + variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict) + @@unique([cartId, variantId]) + @@map("cart_lines") +} +model Coupon { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + code String @db.VarChar(40) + kind CouponKind + currency String @db.Char(3) + amount Decimal? @db.Decimal(12,2) + percentBps Int? @map("percent_bps") + minimumSubtotal Decimal @default(0) @map("minimum_subtotal") @db.Decimal(12,2) + maxUses Int @map("max_uses") + perUserLimit Int @map("per_user_limit") + startsAt DateTime @map("starts_at") @db.Timestamptz(3) + endsAt DateTime @map("ends_at") @db.Timestamptz(3) + active Boolean @default(true) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + orders Order[] + @@unique([organizationId, code]) + @@unique([id, organizationId]) + @@map("coupons") +} +model Order { + id String @id @default(uuid()) @db.Uuid + organizationId String @map("organization_id") @db.Uuid + userId String @map("user_id") @db.Uuid + status OrderStatus @default(PENDING_PAYMENT) + currency String @db.Char(3) + subtotal Decimal @db.Decimal(16,2) + discount Decimal @db.Decimal(16,2) + merchandiseTotal Decimal @map("merchandise_total") @db.Decimal(16,2) + addressSnapshot Json @map("address_snapshot") + couponSnapshot Json? @map("coupon_snapshot") + couponId String? @map("coupon_id") @db.Uuid + idempotencyKey String @map("idempotency_key") @db.Uuid + requestHash String @map("request_hash") @db.Char(64) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict) + coupon Coupon? @relation(fields: [couponId, organizationId], references: [id, organizationId], onDelete: Restrict) + lines OrderLine[] + reservations StockReservation[] + @@unique([userId, organizationId, idempotencyKey]) + @@unique([id, organizationId]) + @@index([organizationId, userId, createdAt, id]) + @@unique([id, userId, organizationId]) + @@index([couponId, status, expiresAt]) + @@map("orders") +} +model OrderLine { + id String @id @default(uuid()) @db.Uuid + orderId String @map("order_id") @db.Uuid + organizationId String @map("organization_id") @db.Uuid + variantId String @map("variant_id") @db.Uuid + sku String @db.VarChar(64) + productName String @map("product_name") @db.VarChar(160) + variantName String @map("variant_name") @db.VarChar(160) + quantity Int + unitPrice Decimal @map("unit_price") @db.Decimal(12,2) + lineTotal Decimal @map("line_total") @db.Decimal(16,2) + order Order @relation(fields: [orderId, organizationId], references: [id, organizationId], onDelete: Restrict) + variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict) + @@unique([orderId, variantId]) + @@map("order_lines") +} diff --git a/prisma/inventory.prisma b/prisma/inventory.prisma index 58a53ac..b0be7a3 100644 --- a/prisma/inventory.prisma +++ b/prisma/inventory.prisma @@ -50,6 +50,8 @@ model StockReservation { organizationId String @map("organization_id") @db.Uuid userId String @map("user_id") @db.Uuid user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict) + orderId String? @map("order_id") @db.Uuid + order Order? @relation(fields: [orderId, userId, organizationId], references: [id, userId, organizationId], onDelete: Restrict) quantity Int status ReservationStatus @default(ACTIVE) idempotencyKey String @map("idempotency_key") @db.Uuid @@ -62,5 +64,3 @@ model StockReservation { @@index([userId, organizationId]) @@map("stock_reservations") } - - diff --git a/prisma/migration-checksums.json b/prisma/migration-checksums.json index 587b350..9e5c06e 100644 --- a/prisma/migration-checksums.json +++ b/prisma/migration-checksums.json @@ -5,5 +5,9 @@ "20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84", "20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36", "20260909141622_commerce_integrity": "30545784aa33f35783c0170b80de2bded32be8781e25e027c4e5e2e5da8348ac", - "20260909153911_inventory_actor_scope": "7d6d2df3a8229032022f5a2ce43ed37c508c4a71743fd2bc6c24786634611d1e" + "20260909153911_inventory_actor_scope": "7d6d2df3a8229032022f5a2ce43ed37c508c4a71743fd2bc6c24786634611d1e", + "20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb", + "20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2", + "20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7", + "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336" } diff --git a/prisma/migrations/20260910182626_checkout_orders/migration.sql b/prisma/migrations/20260910182626_checkout_orders/migration.sql new file mode 100644 index 0000000..182cc6e --- /dev/null +++ b/prisma/migrations/20260910182626_checkout_orders/migration.sql @@ -0,0 +1,142 @@ +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('PENDING_PAYMENT', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "CouponKind" AS ENUM ('FIXED', 'PERCENT'); + +-- AlterTable +ALTER TABLE "stock_reservations" ADD COLUMN "order_id" UUID; + +-- CreateTable +CREATE TABLE "carts" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "version" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "carts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cart_lines" ( + "id" UUID NOT NULL, + "cart_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "variant_id" UUID NOT NULL, + "quantity" INTEGER NOT NULL, + + CONSTRAINT "cart_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "coupons" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "code" VARCHAR(40) NOT NULL, + "kind" "CouponKind" NOT NULL, + "currency" CHAR(3) NOT NULL, + "amount" DECIMAL(12,2), + "percent_bps" INTEGER, + "minimum_subtotal" DECIMAL(12,2) NOT NULL DEFAULT 0, + "max_uses" INTEGER NOT NULL, + "per_user_limit" INTEGER NOT NULL, + "starts_at" TIMESTAMPTZ(3) NOT NULL, + "ends_at" TIMESTAMPTZ(3) NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "coupons_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders" ( + "id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "status" "OrderStatus" NOT NULL DEFAULT 'PENDING_PAYMENT', + "currency" CHAR(3) NOT NULL, + "subtotal" DECIMAL(16,2) NOT NULL, + "discount" DECIMAL(16,2) NOT NULL, + "merchandise_total" DECIMAL(16,2) NOT NULL, + "address_snapshot" JSONB NOT NULL, + "coupon_snapshot" JSONB, + "coupon_id" UUID, + "idempotency_key" UUID NOT NULL, + "request_hash" CHAR(64) NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_lines" ( + "id" UUID NOT NULL, + "order_id" UUID NOT NULL, + "organization_id" UUID NOT NULL, + "variant_id" UUID NOT NULL, + "sku" VARCHAR(64) NOT NULL, + "product_name" VARCHAR(160) NOT NULL, + "variant_name" VARCHAR(160) NOT NULL, + "quantity" INTEGER NOT NULL, + "unit_price" DECIMAL(12,2) NOT NULL, + "line_total" DECIMAL(16,2) NOT NULL, + + CONSTRAINT "order_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "carts_user_id_organization_id_key" ON "carts"("user_id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "carts_id_organization_id_key" ON "carts"("id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "cart_lines_cart_id_variant_id_key" ON "cart_lines"("cart_id", "variant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "coupons_organization_id_code_key" ON "coupons"("organization_id", "code"); + +-- CreateIndex +CREATE UNIQUE INDEX "coupons_id_organization_id_key" ON "coupons"("id", "organization_id"); + +-- CreateIndex +CREATE INDEX "orders_organization_id_user_id_created_at_id_idx" ON "orders"("organization_id", "user_id", "created_at", "id"); + +-- CreateIndex +CREATE INDEX "orders_coupon_id_status_expires_at_idx" ON "orders"("coupon_id", "status", "expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_user_id_organization_id_idempotency_key_key" ON "orders"("user_id", "organization_id", "idempotency_key"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_id_organization_id_key" ON "orders"("id", "organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "order_lines_order_id_variant_id_key" ON "order_lines"("order_id", "variant_id"); + +-- AddForeignKey +ALTER TABLE "carts" ADD CONSTRAINT "carts_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_lines" ADD CONSTRAINT "cart_lines_cart_id_organization_id_fkey" FOREIGN KEY ("cart_id", "organization_id") REFERENCES "carts"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cart_lines" ADD CONSTRAINT "cart_lines_variant_id_organization_id_fkey" FOREIGN KEY ("variant_id", "organization_id") REFERENCES "product_variants"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "coupons" ADD CONSTRAINT "coupons_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_coupon_id_organization_id_fkey" FOREIGN KEY ("coupon_id", "organization_id") REFERENCES "coupons"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_order_id_organization_id_fkey" FOREIGN KEY ("order_id", "organization_id") REFERENCES "orders"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_variant_id_organization_id_fkey" FOREIGN KEY ("variant_id", "organization_id") REFERENCES "product_variants"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_order_id_organization_id_fkey" FOREIGN KEY ("order_id", "organization_id") REFERENCES "orders"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260910182800_checkout_integrity/migration.sql b/prisma/migrations/20260910182800_checkout_integrity/migration.sql new file mode 100644 index 0000000..a3ff189 --- /dev/null +++ b/prisma/migrations/20260910182800_checkout_integrity/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "stock_reservations" DROP CONSTRAINT "stock_reservations_order_id_organization_id_fkey"; + +-- CreateIndex +CREATE UNIQUE INDEX "orders_id_user_id_organization_id_key" ON "orders"("id", "user_id", "organization_id"); + +-- AddForeignKey +ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_order_id_user_id_organization_id_fkey" FOREIGN KEY ("order_id", "user_id", "organization_id") REFERENCES "orders"("id", "user_id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260910183016_checkout_snapshot_guards/migration.sql b/prisma/migrations/20260910183016_checkout_snapshot_guards/migration.sql new file mode 100644 index 0000000..c4bc9c9 --- /dev/null +++ b/prisma/migrations/20260910183016_checkout_snapshot_guards/migration.sql @@ -0,0 +1,73 @@ +ALTER TABLE carts ADD CONSTRAINT cart_version_nonnegative CHECK (version >= 0); +ALTER TABLE cart_lines ADD CONSTRAINT cart_quantity_bounds CHECK (quantity BETWEEN 1 AND 100); +ALTER TABLE coupons ADD CONSTRAINT coupon_rule_valid CHECK ( + (kind = 'FIXED' AND amount IS NOT NULL AND amount > 0 AND percent_bps IS NULL) + OR (kind = 'PERCENT' AND amount IS NULL AND percent_bps BETWEEN 1 AND 10000 AND percent_bps IS NOT NULL) +); +ALTER TABLE coupons ADD CONSTRAINT coupon_limits_valid CHECK ( + minimum_subtotal >= 0 AND max_uses > 0 AND per_user_limit > 0 AND per_user_limit <= max_uses + AND ends_at > starts_at AND code ~ '^[A-Z0-9][A-Z0-9_-]{0,39}$' + AND currency IN ('INR', 'USD', 'EUR', 'GBP') +); +ALTER TABLE orders ADD CONSTRAINT order_totals_valid CHECK ( + subtotal > 0 AND discount >= 0 AND discount <= subtotal + AND merchandise_total = subtotal - discount AND expires_at > created_at +); +ALTER TABLE order_lines ADD CONSTRAINT order_line_totals_valid CHECK ( + quantity BETWEEN 1 AND 100 AND unit_price > 0 AND line_total = unit_price * quantity +); + +CREATE FUNCTION protect_order_snapshot() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Orders cannot be deleted'; END IF; + IF (to_jsonb(NEW) - 'status') IS DISTINCT FROM (to_jsonb(OLD) - 'status') THEN + RAISE EXCEPTION 'Order snapshots are immutable'; + END IF; + IF OLD.status = 'CANCELLED' AND NEW.status <> 'CANCELLED' THEN + RAISE EXCEPTION 'Cancelled orders cannot be reopened'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER orders_snapshot_immutable BEFORE UPDATE OR DELETE ON orders +FOR EACH ROW EXECUTE FUNCTION protect_order_snapshot(); + +CREATE FUNCTION protect_order_line() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'Order lines are immutable'; +END; +$$; +CREATE TRIGGER order_lines_immutable BEFORE UPDATE OR DELETE ON order_lines +FOR EACH ROW EXECUTE FUNCTION protect_order_line(); + +CREATE FUNCTION protect_coupon_rules() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF (to_jsonb(NEW) - 'active') IS DISTINCT FROM (to_jsonb(OLD) - 'active') THEN + RAISE EXCEPTION 'Create a new coupon to change discount rules'; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER coupon_rules_immutable BEFORE UPDATE ON coupons +FOR EACH ROW EXECUTE FUNCTION protect_coupon_rules(); + +CREATE FUNCTION protect_order_reservation() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.order_id IS NOT NULL THEN + IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Order reservations cannot be deleted'; END IF; + IF (to_jsonb(NEW) - 'status') IS DISTINCT FROM (to_jsonb(OLD) - 'status') THEN + RAISE EXCEPTION 'Order reservation allocation is immutable'; + END IF; + END IF; + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER order_reservation_immutable BEFORE UPDATE OR DELETE ON stock_reservations +FOR EACH ROW EXECUTE FUNCTION protect_order_reservation(); + +UPDATE roles SET permissions = ARRAY( + SELECT DISTINCT permission FROM unnest(permissions || ARRAY[ + 'coupons.manage', 'orders.read', 'orders.manage' + ]::text[]) AS permission ORDER BY permission +) WHERE is_system = true; diff --git a/prisma/migrations/20260910184357_order_reconciliation/migration.sql b/prisma/migrations/20260910184357_order_reconciliation/migration.sql new file mode 100644 index 0000000..d8a5014 --- /dev/null +++ b/prisma/migrations/20260910184357_order_reconciliation/migration.sql @@ -0,0 +1,23 @@ +-- Deferred reconciliation permits nested line creation in the same transaction +-- while preventing incomplete orders or later additions to an existing snapshot. +CREATE FUNCTION reconcile_order_lines() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + target_order UUID; + expected NUMERIC; + actual NUMERIC; +BEGIN + IF TG_TABLE_NAME = 'orders' THEN target_order := NEW.id; + ELSE target_order := NEW.order_id; + END IF; + SELECT subtotal INTO expected FROM orders WHERE id = target_order; + SELECT COALESCE(SUM(line_total), 0) INTO actual FROM order_lines WHERE order_id = target_order; + IF expected IS DISTINCT FROM actual THEN + RAISE EXCEPTION 'Order subtotal does not match lines'; + END IF; + RETURN NULL; +END; +$$; +CREATE CONSTRAINT TRIGGER orders_reconcile_lines AFTER INSERT ON orders +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION reconcile_order_lines(); +CREATE CONSTRAINT TRIGGER order_lines_reconcile_total AFTER INSERT ON order_lines +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION reconcile_order_lines(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d697f8c..157b82b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model Organization { products Product[] catalogGroups CatalogGroup[] warehouses Warehouse[] + coupons Coupon[] @@map("organizations") } model User { @@ -41,6 +42,8 @@ model User { addresses Address[] stockEntries StockLedger[] stockReservations StockReservation[] + carts Cart[] + orders Order[] @@unique([organizationId, email]) @@unique([id, organizationId]) @@index([organizationId, createdAt, id]) -- 2.40.1 From 9ac40531b11c63b1c68eb14635468cdd48731d8e Mon Sep 17 00:00:00 2001 From: mihir Date: Fri, 11 Sep 2026 00:28:44 +0530 Subject: [PATCH 2/5] 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(), + }; +} -- 2.40.1 From aacfe6c171864d156834738b0ea577430052e0d9 Mon Sep 17 00:00:00 2001 From: mihir Date: Fri, 11 Sep 2026 00:28:45 +0530 Subject: [PATCH 3/5] feat(coupons): add exact discounts and scoped eligibility rules --- src/checkout/checkout.module.ts | 6 +- src/checkout/money.ts | 26 +++++++++ src/coupons/coupon-policy.ts | 59 +++++++++++++++++++ src/coupons/coupon.schema.ts | 38 +++++++++++++ src/coupons/coupon.store.ts | 55 ++++++++++++++++++ src/coupons/coupons.controller.ts | 51 +++++++++++++++++ src/identity/permissions.ts | 3 + test/checkout-policy.spec.ts | 94 +++++++++++++++++++++++++++++++ 8 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 src/checkout/money.ts create mode 100644 src/coupons/coupon-policy.ts create mode 100644 src/coupons/coupon.schema.ts create mode 100644 src/coupons/coupon.store.ts create mode 100644 src/coupons/coupons.controller.ts create mode 100644 test/checkout-policy.spec.ts diff --git a/src/checkout/checkout.module.ts b/src/checkout/checkout.module.ts index 362d6c3..33e9db0 100644 --- a/src/checkout/checkout.module.ts +++ b/src/checkout/checkout.module.ts @@ -3,9 +3,11 @@ import { DatabaseModule } from '../database/database.module'; import { IdentityModule } from '../identity/identity.module'; import { CartStore } from './cart.store'; import { CartController } from './cart.controller'; +import { CouponStore } from '../coupons/coupon.store'; +import { CouponsController } from '../coupons/coupons.controller'; @Module({ imports: [DatabaseModule, IdentityModule], - controllers: [CartController], - providers: [CartStore], + controllers: [CartController, CouponsController], + providers: [CartStore, CouponStore], }) export class CheckoutModule {} diff --git a/src/checkout/money.ts b/src/checkout/money.ts new file mode 100644 index 0000000..9ab6a1e --- /dev/null +++ b/src/checkout/money.ts @@ -0,0 +1,26 @@ +import { AppError } from '../common/errors/app-error'; + +export function minor(value: string): bigint { + if (!/^\d+(\.\d{1,2})?$/.test(value)) throw new AppError('MONEY_RANGE'); + const [whole, fraction = ''] = value.split('.'); + return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')); +} +export function decimal(value: bigint): string { + if (value < 0n || value > 9999999999999999n) + throw new AppError('MONEY_RANGE'); + return `${value / 100n}.${(value % 100n).toString().padStart(2, '0')}`; +} +export function discountFor( + subtotal: bigint, + coupon: { + kind: 'FIXED' | 'PERCENT'; + amount: string | null; + percentBps: number | null; + }, +): bigint { + const amount = + coupon.kind === 'FIXED' + ? minor(coupon.amount!) + : (subtotal * BigInt(coupon.percentBps!) + 5000n) / 10000n; + return amount > subtotal ? subtotal : amount; +} diff --git a/src/coupons/coupon-policy.ts b/src/coupons/coupon-policy.ts new file mode 100644 index 0000000..e6d37e6 --- /dev/null +++ b/src/coupons/coupon-policy.ts @@ -0,0 +1,59 @@ +import type { Coupon, Prisma } from '../generated/prisma/client'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import { minor, discountFor } from '../checkout/money'; + +export function assertCoupon( + coupon: Coupon | null, + currency: string, + subtotal: bigint, + now: Date, + uses: number, + userUses: number, +): asserts coupon is Coupon { + if (!coupon) throw new AppError('COUPON_INELIGIBLE', 'COUPON_UNKNOWN'); + const failures: [boolean, string][] = [ + [!coupon.active, 'COUPON_DISABLED'], + [coupon.currency !== currency, 'COUPON_CURRENCY'], + [coupon.startsAt > now, 'COUPON_NOT_STARTED'], + [coupon.endsAt <= now, 'COUPON_EXPIRED'], + [minor(coupon.minimumSubtotal.toString()) > subtotal, 'COUPON_MINIMUM'], + [uses >= coupon.maxUses, 'COUPON_TOTAL_LIMIT'], + [userUses >= coupon.perUserLimit, 'COUPON_USER_LIMIT'], + ]; + const failure = failures.find(([failed]) => failed); + if (failure) throw new AppError('COUPON_INELIGIBLE', failure[1]); +} +export async function priceCoupon( + tx: Prisma.TransactionClient, + actor: Principal, + code: string | undefined, + currency: string, + subtotal: bigint, + now: Date, +) { + if (!code) return { discount: 0n, coupon: null }; + const coupon = await tx.coupon.findUnique({ + where: { + organizationId_code: { organizationId: actor.organizationId, code }, + }, + }); + const where = { + couponId: coupon?.id ?? '00000000-0000-0000-0000-000000000000', + status: 'PENDING_PAYMENT' as const, + expiresAt: { gt: now }, + }; + const uses = await tx.order.count({ where }); + const userUses = await tx.order.count({ + where: { ...where, userId: actor.userId }, + }); + assertCoupon(coupon, currency, subtotal, now, uses, userUses); + return { + coupon, + discount: discountFor(subtotal, { + kind: coupon.kind, + amount: coupon.amount?.toString() ?? null, + percentBps: coupon.percentBps, + }), + }; +} diff --git a/src/coupons/coupon.schema.ts b/src/coupons/coupon.schema.ts new file mode 100644 index 0000000..52164f8 --- /dev/null +++ b/src/coupons/coupon.schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { CURRENCIES } from '../common/currency'; +import { couponCode } from '../checkout/checkout.schemas'; +const amount = z.string().regex(/^(0|[1-9]\d{0,9})\.\d{2}$/); +const common = z.object({ + code: couponCode, + currency: z.enum(CURRENCIES), + minimumSubtotal: amount.default('0.00'), + maxUses: z.number().int().min(1).max(1000000), + perUserLimit: z.number().int().min(1).max(100), + startsAt: z.iso + .datetime({ offset: true }) + .transform((value) => new Date(value)), + endsAt: z.iso + .datetime({ offset: true }) + .transform((value) => new Date(value)), +}); +export const couponSchema = z + .discriminatedUnion('kind', [ + common + .extend({ + kind: z.literal('FIXED'), + amount: amount.refine((value) => value !== '0.00'), + }) + .strict(), + common + .extend({ + kind: z.literal('PERCENT'), + percentBps: z.number().int().min(1).max(10000), + }) + .strict(), + ]) + .refine((value) => value.endsAt > value.startsAt, { path: ['endsAt'] }) + .refine((value) => value.perUserLimit <= value.maxUses, { + path: ['perUserLimit'], + }); +export const couponStatusSchema = z.object({ active: z.boolean() }).strict(); +export type CouponInput = z.infer; diff --git a/src/coupons/coupon.store.ts b/src/coupons/coupon.store.ts new file mode 100644 index 0000000..74d33d3 --- /dev/null +++ b/src/coupons/coupon.store.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { AccessStore } from '../identity/access.store'; +import type { Principal } from '../identity/identity.types'; +import { recordAudit } from '../identity/audit'; +import { AppError } from '../common/errors/app-error'; +import type { CouponInput } from './coupon.schema'; + +@Injectable() +export class CouponStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + list(actor: Principal, page: { limit: number; offset: number }) { + return this.db.coupon.findMany({ + where: { organizationId: actor.organizationId }, + take: page.limit, + skip: page.offset, + orderBy: { id: 'asc' }, + }); + } + create(actor: Principal, input: CouponInput) { + return this.access.mutate(actor, 'coupons.manage', async (tx) => { + const coupon = await tx.coupon.create({ + data: { ...input, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'coupon.created', + coupon.id, + ); + return coupon; + }); + } + status(actor: Principal, id: string, active: boolean) { + return this.access.mutate(actor, 'coupons.manage', async (tx) => { + const coupon = await tx.coupon.findFirst({ + where: { id, organizationId: actor.organizationId }, + }); + if (!coupon) throw new AppError('COUPON_NOT_FOUND'); + const row = await tx.coupon.update({ where: { id }, data: { active } }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'coupon.status.changed', + id, + ); + return row; + }); + } +} diff --git a/src/coupons/coupons.controller.ts b/src/coupons/coupons.controller.ts new file mode 100644 index 0000000..c588e52 --- /dev/null +++ b/src/coupons/coupons.controller.ts @@ -0,0 +1,51 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { SchemaPipe } from '../common/validation.pipe'; +import { pageSchema } from '../identity/identity.schemas'; +import { + couponSchema, + couponStatusSchema, + type CouponInput, +} from './coupon.schema'; +import { CouponStore } from './coupon.store'; + +@Controller('coupons') +@RequirePermission('coupons.manage') +export class CouponsController { + constructor(private readonly coupons: CouponStore) {} + @Get() + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: { limit: number; offset: number }, + ) { + return this.coupons.list(actor, page); + } + @Post() + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(couponSchema)) input: CouponInput, + ) { + return this.coupons.create(actor, input); + } + @Patch(':id/status') + status( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Body(new SchemaPipe(couponStatusSchema)) input: { active: boolean }, + ) { + return this.coupons.status(actor, id, input.active); + } +} diff --git a/src/identity/permissions.ts b/src/identity/permissions.ts index e52b281..febcbd0 100644 --- a/src/identity/permissions.ts +++ b/src/identity/permissions.ts @@ -1,4 +1,7 @@ export const PERMISSIONS = [ + 'coupons.manage', + 'orders.read', + 'orders.manage', 'users.read', 'users.create', 'users.approve', diff --git a/test/checkout-policy.spec.ts b/test/checkout-policy.spec.ts new file mode 100644 index 0000000..f018fa9 --- /dev/null +++ b/test/checkout-policy.spec.ts @@ -0,0 +1,94 @@ +import { decimal, minor, discountFor } from '../src/checkout/money'; +import { couponSchema } from '../src/coupons/coupon.schema'; +import { + cartLineSchema, + checkoutSchema, +} from '../src/checkout/checkout.schemas'; +import { assertCoupon } from '../src/coupons/coupon-policy'; +import { Prisma, type Coupon } from '../src/generated/prisma/client'; +import { couponInput } from './helpers/checkout'; + +describe('checkout money and eligibility policies', () => { + it('uses exact minor units and round-half-up percentage discounts', () => { + expect(minor('0.10') + minor('0.20')).toBe(30n); + expect(minor('499')).toBe(49900n); + expect(minor('1.5')).toBe(150n); + expect(decimal(1n)).toBe('0.01'); + expect( + discountFor(101n, { kind: 'PERCENT', percentBps: 5000, amount: null }), + ).toBe(51n); + expect( + discountFor(100n, { kind: 'FIXED', amount: '5.00', percentBps: null }), + ).toBe(100n); + expect( + discountFor(1000n, { kind: 'FIXED', amount: '1.50', percentBps: null }), + ).toBe(150n); + expect(decimal(9999999999999999n)).toBe('99999999999999.99'); + for (const value of ['-1', '0.001', 'NaN', '1e3']) + expect(() => minor(value)).toThrow(); + expect(() => decimal(-1n)).toThrow(); + expect(() => decimal(10000000000000000n)).toThrow(); + }); + it('validates coupon dates, currency, amount, quotas and mutually exclusive rules', () => { + const input = couponInput(); + expect(couponSchema.parse(input).code).toBe(input.code.toUpperCase()); + for (const change of [ + { endsAt: input.startsAt }, + { perUserLimit: 11 }, + { percentBps: 10001 }, + { amount: '2.00' }, + { currency: 'XXX' }, + { code: '