Compare commits

...

6 Commits

48 changed files with 2374 additions and 17 deletions

View File

@ -30,3 +30,4 @@ Production must use a managed secret store, TLS termination, a restricted databa
Identity: see [API contract](docs/identity-api.md) and [setup/release guide](docs/identity-operations.md). Identity: see [API contract](docs/identity-api.md) and [setup/release guide](docs/identity-operations.md).
Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/commerce-api.md), [errors](docs/error-contract.md), [migrations](docs/migrations.md), [security preparation](docs/vapt-readiness.md) and [verification](docs/verification.md). Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/commerce-api.md), [errors](docs/error-contract.md), [migrations](docs/migrations.md), [security preparation](docs/vapt-readiness.md) and [verification](docs/verification.md).
Phase 1D adds versioned carts, coupons, atomic checkout and private order snapshots. See [checkout API and pricing boundary](docs/checkout-api.md). Payment remains disabled until tax, shipping and payment rules are finalized.

49
docs/checkout-api.md Normal file
View File

@ -0,0 +1,49 @@
# Phase 1D: carts, coupons and orders
All paths below start with /api/v1. Customer routes require an active bearer session and operate only on that user's organization and data. Customers do not need staff inventory permissions. Accounts currently use the existing provisioning flow.
| Method and path | Access |
| -------------------------------------------- | --------------------------------------------------------------------------- |
| GET /cart | Own cart |
| PUT /cart/lines/:variantId | Own cart; body: quantity, version |
| DELETE /cart/lines/:variantId | Own cart; body: version |
| POST /checkout | Own cart; body: cartVersion, addressId, idempotencyKey, optional couponCode |
| GET /orders, GET /orders/:id | Own orders |
| POST /orders/:id/cancel | Own order |
| GET /admin/orders, GET /admin/orders/:id | orders.read |
| POST /admin/orders/:id/cancel | orders.manage |
| GET/POST /coupons, PATCH /coupons/:id/status | coupons.manage |
Lists accept limit (1100, default 25) and offset (010000). Staff order lists return summaries; private address snapshots are available only in authorized detail responses. New permissions are granted to existing system roles by migration; custom roles keep their explicit permissions.
## Cart and checkout contract
GET /cart returns version and lines with current product information. PUT sets an absolute quantity of 1100. A cart contains at most 20 variants in one currency. Every edit requires the last version and increments it. Stale writes fail with CART_CHANGED. A cart does not hold stock.
POST /checkout requires a UUID idempotencyKey and the expected cartVersion. It rechecks product availability, calculates current server prices, validates a private saved address and any coupon, creates immutable snapshots and reserves stock for 15 minutes. It then clears the cart and increments its version. These writes and the audit record share one transaction; failures preserve the cart and roll back orders and holds.
A matching retry returns the original order with its current status even after cart clearing. Reusing the same key with changed input returns IDEMPOTENCY_CONFLICT. Keys are scoped to the user and organization. Use a new key for a new checkout intent.
Orders snapshot SKU, product/variant names, unit prices, quantities, line totals, currency, coupon rules, subtotal, discount, merchandise total and address. Later catalog/address edits do not alter them. Decimal output strings can omit trailing zeroes. Calculations use integer minor units and percentage discounts round half up to the nearest minor unit. SQL enforces line arithmetic and reconciles order subtotals at transaction commit.
## Pricing and payment boundary
Orders start as PENDING_PAYMENT but expose pricingStatus UNFINALIZED, paymentAvailable false, and null taxTotal, shippingTotal and payableTotal. Merchandise total is subtotal minus discount; it is not a final amount to charge. Null charges must never be rendered as free shipping or zero tax.
Tax and shipping rules have not been supplied. This phase makes no assumption about tax treatment or delivery charges and does not create payments. Before enabling payment, finalize and snapshot those rules through a new migration and implement the verified payment lifecycle in Phase 1E. A fully discounted order still requires that workflow.
## Coupon rules
POST /coupons accepts code, currency, minimumSubtotal, maxUses, perUserLimit, startsAt and endsAt. FIXED coupons also require a positive amount string with two fractional digits. PERCENT coupons require percentBps from 1 to 10000; 1000 means 10%. Codes are normalized to uppercase. Dates require explicit timezone offsets. Ends must follow starts.
Only one coupon can apply to an order. It must be active, within its date window, in the cart currency, above the minimum subtotal and within total and per-user usage limits. Fixed discounts are capped at subtotal. Rules are immutable; create a new code to change them. PATCH status accepts only active.
Usage is held by unexpired pending orders. Cancellation and expiry free that capacity. Future paid-order redemption must remain counted when payments are introduced. Ineligible customer responses remain generic; logs carry distinct diagnostic reasons without exposing private data.
## Inventory and order lifecycle
Checkout selects available stock across warehouses in stable stock-ID order, with a maximum of 200 candidate stock items per checkout. Stock row locks coordinate with standalone reservations and adjustments. An account may have at most ten unexpired pending orders.
Reservations reduce availability, not on-hand stock. Order holds cannot be committed/released through standalone inventory APIs. Cancellation releases all holds atomically and is idempotent; it does not rebuild the cart. After 15 minutes, pending orders display EXPIRED and their holds stop consuming availability without a cleanup job. Expired orders are not payable. The later payment phase must recheck expiry and define stock commitment before fulfillment.
The current organization lock serializes checkout with catalog, address, coupon and cart writes. This favors correctness within the existing architecture; benchmark contention on native PostgreSQL before scaling traffic. Native tests cover duplicate requests, coupon competition and checkout versus standalone inventory reservation.

View File

@ -7,3 +7,4 @@ Each defined error has a distinct code and message. The global filter logs the e
Login failures deliberately share a public message to prevent enumeration; internal diagnostics distinguish causes. Unknown failures return INTERNAL_FAILURE with a safe message. Known database failures are classified centrally. Login failures deliberately share a public message to prevent enumeration; internal diagnostics distinguish causes. Unknown failures return INTERNAL_FAILURE with a safe message. Known database failures are classified centrally.
Responses include X-Request-Id and Cache-Control: no-store. Throttled responses include Retry-After seconds. Client request IDs are not trusted. Configure restricted log access, retention and alerting at deployment. Responses include X-Request-Id and Cache-Control: no-store. Throttled responses include Retry-After seconds. Client request IDs are not trusted. Configure restricted log access, retention and alerting at deployment.
Checkout codes are defined in src/common/errors/checkout-errors.ts. Coupon eligibility failures share a safe public response with separate internal diagnostic reasons.

View File

@ -12,3 +12,4 @@ The check normalizes line endings and rejects modified or missing recorded migra
Production uses `pnpm db:deploy`, then `pnpm db:status`. Never use db push in production. Schema diff tooling targets the whole prisma directory. Destructive changes need an expand/backfill/contract rollout and recovery planning. Production uses `pnpm db:deploy`, then `pnpm db:status`. Never use db push in production. Schema diff tooling targets the whole prisma directory. Destructive changes need an expand/backfill/contract rollout and recovery planning.
Phase 1C appends four migrations after the original three: catalog/addresses, inventory, commerce integrity and inventory actor scope. SQL maintains additional integrity constraints and the append-only ledger trigger. Phase 1C appends four migrations after the original three: catalog/addresses, inventory, commerce integrity and inventory actor scope. SQL maintains additional integrity constraints and the append-only ledger trigger.
Phase 1D appends checkout tables, reservation ownership, immutable snapshot guards and deferred order/line reconciliation. Earlier migrations are unchanged.

View File

@ -7,7 +7,7 @@ Source: Mani Candles Commerce Platform specification and project pack created in
- 1A (implemented): service bootstrap, configuration, database lifecycle, initial organization migration, health API, test/build baseline, team workflow. - 1A (implemented): service bootstrap, configuration, database lifecycle, initial organization migration, health API, test/build baseline, team workflow.
- 1B (implemented; native CI and deployment configuration pending): authentication, sessions, recovery, users, configurable RBAC, organization access, approval status, audit events. Test denied access and cross-organization access. - 1B (implemented; native CI and deployment configuration pending): authentication, sessions, recovery, users, configurable RBAC, organization access, approval status, audit events. Test denied access and cross-organization access.
- 1C (implemented; native concurrency CI pending): catalog, variants, collections, addresses, inventory ledger and reservations. Test concurrent reservation and stock reconciliation. - 1C (implemented; native concurrency CI pending): catalog, variants, collections, addresses, inventory ledger and reservations. Test concurrent reservation and stock reconciliation.
- 1D: cart, checkout, orders, coupons and pricing snapshots. Test money precision, discount eligibility, retries and transaction rollback. - 1D (core implemented; tax/shipping rules, payment enablement and native CI pending): cart, checkout, orders, coupons and pricing snapshots. Test money precision, discount eligibility, retries and transaction rollback.
- 1E: verified payments/refunds, shipping/tracking, returns, notifications and operational dashboard. Test signatures, replay, partial fulfillment and reconciliation. - 1E: verified payments/refunds, shipping/tracking, returns, notifications and operational dashboard. Test signatures, replay, partial fulfillment and reconciliation.
## Phase 2: Internal operations ## Phase 2: Internal operations

View File

@ -11,3 +11,4 @@ Before release, run native PostgreSQL concurrency tests and Gitea CI, validate T
Live SMTP and the recovery frontend remain pending. Recovery delivery is synchronous until the notification queue milestone; assess timing-based enumeration with the real adapter. No external email or production deployment was performed. Live SMTP and the recovery frontend remain pending. Recovery delivery is synchronous until the notification queue milestone; assess timing-based enumeration with the real adapter. No external email or production deployment was performed.
References: [OWASP ASVS](https://owasp.org/www-project-application-security-verification-standard/) and [Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html). Conformance has not been independently assessed. References: [OWASP ASVS](https://owasp.org/www-project-application-security-verification-standard/) and [Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html). Conformance has not been independently assessed.
Phase 1D adds version checks, scoped idempotency, immutable order snapshots, deferred total reconciliation, order-held stock, coupon quotas and resource bounds. Native checkout race tests are included but await TEST_DATABASE_URL. Tax/shipping pricing and payment enablement remain blocked on business rules and Phase 1E.

View File

@ -1,14 +1,14 @@
# Verification record — Phase 1C # Verification record — Phase 1D
- 131 passing tests across 21 suites; two native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL. - 154 passing tests across 28 suites; five native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL (one suite is entirely native).
- Coverage: 99.61% statements, 99.56% lines, 87.21% branches and 100% functions for measured application code. - Coverage: 99.54% statements, 99.65% lines, 87.83% branches and 100% functions for measured application code.
- Formatting, migration checksums, Prisma validation, strict TypeScript checks and production compilation pass. - Formatting, migration checksums, Prisma validation, strict TypeScript checks and production compilation pass.
- All seven migrations execute through Prisma migrate deploy; status is up to date and schema diff reports no drift against the disposable embedded PostgreSQL engine. The original three migrations remain unchanged; four timestamped migrations were appended. - All eleven migrations execute through Prisma migrate deploy; status is up to date and schema diff reports no drift against the disposable embedded PostgreSQL engine. The prior seven migrations remain unchanged; four timestamped migrations were appended.
- Tests cover publication, private addresses, stock reconciliation, reservation expiry/retry/commit, append-only ledgers, organization boundaries and safe HTTP errors. - New tests cover private/versioned carts, exact discount arithmetic, coupon eligibility, immutable order snapshots, deferred subtotal reconciliation, authorization, stock allocation, idempotent checkout/cancellation and rollback after final-write failure.
- Production dependency audit reports zero advisories after targeted transitive dependency overrides. - Production dependency audit reports no known vulnerabilities. No dependency versions changed in this phase.
Native PostgreSQL concurrency and remote Gitea CI remain pending. CI provisions PostgreSQL 17 for competing reservations and recovery consumption. Embedded tests do not establish multi-connection locking behavior. Native PostgreSQL concurrency and remote Gitea CI remain pending. CI provisions PostgreSQL 17. Native tests cover recovery consumption, inventory competition, checkout replay, coupon competition and checkout versus standalone inventory reservations. Embedded tests do not establish multi-connection locking behavior.
No production deployment, real SMTP delivery or formal VAPT was performed. See [assessment preparation](vapt-readiness.md) for release checks. Tax/shipping rules have not been supplied. Orders expose unfinalized pricing, null payable totals and disabled payments. Full checkout-to-payment acceptance remains pending those rules and Phase 1E. No production deployment, real SMTP delivery or formal VAPT was performed. See [checkout contract](checkout-api.md) and [security assessment preparation](vapt-readiness.md).
Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/catalog-inventory, based on fetched main. Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/checkout-orders, based on merged Phase 1C at 72cb947.

View File

@ -35,6 +35,8 @@ model ProductVariant {
attributes Json @default("{}") attributes Json @default("{}")
active Boolean @default(true) active Boolean @default(true)
stockItems StockItem[] stockItems StockItem[]
cartLines CartLine[]
orderLines OrderLine[]
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Restrict) product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, sku]) @@unique([organizationId, sku])
@@unique([id, organizationId]) @@unique([id, organizationId])
@ -63,5 +65,3 @@ model ProductGroup {
@@index([groupId, organizationId]) @@index([groupId, organizationId])
@@map("product_groups") @@map("product_groups")
} }

93
prisma/checkout.prisma Normal file
View File

@ -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")
}

View File

@ -50,6 +50,8 @@ model StockReservation {
organizationId String @map("organization_id") @db.Uuid organizationId String @map("organization_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict) 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 quantity Int
status ReservationStatus @default(ACTIVE) status ReservationStatus @default(ACTIVE)
idempotencyKey String @map("idempotency_key") @db.Uuid idempotencyKey String @map("idempotency_key") @db.Uuid
@ -62,5 +64,3 @@ model StockReservation {
@@index([userId, organizationId]) @@index([userId, organizationId])
@@map("stock_reservations") @@map("stock_reservations")
} }

View File

@ -5,5 +5,9 @@
"20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84", "20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84",
"20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36", "20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36",
"20260909141622_commerce_integrity": "30545784aa33f35783c0170b80de2bded32be8781e25e027c4e5e2e5da8348ac", "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"
} }

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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();

View File

@ -22,6 +22,7 @@ model Organization {
products Product[] products Product[]
catalogGroups CatalogGroup[] catalogGroups CatalogGroup[]
warehouses Warehouse[] warehouses Warehouse[]
coupons Coupon[]
@@map("organizations") @@map("organizations")
} }
model User { model User {
@ -41,6 +42,8 @@ model User {
addresses Address[] addresses Address[]
stockEntries StockLedger[] stockEntries StockLedger[]
stockReservations StockReservation[] stockReservations StockReservation[]
carts Cart[]
orders Order[]
@@unique([organizationId, email]) @@unique([organizationId, email])
@@unique([id, organizationId]) @@unique([id, organizationId])
@@index([organizationId, createdAt, id]) @@index([organizationId, createdAt, id])

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,90 @@
import type { Prisma } from '../generated/prisma/client';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import { decimal, minor } from './money';
import { priceCoupon } from '../coupons/coupon-policy';
import type { CheckoutInput } from './checkout.schemas';
export async function checkoutSnapshot(
tx: Prisma.TransactionClient,
actor: Principal,
input: CheckoutInput,
) {
const cart = await tx.cart.findUnique({
where: {
userId_organizationId: {
userId: actor.userId,
organizationId: actor.organizationId,
},
},
include: {
lines: {
include: { variant: { include: { product: true } } },
orderBy: { variantId: 'asc' },
},
},
});
if (!cart || cart.lines.length === 0) throw new AppError('CART_EMPTY');
if (cart.version !== input.cartVersion) throw new AppError('CART_CHANGED');
const address = await tx.address.findFirst({
where: {
id: input.addressId,
userId: actor.userId,
organizationId: actor.organizationId,
},
});
if (!address) throw new AppError('ADDRESS_NOT_FOUND');
const currency = cart.lines[0]!.variant.currency;
const lines = cart.lines.map(({ variant, quantity }) => {
if (!variant.active || variant.product.status !== 'PUBLISHED')
throw new AppError('CART_ITEM_UNAVAILABLE');
if (variant.currency !== currency) throw new AppError('CART_CURRENCY');
return {
variantId: variant.id,
sku: variant.sku,
productName: variant.product.name,
variantName: variant.name,
quantity,
unitPrice: variant.price,
lineTotal: decimal(minor(variant.price.toString()) * BigInt(quantity)),
};
});
const subtotal = lines.reduce((sum, line) => sum + minor(line.lineTotal), 0n);
const { coupon, discount } = await priceCoupon(
tx,
actor,
input.couponCode,
currency,
subtotal,
new Date(),
);
return {
cartId: cart.id,
lines,
currency,
subtotal: decimal(subtotal),
discount: decimal(discount),
merchandiseTotal: decimal(subtotal - discount),
couponId: coupon?.id,
couponSnapshot: coupon
? {
code: coupon.code,
kind: coupon.kind,
amount: coupon.amount?.toString() ?? null,
percentBps: coupon.percentBps,
minimumSubtotal: coupon.minimumSubtotal.toString(),
}
: undefined,
addressSnapshot: {
recipient: address.recipient,
line1: address.line1,
line2: address.line2,
city: address.city,
region: address.region,
postalCode: address.postalCode,
countryCode: address.countryCode,
phone: address.phone,
},
};
}

View File

@ -0,0 +1,23 @@
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';
import { CheckoutStore } from './checkout.store';
import { OrderStore } from './order.store';
import { OrdersController } from './orders.controller';
import { OrderAdminController } from './order-admin.controller';
import { CouponStore } from '../coupons/coupon.store';
import { CouponsController } from '../coupons/coupons.controller';
@Module({
imports: [DatabaseModule, IdentityModule],
controllers: [
CartController,
OrdersController,
OrderAdminController,
CouponsController,
],
providers: [CartStore, CheckoutStore, OrderStore, CouponStore],
})
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>;

View File

@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
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 { assertReplay, commandHash } from '../inventory/inventory.policy';
import type { CheckoutInput } from './checkout.schemas';
import { checkoutSnapshot } from './checkout-snapshot';
import { holdOrderStock } from './stock-allocation';
import { orderView } from './order-view';
@Injectable()
export class CheckoutStore {
constructor(private readonly access: AccessStore) {}
create(actor: Principal, input: CheckoutInput) {
const requestHash = commandHash(
input.cartVersion,
input.addressId,
input.couponCode ?? '',
);
// The organization lock also serializes catalog, coupon and cart changes.
// Stock locks below coordinate with independent inventory transactions.
return this.access.mutate(actor, null, async (tx) => {
const previous = await tx.order.findUnique({
where: {
userId_organizationId_idempotencyKey: {
userId: actor.userId,
organizationId: actor.organizationId,
idempotencyKey: input.idempotencyKey,
},
},
include: { lines: true },
});
if (previous) {
assertReplay(previous.requestHash, requestHash);
return orderView(previous);
}
const now = new Date();
if (
(await tx.order.count({
where: {
userId: actor.userId,
organizationId: actor.organizationId,
status: 'PENDING_PAYMENT',
expiresAt: { gt: now },
},
})) >= 10
)
throw new AppError('ORDER_LIMIT');
const { cartId, lines, ...snapshot } = await checkoutSnapshot(
tx,
actor,
input,
);
const expiresAt = new Date(now.getTime() + 15 * 60000);
const order = await tx.order.create({
data: {
...snapshot,
organizationId: actor.organizationId,
userId: actor.userId,
idempotencyKey: input.idempotencyKey,
requestHash,
expiresAt,
lines: { create: lines },
},
include: { lines: true },
});
await holdOrderStock(tx, actor, order.id, expiresAt, lines);
await tx.cartLine.deleteMany({ where: { cartId } });
await tx.cart.update({
where: { id: cartId },
data: { version: { increment: 1 } },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'order.created',
order.id,
);
return orderView(order);
});
}
}

26
src/checkout/money.ts Normal file
View File

@ -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;
}

View File

@ -0,0 +1,45 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
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, type PageInput } from '../identity/identity.schemas';
import { OrderStore } from './order.store';
@Controller('admin/orders')
export class OrderAdminController {
constructor(private readonly orders: OrderStore) {}
@Get()
@RequirePermission('orders.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.orders.list(actor, page, true);
}
@Get(':id')
@RequirePermission('orders.read')
get(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.orders.get(actor, id, true);
}
@Post(':id/cancel')
@RequirePermission('orders.manage')
cancel(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.orders.cancel(actor, id, true);
}
}

View File

@ -0,0 +1,34 @@
import type { Order, OrderLine } from '../generated/prisma/client';
export function orderStatus(order: Pick<Order, 'status' | 'expiresAt'>) {
return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date()
? 'EXPIRED'
: order.status;
}
export function orderView(order: Order & { lines: OrderLine[] }) {
return {
id: order.id,
status: orderStatus(order),
currency: order.currency,
subtotal: order.subtotal,
discount: order.discount,
merchandiseTotal: order.merchandiseTotal,
pricingStatus: 'UNFINALIZED',
taxTotal: null,
shippingTotal: null,
payableTotal: null,
paymentAvailable: false,
address: order.addressSnapshot,
coupon: order.couponSnapshot,
createdAt: order.createdAt,
expiresAt: order.expiresAt,
lines: order.lines.map((line) => ({
variantId: line.variantId,
sku: line.sku,
productName: line.productName,
variantName: line.variantName,
quantity: line.quantity,
unitPrice: line.unitPrice,
lineTotal: line.lineTotal,
})),
};
}

View File

@ -0,0 +1,98 @@
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 { lockStock } from '../inventory/stock-lock';
import { orderView, orderStatus } from './order-view';
@Injectable()
export class OrderStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(
actor: Principal,
page: { limit: number; offset: number },
staff = false,
) {
return this.db.order
.findMany({
where: {
organizationId: actor.organizationId,
...(!staff ? { userId: actor.userId } : {}),
},
take: page.limit,
skip: page.offset,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: {
id: true,
status: true,
currency: true,
merchandiseTotal: true,
createdAt: true,
expiresAt: true,
},
})
.then((orders) =>
orders.map((order) => ({
...order,
status: orderStatus(order),
})),
);
}
async get(actor: Principal, id: string, staff = false) {
const order = await this.db.order.findFirst({
where: {
id,
organizationId: actor.organizationId,
...(!staff ? { userId: actor.userId } : {}),
},
include: { lines: { orderBy: { variantId: 'asc' } } },
});
if (!order) throw new AppError('ORDER_NOT_FOUND');
return orderView(order);
}
cancel(actor: Principal, id: string, staff = false) {
return this.access.mutate(
actor,
staff ? 'orders.manage' : null,
async (tx) => {
const order = await tx.order.findFirst({
where: {
id,
organizationId: actor.organizationId,
...(!staff ? { userId: actor.userId } : {}),
},
include: {
lines: true,
reservations: { orderBy: { stockItemId: 'asc' } },
},
});
if (!order) throw new AppError('ORDER_NOT_FOUND');
if (order.status === 'CANCELLED') return orderView(order);
for (const reservation of order.reservations)
await lockStock(tx, actor.organizationId, reservation.stockItemId);
await tx.stockReservation.updateMany({
where: { orderId: id, status: 'ACTIVE' },
data: { status: 'RELEASED' },
});
const updated = await tx.order.update({
where: { id },
data: { status: 'CANCELLED' },
include: { lines: true },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'order.cancelled',
id,
);
return orderView(updated);
},
);
}
}

View File

@ -0,0 +1,52 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { CurrentPrincipal } from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { SchemaPipe } from '../common/validation.pipe';
import { pageSchema, type PageInput } from '../identity/identity.schemas';
import { checkoutSchema, type CheckoutInput } from './checkout.schemas';
import { CheckoutStore } from './checkout.store';
import { OrderStore } from './order.store';
@Controller()
export class OrdersController {
constructor(
private readonly checkout: CheckoutStore,
private readonly orders: OrderStore,
) {}
@Post('checkout')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(checkoutSchema)) input: CheckoutInput,
) {
return this.checkout.create(actor, input);
}
@Get('orders')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.orders.list(actor, page);
}
@Get('orders/:id')
get(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.orders.get(actor, id);
}
@Post('orders/:id/cancel')
cancel(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.orders.cancel(actor, id);
}
}

View File

@ -0,0 +1,52 @@
import { randomUUID } from 'node:crypto';
import type { Prisma } from '../generated/prisma/client';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import { lockStock, reservedQuantity } from '../inventory/stock-lock';
import { commandHash } from '../inventory/inventory.policy';
export async function holdOrderStock(
tx: Prisma.TransactionClient,
actor: Principal,
orderId: string,
expiresAt: Date,
lines: { variantId: string; quantity: number }[],
) {
const stocks = await tx.stockItem.findMany({
where: {
organizationId: actor.organizationId,
variantId: { in: lines.map((line) => line.variantId) },
},
orderBy: { id: 'asc' },
take: 201,
});
if (stocks.length > 200) throw new AppError('STOCK_ALLOCATION_LIMIT');
const now = new Date();
const remaining = new Map(
lines.map((line) => [line.variantId, line.quantity]),
);
for (const candidate of stocks) {
const stock = await lockStock(tx, actor.organizationId, candidate.id);
const needed = remaining.get(stock.variantId)!;
if (!needed) continue;
const available =
stock.onHand - (await reservedQuantity(tx, stock.id, now));
const quantity = Math.min(needed, available);
if (quantity <= 0) continue;
await tx.stockReservation.create({
data: {
stockItemId: stock.id,
organizationId: actor.organizationId,
userId: actor.userId,
orderId,
quantity,
expiresAt,
idempotencyKey: randomUUID(),
requestHash: commandHash(orderId, stock.id, quantity),
},
});
remaining.set(stock.variantId, needed - quantity);
}
if ([...remaining.values()].some((quantity) => quantity > 0))
throw new AppError('STOCK_INSUFFICIENT');
}

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;

View File

@ -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,
}),
};
}

View File

@ -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<typeof couponSchema>;

View File

@ -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;
});
}
}

View File

@ -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);
}
}

View File

@ -1,4 +1,7 @@
export const PERMISSIONS = [ export const PERMISSIONS = [
'coupons.manage',
'orders.read',
'orders.manage',
'users.read', 'users.read',
'users.create', 'users.create',
'users.approve', 'users.approve',

View File

@ -23,6 +23,7 @@ export class ReservationTransitionStore {
}; };
const existing = await tx.stockReservation.findFirst({ where: lookup }); const existing = await tx.stockReservation.findFirst({ where: lookup });
if (!existing) throw new AppError('RESERVATION_NOT_FOUND'); if (!existing) throw new AppError('RESERVATION_NOT_FOUND');
if (existing.orderId) throw new AppError('ORDER_RESERVATION_MANAGED');
const stock = await lockStock( const stock = await lockStock(
tx, tx,
actor.organizationId, actor.organizationId,

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);
});
});

View File

@ -0,0 +1,118 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture, couponInput } from './helpers/checkout';
import { secondActor, addressInput } from './helpers/commerce';
const native = process.env.TEST_DATABASE_URL ? describe : describe.skip;
native('native PostgreSQL checkout concurrency', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('creates one order for two simultaneous identical requests', async () => {
const f = await checkoutFixture(ctx);
const responses = await Promise.all(
[1, 2].map(() =>
ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input),
),
);
expect(responses.map((response) => response.status)).toEqual([201, 201]);
expect(responses[0]!.body.id).toBe(responses[1]!.body.id);
expect(
await ctx.db.stockReservation.count({
where: { userId: f.actor.userId },
}),
).toBe(1);
});
it('lets only one competing checkout claim the final stock and coupon use', async () => {
const f = await checkoutFixture(ctx, 2);
const other = await secondActor(ctx);
const address = await ctx
.api()
.post('/api/v1/addresses')
.auth(other.token, { type: 'bearer' })
.send(addressInput)
.expect(201);
await ctx
.api()
.put('/api/v1/cart/lines/' + f.variant.id)
.auth(other.token, { type: 'bearer' })
.send({ quantity: 2, version: 0 })
.expect(200);
const coupon = await ctx
.api()
.post('/api/v1/coupons')
.auth(ctx.token, { type: 'bearer' })
.send({ ...couponInput(), maxUses: 1 })
.expect(201);
const commands = [
{ actor: f.actor, input: { ...f.input, couponCode: coupon.body.code } },
{
actor: other,
input: {
...f.input,
addressId: address.body.id,
idempotencyKey: randomUUID(),
couponCode: coupon.body.code,
},
},
];
const responses = await Promise.all(
commands.map(({ actor, input }) =>
ctx
.api()
.post('/api/v1/checkout')
.auth(actor.token, { type: 'bearer' })
.send(input),
),
);
expect(responses.map((response) => response.status).sort()).toEqual([
201, 409,
]);
expect(
await ctx.db.order.count({ where: { couponId: coupon.body.id } }),
).toBe(1);
const reserved = await ctx.db.stockReservation.aggregate({
where: { stockItemId: f.stock.id },
_sum: { quantity: true },
});
expect(reserved._sum.quantity).toBe(2);
});
it('coordinates checkout with a standalone inventory reservation', async () => {
const f = await checkoutFixture(ctx, 2);
const responses = await Promise.all([
ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input),
ctx
.api()
.post('/api/v1/inventory/reservations')
.auth(ctx.token, { type: 'bearer' })
.send({
stockItemId: f.stock.id,
quantity: 2,
idempotencyKey: randomUUID(),
}),
]);
expect(responses.map((response) => response.status).sort()).toEqual([
201, 409,
]);
const held = await ctx.db.stockReservation.aggregate({
where: { stockItemId: f.stock.id },
_sum: { quantity: true },
});
expect(held._sum.quantity).toBe(2);
});
});

View File

@ -0,0 +1,102 @@
import { CartStore } from '../src/checkout/cart.store';
import { CheckoutStore } from '../src/checkout/checkout.store';
import { holdOrderStock } from '../src/checkout/stock-allocation';
import { orderView } from '../src/checkout/order-view';
import type { AccessStore } from '../src/identity/access.store';
import type { Principal } from '../src/identity/identity.types';
import { Prisma, type Order } from '../src/generated/prisma/client';
const actor = { userId: 'user', organizationId: 'org' } as Principal;
function accessFor(tx: unknown) {
return {
mutate: (
_actor: unknown,
_permission: unknown,
work: (tx: unknown) => unknown,
) => work(tx),
} as AccessStore;
}
describe('checkout resource limits and expiration', () => {
it('rejects a 21st cart line before writing', async () => {
const tx = {
cart: { upsert: jest.fn().mockResolvedValue({ id: 'cart', version: 1 }) },
productVariant: {
findFirst: jest.fn().mockResolvedValue({ currency: 'INR' }),
},
cartLine: {
findMany: jest.fn().mockResolvedValue(
Array.from({ length: 20 }, (_, index) => ({
variantId: String(index),
variant: { currency: 'INR' },
})),
),
upsert: jest.fn(),
},
};
await expect(
new CartStore(accessFor(tx)).set(actor, 'new', 1, 1),
).rejects.toMatchObject({ code: 'CART_LIMIT' });
expect(tx.cartLine.upsert).not.toHaveBeenCalled();
});
it('rejects an eleventh active order before creating an order or stock hold', async () => {
const tx = {
order: {
findUnique: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(10),
create: jest.fn(),
},
};
await expect(
new CheckoutStore(accessFor(tx)).create(actor, {
cartVersion: 1,
addressId: 'address',
idempotencyKey: 'key',
}),
).rejects.toMatchObject({ code: 'ORDER_LIMIT' });
expect(tx.order.create).not.toHaveBeenCalled();
});
it('bounds stock allocation work before acquiring locks', async () => {
const tx = {
stockItem: { findMany: jest.fn().mockResolvedValue(Array(201).fill({})) },
$queryRaw: jest.fn(),
};
await expect(
holdOrderStock(
tx as unknown as Prisma.TransactionClient,
actor,
'order',
new Date(),
[],
),
).rejects.toMatchObject({ code: 'STOCK_ALLOCATION_LIMIT' });
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('exposes expiry without pretending an unfinalized order is payable', () => {
const order: Order & { lines: [] } = {
id: 'order',
organizationId: 'org',
userId: 'user',
status: 'PENDING_PAYMENT',
currency: 'INR',
subtotal: new Prisma.Decimal(1),
discount: new Prisma.Decimal(0),
merchandiseTotal: new Prisma.Decimal(1),
addressSnapshot: {},
couponSnapshot: null,
couponId: null,
idempotencyKey: 'key',
requestHash: 'hash',
createdAt: new Date(0),
expiresAt: new Date(1),
lines: [],
};
expect(orderView(order)).toMatchObject({
status: 'EXPIRED',
payableTotal: null,
paymentAvailable: false,
});
expect(orderView({ ...order, status: 'CANCELLED' }).status).toBe(
'CANCELLED',
);
});
});

View File

@ -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: '<script>' },
])
expect(couponSchema.safeParse({ ...input, ...change }).success).toBe(
false,
);
const { percentBps: _percent, ...fixed } = input;
expect(
couponSchema.safeParse({ ...fixed, kind: 'FIXED', amount: '2.00' })
.success,
).toBe(true);
expect(
couponSchema.safeParse({ ...fixed, kind: 'FIXED', amount: '0.00' })
.success,
).toBe(false);
});
it('rejects every coupon eligibility boundary', () => {
const now = new Date();
const coupon = {
...couponInput(),
id: 'id',
organizationId: 'org',
kind: 'PERCENT',
startsAt: new Date(now.getTime() - 1000),
endsAt: new Date(now.getTime() + 1000),
active: true,
amount: null,
minimumSubtotal: new Prisma.Decimal(100),
} as Coupon;
expect(() => assertCoupon(coupon, 'INR', 10000n, now, 0, 0)).not.toThrow();
for (const invalid of [
null,
{ ...coupon, active: false },
{ ...coupon, currency: 'USD' },
{ ...coupon, startsAt: new Date(now.getTime() + 1) },
{ ...coupon, endsAt: now },
])
expect(() => assertCoupon(invalid, 'INR', 10000n, now, 0, 0)).toThrow();
expect(() => assertCoupon(coupon, 'INR', 9999n, now, 0, 0)).toThrow();
expect(() => assertCoupon(coupon, 'INR', 10000n, now, 10, 0)).toThrow();
expect(() => assertCoupon(coupon, 'INR', 10000n, now, 0, 1)).toThrow();
});
it('bounds carts and rejects client totals and unknown fields', () => {
for (const quantity of [0, -1, 101, 0.5])
expect(cartLineSchema.safeParse({ quantity, version: 0 }).success).toBe(
false,
);
expect(cartLineSchema.safeParse({ quantity: 1, version: -1 }).success).toBe(
false,
);
expect(
checkoutSchema.safeParse({ cartVersion: 0, total: '0.00' }).success,
).toBe(false);
});
});

View File

@ -0,0 +1,107 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture } from './helpers/checkout';
import * as audit from '../src/identity/audit';
describe('checkout allocation and final-write rollback', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('rolls back order, holds and cart clearing when the audit write fails', async () => {
const f = await checkoutFixture(ctx);
jest
.spyOn(audit, 'recordAudit')
.mockRejectedValueOnce(new Error('Synthetic audit failure'));
const result = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(500);
expect(result.body.code).toBe('INTERNAL_FAILURE');
expect(JSON.stringify(result.body)).not.toContain('Synthetic');
expect(
await ctx.db.order.count({ where: { userId: f.actor.userId } }),
).toBe(0);
expect(
await ctx.db.stockReservation.count({
where: { userId: f.actor.userId },
}),
).toBe(0);
const cart = await ctx
.api()
.get('/api/v1/cart')
.auth(f.actor.token, { type: 'bearer' })
.expect(200);
expect(cart.body.version).toBe(1);
expect(cart.body.lines).toHaveLength(1);
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
});
it('splits a line across warehouses without consuming on-hand stock', async () => {
const f = await checkoutFixture(ctx, 1);
const warehouse = await ctx.db.warehouse.create({
data: { organizationId: f.actor.organizationId, name: randomUUID() },
});
const stock = await ctx.db.stockItem.create({
data: {
organizationId: f.actor.organizationId,
warehouseId: warehouse.id,
variantId: f.variant.id,
},
});
await ctx
.api()
.post('/api/v1/inventory/adjustments')
.auth(ctx.token, { type: 'bearer' })
.send({
stockItemId: stock.id,
delta: 1,
reason: 'Opening balance',
idempotencyKey: randomUUID(),
})
.expect(201);
const result = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
const holds = await ctx.db.stockReservation.findMany({
where: { orderId: result.body.id },
});
expect(holds).toHaveLength(2);
expect(holds.map((hold) => hold.quantity)).toEqual([1, 1]);
});
it('revalidates product availability and currency when checking out a saved cart', async () => {
const f = await checkoutFixture(ctx);
await ctx.db.productVariant.update({
where: { id: f.variant.id },
data: { active: false },
});
const response = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(409);
expect(response.body.code).toBe('CART_ITEM_UNAVAILABLE');
expect(
await ctx.db.order.count({ where: { userId: f.actor.userId } }),
).toBe(0);
});
});

180
test/checkout.spec.ts Normal file
View File

@ -0,0 +1,180 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture, couponInput } from './helpers/checkout';
import { secondActor, seedStock, addressInput } from './helpers/commerce';
describe('atomic checkout and order snapshots', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
});
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('snapshots server prices and address, holds stock, clears cart and replays safely', async () => {
const f = await checkoutFixture(ctx);
const created = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
expect(created.body).toMatchObject({
subtotal: '998',
discount: '0',
merchandiseTotal: '998',
status: 'PENDING_PAYMENT',
pricingStatus: 'UNFINALIZED',
payableTotal: null,
paymentAvailable: false,
});
expect(created.body.requestHash).toBeUndefined();
expect(
await ctx.db.stockReservation.count({
where: { orderId: created.body.id },
}),
).toBe(1);
expect(
(await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } }))
.onHand,
).toBe(10);
await ctx.db.productVariant.update({
where: { id: f.variant.id },
data: { price: '1.00', name: 'Changed' },
});
await ctx
.api()
.put('/api/v1/addresses/' + f.address.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ ...addressInput, recipient: 'Changed' })
.expect(200);
const replay = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
expect(replay.body).toEqual(created.body);
expect(replay.body.address.recipient).toBe(addressInput.recipient);
const cart = await ctx
.api()
.get('/api/v1/cart')
.auth(f.actor.token, { type: 'bearer' })
.expect(200);
expect(cart.body).toEqual({ version: 2, lines: [] });
const conflict = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send({ ...f.input, cartVersion: 2 })
.expect(409);
expect(conflict.body.code).toBe('IDEMPOTENCY_CONFLICT');
});
it('rolls back orders and partial holds on a stock shortage, preserving cart', async () => {
const f = await checkoutFixture(ctx);
const unavailable = await seedStock(ctx, 0);
await ctx
.api()
.put('/api/v1/cart/lines/' + unavailable.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ quantity: 1, version: 1 })
.expect(200);
const response = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send({ ...f.input, cartVersion: 2 })
.expect(409);
expect(response.body.code).toBe('STOCK_INSUFFICIENT');
expect(
await ctx.db.order.count({ where: { userId: f.actor.userId } }),
).toBe(0);
expect(
await ctx.db.stockReservation.count({
where: { userId: f.actor.userId },
}),
).toBe(0);
expect(
await ctx.db.cartLine.count({
where: { cart: { userId: f.actor.userId } },
}),
).toBe(2);
});
it('validates live cart version, private address and coupon eligibility before writes', async () => {
const f = await checkoutFixture(ctx);
for (const [change, code] of [
[{ cartVersion: 0 }, 'CART_CHANGED'],
[{ addressId: randomUUID() }, 'ADDRESS_NOT_FOUND'],
[{ couponCode: 'MISSING' }, 'COUPON_INELIGIBLE'],
] as const) {
const response = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send({ ...f.input, ...change })
.expect(code === 'ADDRESS_NOT_FOUND' ? 404 : 409);
expect(response.body.code).toBe(code);
}
const other = await secondActor(ctx);
await ctx
.api()
.post('/api/v1/checkout')
.auth(other.token, { type: 'bearer' })
.send(f.input)
.expect(409);
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send({ ...f.input, subtotal: '0.01' })
.expect(400);
});
it('applies a coupon exactly once and releases its usage on cancellation', async () => {
const f = await checkoutFixture(ctx);
const coupon = await ctx
.api()
.post('/api/v1/coupons')
.auth(ctx.token, { type: 'bearer' })
.send(couponInput())
.expect(201);
const input = { ...f.input, couponCode: coupon.body.code };
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(input)
.expect(201);
expect(order.body).toMatchObject({
subtotal: '998',
discount: '99.8',
merchandiseTotal: '898.2',
});
await ctx
.api()
.put('/api/v1/cart/lines/' + f.variant.id)
.auth(f.actor.token, { type: 'bearer' })
.send({ quantity: 1, version: 2 })
.expect(200);
const next = { ...input, cartVersion: 3, idempotencyKey: randomUUID() };
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(next)
.expect(409);
await ctx
.api()
.post('/api/v1/orders/' + order.body.id + '/cancel')
.auth(f.actor.token, { type: 'bearer' })
.expect(201);
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(next)
.expect(201);
});
});

103
test/coupons.spec.ts Normal file
View File

@ -0,0 +1,103 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture, couponInput } from './helpers/checkout';
import { secondActor } from './helpers/commerce';
describe('coupon administration', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('requires permissions, scopes reads and deactivates immutable coupon rules', async () => {
const customer = await secondActor(ctx);
await ctx
.api()
.post('/api/v1/coupons')
.auth(customer.token, { type: 'bearer' })
.send(couponInput())
.expect(403);
const created = await ctx
.api()
.post('/api/v1/coupons')
.auth(ctx.token, { type: 'bearer' })
.send(couponInput())
.expect(201);
const list = await ctx
.api()
.get('/api/v1/coupons')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(
list.body.some((row: { id: string }) => row.id === created.body.id),
).toBe(true);
const foreign = await secondActor(ctx, false, ['coupons.manage']);
const empty = await ctx
.api()
.get('/api/v1/coupons')
.auth(foreign.token, { type: 'bearer' })
.expect(200);
expect(empty.body).toEqual([]);
await ctx
.api()
.patch('/api/v1/coupons/' + created.body.id + '/status')
.auth(foreign.token, { type: 'bearer' })
.send({ active: false })
.expect(404);
await ctx
.api()
.patch('/api/v1/coupons/' + created.body.id + '/status')
.auth(ctx.token, { type: 'bearer' })
.send({ active: false })
.expect(200);
await expect(
ctx.executeSql(
`UPDATE coupons SET percent_bps = 5000 WHERE id = '${created.body.id}'`,
),
).rejects.toThrow();
const f = await checkoutFixture(ctx);
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send({ ...f.input, couponCode: created.body.code })
.expect(409);
});
it('caps fixed discounts at subtotal and rejects duplicate normalized codes', async () => {
const input = couponInput();
const { percentBps: _percent, ...common } = input;
const fixed = { ...common, kind: 'FIXED', amount: '9999.00' };
const coupon = await ctx
.api()
.post('/api/v1/coupons')
.auth(ctx.token, { type: 'bearer' })
.send(fixed)
.expect(201);
await ctx
.api()
.post('/api/v1/coupons')
.auth(ctx.token, { type: 'bearer' })
.send({ ...fixed, code: fixed.code.toLowerCase() })
.expect(409);
const f = await checkoutFixture(ctx);
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send({ ...f.input, couponCode: coupon.body.code })
.expect(201);
expect(order.body.merchandiseTotal).toBe('0');
expect(order.body.paymentAvailable).toBe(false);
await ctx
.api()
.patch('/api/v1/coupons/' + randomUUID() + '/status')
.auth(ctx.token, { type: 'bearer' })
.send({ active: false })
.expect(404);
});
});

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(),
};
}

191
test/orders.spec.ts Normal file
View File

@ -0,0 +1,191 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture } from './helpers/checkout';
import { secondActor } from './helpers/commerce';
describe('order ownership and cancellation', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
});
it('isolates customers and organizations while allowing scoped staff reads', async () => {
const f = await checkoutFixture(ctx);
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
const other = await secondActor(ctx);
const foreign = await secondActor(ctx, false, [
'orders.read',
'orders.manage',
]);
for (const actor of [other, foreign]) {
await ctx
.api()
.get('/api/v1/orders/' + order.body.id)
.auth(actor.token, { type: 'bearer' })
.expect(404);
await ctx
.api()
.post('/api/v1/orders/' + order.body.id + '/cancel')
.auth(actor.token, { type: 'bearer' })
.expect(404);
}
await ctx
.api()
.get('/api/v1/admin/orders/' + order.body.id)
.auth(other.token, { type: 'bearer' })
.expect(403);
await ctx
.api()
.get('/api/v1/admin/orders/' + order.body.id)
.auth(foreign.token, { type: 'bearer' })
.expect(404);
await ctx
.api()
.post('/api/v1/admin/orders/' + order.body.id + '/cancel')
.auth(foreign.token, { type: 'bearer' })
.expect(404);
await ctx
.api()
.get('/api/v1/admin/orders/' + order.body.id)
.auth(ctx.token, { type: 'bearer' })
.expect(200);
const own = await ctx
.api()
.get('/api/v1/orders')
.auth(f.actor.token, { type: 'bearer' })
.expect(200);
expect(own.body.map((row: { id: string }) => row.id)).toEqual([
order.body.id,
]);
const others = await ctx
.api()
.get('/api/v1/orders')
.auth(other.token, { type: 'bearer' })
.expect(200);
expect(others.body).toEqual([]);
const staff = await ctx
.api()
.get('/api/v1/admin/orders?limit=1')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(staff.body).toHaveLength(1);
expect(staff.body[0].address).toBeUndefined();
});
it('cancels once, releases holds and blocks standalone reservation transitions', async () => {
const f = await checkoutFixture(ctx);
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
const hold = await ctx.db.stockReservation.findFirstOrThrow({
where: { orderId: order.body.id },
});
const denied = await ctx
.api()
.post('/api/v1/inventory/reservations/' + hold.id + '/commit')
.auth(ctx.token, { type: 'bearer' })
.expect(409);
expect(denied.body.code).toBe('ORDER_RESERVATION_MANAGED');
const result = await ctx
.api()
.post('/api/v1/admin/orders/' + order.body.id + '/cancel')
.auth(ctx.token, { type: 'bearer' })
.expect(201);
expect(result.body.status).toBe('CANCELLED');
await ctx
.api()
.post('/api/v1/orders/' + order.body.id + '/cancel')
.auth(f.actor.token, { type: 'bearer' })
.expect(201);
expect(
(
await ctx.db.stockReservation.findUniqueOrThrow({
where: { id: hold.id },
})
).status,
).toBe('RELEASED');
expect(
await ctx.db.auditEvent.count({
where: { targetId: order.body.id, action: 'order.cancelled' },
}),
).toBe(1);
expect(
(await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } }))
.onHand,
).toBe(10);
});
it('enforces immutable snapshots and line arithmetic through SQL', async () => {
const f = await checkoutFixture(ctx);
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
await expect(
ctx.executeSql(
`UPDATE orders SET subtotal = 1 WHERE id = '${order.body.id}'`,
),
).rejects.toThrow();
await expect(
ctx.executeSql(`DELETE FROM orders WHERE id = '${order.body.id}'`),
).rejects.toThrow();
await expect(
ctx.executeSql(
`UPDATE order_lines SET quantity = 10 WHERE order_id = '${order.body.id}'`,
),
).rejects.toThrow();
await expect(
ctx.executeSql(
`DELETE FROM order_lines WHERE order_id = '${order.body.id}'`,
),
).rejects.toThrow();
await expect(
ctx.executeSql(
`UPDATE stock_reservations SET order_id = NULL WHERE order_id = '${order.body.id}'`,
),
).rejects.toThrow();
await expect(
ctx.executeSql(
`DELETE FROM stock_reservations WHERE order_id = '${order.body.id}'`,
),
).rejects.toThrow();
});
it('rejects a later line insertion that would change a committed order snapshot', async () => {
const f = await checkoutFixture(ctx);
const result = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
const variant = await ctx.db.productVariant.create({
data: {
organizationId: f.actor.organizationId,
productId: f.product.id,
sku: result.body.id,
name: 'Extra line',
price: '1.00',
},
});
await expect(
ctx.executeSql(`INSERT INTO order_lines
(id, order_id, organization_id, variant_id, sku, product_name, variant_name, quantity, unit_price, line_total)
VALUES (gen_random_uuid(), '${result.body.id}', '${f.actor.organizationId}', '${variant.id}', 'EXTRA', 'Extra', 'Extra', 1, 1, 1)`),
).rejects.toThrow('Order subtotal does not match lines');
expect(
await ctx.db.orderLine.count({ where: { orderId: result.body.id } }),
).toBe(1);
});
});