Compare commits

..

17 Commits

Author SHA1 Message Date
Mihir Motiyani d22aa78b6d Merge pull request 'feat(procurement): add supplier and material master data' (#6) from feat/supplier-materials into main
Reviewed-on: #6
2026-09-13 14:35:11 +05:30
mihir 22a26c2e25 feat(procurement): add supplier and material master data 2026-09-13 14:32:57 +05:30
Mihir Motiyani 46dfae07eb Merge pull request 'feat(docs): add Swagger UI with validation schemas and bearer authentication' (#5) from feat/swagger-ui into main
Reviewed-on: #5
2026-09-13 13:54:13 +05:30
mihir 2a85081c7d feat(docs): add Swagger UI with validation schemas and bearer authentication 2026-09-11 17:15:37 +05:30
Mihir Motiyani 3da6f4e469 Merge pull request 'feat/commerce-operations' (#4) from feat/commerce-operations into main
Reviewed-on: #4
2026-09-11 17:01:24 +05:30
mihir 2ee2e7dd1f docs(operations): document blueprint boundaries and acceptance tests 2026-09-11 16:59:58 +05:30
mihir cef6c5ecb0 feat(events): add leased outbox delivery and scoped operational APIs 2026-09-11 16:59:57 +05:30
mihir 5e5f6c1faa feat(pricing): snapshot configurable tax and shipping policies at checkout 2026-09-11 16:59:57 +05:30
mihir dcda859fb8 feat(blueprint): define provider contracts and payment fulfillment policies 2026-09-11 16:59:55 +05:30
mihir 27a1172683 feat(data): add immutable pricing and transactional event migrations 2026-09-11 16:59:54 +05:30
Mihir Motiyani 068b0f9829 Merge pull request 'feat/checkout-orders' (#3) from feat/checkout-orders into main
Reviewed-on: #3
2026-09-11 00:39:58 +05:30
mihir 812b75279a docs(checkout): document pricing boundary and verification evidence 2026-09-11 00:28:46 +05:30
mihir 44b74e69c7 feat(orders): add atomic checkout snapshots stock holds and cancellation 2026-09-11 00:28:46 +05:30
mihir aacfe6c171 feat(coupons): add exact discounts and scoped eligibility rules 2026-09-11 00:28:45 +05:30
mihir 9ac40531b1 feat(cart): add private versioned carts with bounded inputs 2026-09-11 00:28:44 +05:30
mihir 6401d2c1c2 feat(data): add append-only checkout migrations and snapshot integrity 2026-09-11 00:28:42 +05:30
Mihir Motiyani 72cb947128 Merge pull request 'feat/catalog-inventory' (#2) from feat/catalog-inventory into main
Reviewed-on: #2
2026-09-10 23:43:42 +05:30
103 changed files with 5388 additions and 20 deletions

View File

@ -13,3 +13,6 @@ RECOVERY_TTL_MINUTES=15
# SMTP_FROM=support@example.com
# RECOVERY_URL=https://shop.example.com/reset
DATABASE_POOL_SIZE=10
# Optional; defaults to true only in development.
# SWAGGER_ENABLED=true

View File

@ -30,3 +30,10 @@ 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).
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.
Phase 1E provides a [provider-independent blueprint and test matrix](docs/phase1e-blueprint.md), configurable pricing snapshots and an [operational outbox/API](docs/operations-api.md). No real gateway or message delivery is enabled.
Phase 2A adds organization-scoped [supplier and material master data](docs/procurement-api.md). Purchase orders, receipts, QC and production remain later milestones.
### Swagger UI
Open http://localhost:3000/api/docs in development to browse and test the API. See [API explorer](docs/swagger.md) for authentication and configuration.

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. Without a matching active pricing policy, pricingStatus is UNFINALIZED and taxTotal, shippingTotal and payableTotal are null. Phase 1E adds optional finalized pricing snapshots; see [pricing configuration](operations-api.md). paymentAvailable remains false in all cases. 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. Configure approved pricing policy versions before using finalized totals. Real payment integration is deferred at the user request; follow the Phase 1E blueprint before enabling it. 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,5 @@ 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.
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.
Phase 1E codes live in src/common/errors/operations-errors.ts. Delivery logs use fixed DELIVERY_FAILED and DELIVERY_LEASE_LOST events with event IDs, never raw adapter errors.

View File

@ -12,3 +12,6 @@ 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.
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.
Phase 1E adds pricing policies, immutable final-price snapshots and commerce events with leased delivery state in two further timestamped migrations. All eleven preceding migrations remain unchanged.
Phase 2A appends supplier, material and supplier-material compatibility tables. It grants procurement permissions to existing system roles; custom roles must be updated through the RBAC API.

39
docs/operations-api.md Normal file
View File

@ -0,0 +1,39 @@
# Pricing and operations APIs
All routes begin with /api/v1 and require an active bearer session. Organization scope comes from that session.
| Route | Permission |
| ---------------------------------------- | ------------------- |
| GET/POST /admin/pricing-policies | pricing.manage |
| PATCH /admin/pricing-policies/:id/status | pricing.manage |
| GET /admin/operations/summary | operations.read |
| GET /admin/operations/events | operations.read |
| POST /admin/operations/events/:id/retry | notifications.retry |
List routes use the existing limit/offset bounds. Existing system roles gain these permissions through migration; custom roles require explicit grants.
## Pricing configuration
Create a policy with name, currency, countryCode, optional region, taxMode (INCLUSIVE or EXCLUSIVE), merchandiseTaxBps, shippingFee, shippingTaxBps and optional freeShippingMinimum. Rates use basis points (100 means 1%) and must be 010000. Money inputs are nonnegative decimal strings with two fractional digits. Free-shipping minimum may be null. Country/region values normalize to uppercase.
Every policy starts inactive. PATCH status accepts only active. Activating a version disables the previous active version for the same organization/currency/country/region. A matching region-specific rule takes precedence over the country's empty-region fallback. No policy values are seeded for Mani Candles.
Checkout selects an active matching policy and snapshots its rules and calculated totals. No match leaves pricing UNFINALIZED. Existing orders are never retroactively priced or repriced. Policy rules and order-pricing rows are immutable; changes require a new policy version.
Merchandise tax is calculated on the discounted merchandise amount. INCLUSIVE extracts tax; EXCLUSIVE adds it. ShippingFee is tax-exclusive; shippingTaxBps applies to that fee. Free-shipping eligibility uses the discounted merchandise amount before adding tax or shipping. Calculations use exact integer minor units and round half up.
Order detail exposes pricingStatus, taxTotal, shippingTotal, payableTotal and pricing breakdown. shippingTotal is the net shipping fee; taxTotal includes merchandise and shipping tax, including tax already included in merchandise prices where applicable. Clients must use payableTotal as authoritative and must not reconstruct it by blindly summing these display fields. Decimal output may omit trailing zeroes.
paymentAvailable remains false regardless of pricing configuration because no production gateway exists. Finalized pricing is not proof of payment or shipping eligibility.
## Event delivery and operations
Successful checkout and cancellation append unique immutable commerce events with IDs and order references only. Event creation shares the business transaction, so rollback creates no event. A separate mutable delivery row tracks attempts, availability and delivery state. Private addresses and credentials are not copied into events.
DeliveryStore claims one event with FOR UPDATE SKIP LOCKED and a 60-second lease. Acknowledgement must match the current, unexpired lease. Failures back off for 30, 60, 120 and 240 seconds between automatic attempts; a fifth failed attempt exhausts automatic eligibility. Authorized manual retry resets a failed or pending event, but rejects delivered events and active leases.
Delivery is at-least-once. Adapters must use the stable event ID for deduplication where supported. A crash after external delivery but before acknowledgement may otherwise cause a duplicate; the queue does not promise exactly-once delivery. Logs contain event IDs and fixed error codes, never raw adapter exceptions.
The default DeliveryPort refuses to dispatch. There is no scheduled worker or real message adapter enabled. Tests inject fake delivery behavior. When adding an adapter, implement bounded network timeouts below the lease duration, event-ID deduplication, recipient authorization and a supervised worker.
Summary returns scoped pending, expired and cancelled order counts, active orders without pricing, pending delivery counts and exhausted delivery counts. These are operational counts, not revenue reports. Event listing omits lease tokens and private message contents.

66
docs/phase1e-blueprint.md Normal file
View File

@ -0,0 +1,66 @@
# Phase 1E provider-independent blueprint
The user chose a blueprint and test cases before purchasing a gateway. No payment, shipping or messaging provider is selected or registered. Fake gateway code exists only under test/helpers; its HMAC format is illustrative and must never be treated as a production provider protocol.
## Implemented now
- Inactive-by-default, immutable pricing policy versions with explicit tax and shipping settings.
- Checkout snapshots of configured final totals, with database reconciliation and preserved historical prices.
- Transactional order-created/order-cancelled events, leased delivery, bounded retries and safe diagnostics.
- Authorized operational summary, event inspection and retry APIs.
- Gateway contracts and pure decision policies for capture validation, late capture compensation, refund bounds, partial shipping and returns.
- Fake-provider contract tests and database integration tests.
There are no production payment/capture/refund/webhook/shipment/return endpoints. No real payment or notification is sent. Order status remains the Phase 1D pending/cancelled model. Payment and fulfillment decisions are tested policies, not a persisted live payment lifecycle.
## Integration boundaries
```mermaid
flowchart LR
Checkout --> PricingSnapshot
Checkout --> Order
Order --> TransactionalEvents
TransactionalEvents --> LeasedDelivery
LeasedDelivery --> DeliveryAdapter["Future notification adapter"]
GatewayAdapter["Future gateway adapter"] --> VerifiedCapture
VerifiedCapture --> CapturePolicy
CapturePolicy --> StockCommit["Future transactional stock commit"]
CapturePolicy --> ReviewRefund["Late/mismatched capture review"]
```
GatewayPort defines idempotent payment creation, raw-byte capture verification and refund submission. Money crosses the provider boundary as canonical integer minor-unit strings with currency. Never accept amounts, successful payment flags or fulfillment authorization from the frontend. Normalize a provider's verified payload into CapturedPayment, then compare it with server-stored references and totals.
Capture decisions reject reference, amount and currency mismatches. Duplicate captured payments cause no additional stock commitment. A cancelled or expired order requires review and compensation, even if the provider says payment succeeded: its stock may already have been allocated elsewhere. Do not automatically refund from this pure decision function; a persisted, idempotent refund workflow must execute that decision.
Refund eligibility subtracts completed and pending refunds from captured money. Gateway acceptance means PENDING, not refunded. Only verified terminal provider state may mark a refund completed.
## Persistence required when a gateway is selected
Add new timestamped migrations for payment attempts, authenticated event receipts, refund requests and state transitions. Enforce unique provider/event IDs and compare payload digests when a repeated event ID arrives. Store an idempotency key before network work. Do not hold database locks while calling providers.
Process verified capture under order/stock locks in one transaction: recheck expiry, references and amount, commit each reserved quantity once, update payment/order state, preserve paid coupon usage, append ledger/audit records and enqueue notifications. Persist unmatched or late captures for reconciliation and compensation. Cancellation after capture must use refund policy, not the current pending-order cancellation route.
Refund requests must lock the payment while reserving refund capacity; pending amounts count against the refundable balance. Provider calls and callbacks need durable retry/reconciliation. Never interpret a timeout as proof that the external write failed.
Shipping persistence should include shipment lines and carrier events. Check paid eligibility and remaining quantities under locks before booking partial shipments. Track provider references and deduplicate events; out-of-order tracking must not regress delivery state. Do not conflate booking, dispatch and delivery.
Returns need request lines, delivered quantities, configurable time windows, approval and receipt/QC states. Pending returns reserve eligibility. Refund and restock are separate actions: a requested/approved return does not prove that inventory was physically received or saleable.
## Acceptance test matrix
| Area | Tests now | Required provider-stage tests |
| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Payment creation | Same-key replay; changed input rejected by fake adapter | Timeout reconciliation and durable attempt recovery |
| Webhooks | Fake signature, raw-byte tampering, malformed and oversized payloads | Selected provider signatures, key rotation, actual event payloads and persisted replay protection |
| Capture | References, exact amount/currency, duplicate and late/cancelled cases | Atomic payment/stock/ledger commit under real PostgreSQL races |
| Refunds | Completed + pending bounds; fake retry idempotency | Partial refunds, concurrent limits, provider failure/reconciliation |
| Shipping | Paid eligibility, partial quantity bounds, monotonic tracking | Provider booking, replay, split shipment and tracking fixtures |
| Returns | Delivered/pending/returned bounds and explicit window | Approval, receipt/QC, separate restock/refund transactions |
| Pricing | Inclusive/exclusive arithmetic, rounding, region selection, immutable snapshots | Approved tax treatment, invoices, product tax classes and shipping services |
| Notifications | Atomic enqueue, leases, backoff, retries, redacted failures | Real adapter deduplication and delivery receipts |
The current tax calculator supports one configured merchandise rate per matching geographic policy, plus a shipping rate. Test values are synthetic and are not tax advice or the store's approved rates. Mixed tax classes, invoice requirements, provider shipping quotations and real business rules must be designed before production enablement.
## Before enabling providers
Select the gateway and shipping/messaging services, implement their official adapters, run their sandbox fixtures and persist the workflows above. Keep raw webhook bytes available only at the gateway boundary and redact credentials and personal information from logs. Use environment-managed secrets, native PostgreSQL concurrency checks and an independent VAPT of the deployed service. Purchasing a gateway is not needed to run the current tests.

16
docs/procurement-api.md Normal file
View File

@ -0,0 +1,16 @@
# Phase 2A: suppliers and materials
This phase introduces private, organization-scoped supplier and material master data. It does not create purchase orders, receipts, invoices, stock movements, production batches, or documents.
| Method | Route | Permission | Purpose |
| -------- | -------------------------------------------- | ------------------ | --------------------------------------------------------------- |
| GET | /suppliers | procurement.read | Page through suppliers; filter with `active` and `search` |
| POST/PUT | /suppliers, /suppliers/:id | procurement.manage | Create or replace supplier records |
| GET | /materials | procurement.read | Page through materials; filter with `active` and `search` |
| POST/PUT | /materials, /materials/:id | procurement.manage | Create or replace materials |
| GET | /suppliers/:supplierId/materials | procurement.read | View the supplier's compatible materials |
| PUT | /suppliers/:supplierId/materials/:materialId | procurement.manage | Create or replace compatibility, commercial lead time and quote |
Supplier and material codes are uppercase, organization-unique identifiers. Materials use a fixed kind and unit to avoid ambiguous procurement and BOM quantities. Compatibility records hold a supplier SKU, lead time, minimum quantity, price, currency, and active status. A future purchase order must snapshot these values rather than rely on a later edit.
All endpoints require a session and enforce a permission again inside write transactions. Missing suppliers and materials produce distinct scoped errors. Create, update, and compatibility actions are audit-recorded without arbitrary payloads. The migration is append-only and grants the new permissions only to existing system roles; custom roles require an explicit update.

View File

@ -7,8 +7,8 @@ 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.
- 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.
- 1D: 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.
- 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 (blueprint and supporting pricing/outbox APIs implemented; live providers deferred at user request): verified payments/refunds, shipping/tracking, returns, notifications and operational dashboard. Test signatures, replay, partial fulfillment and reconciliation.
## Phase 2: Internal operations

15
docs/swagger.md Normal file
View File

@ -0,0 +1,15 @@
# API explorer
Start the backend with the existing database configuration (`pnpm build`, then `pnpm start`). In development, open **http://localhost:3000/api/docs**. The machine-readable OpenAPI specification is at **/api/docs-json**. If PORT differs, use that port.
1. Bootstrap an owner using the setup instructions in README, or use an existing account.
2. Expand Auth and execute `POST /api/v1/auth/login` with your organization UUID, email and password.
3. Copy `accessToken` from the successful response. Click **Authorize**, paste the token without a `Bearer` prefix and confirm.
4. Select an endpoint, click **Try it out**, fill its parameters/body and execute. Required permissions appear in the endpoint description. Requests use your real account permissions and can change data.
5. Log out through the API when finished and clear authorization in the UI.
The UI groups all registered controller routes. Request bodies, required fields, query defaults and constraints are derived from the same Zod schemas used by validation. Custom cross-field refinements and business rules still apply on the server. Successful response bodies are not yet exhaustively modeled; inspect actual responses. Errors share a documented envelope with distinct codes and a request ID for log correlation.
`SWAGGER_ENABLED=true` explicitly enables documentation; `false` disables it. If omitted, it is enabled only in development. Documentation itself does not require a session, so enable it on a deployed environment only when its API inventory should be visible there. Protected API operations still require authentication and permissions. Tokens are not persisted by Swagger across reloads. Assets are served locally and external schema validation is disabled.
Payment and shipping remain test blueprints. No gateway credentials or real integration are required by Swagger. No database schema changes or migrations are needed for this feature.

View File

@ -11,3 +11,5 @@ 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.
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.
The Phase 1E gateway is a test-only fake. Signature tests against it do not establish security for a real provider. Production payment/refund/webhook and fulfillment workflows require the provider-stage controls and tests in [the blueprint](phase1e-blueprint.md). The default notification adapter refuses delivery.

View File

@ -1,14 +1,16 @@
# Verification record — Phase 1C
# Verification record — Phase 1E blueprint
- 131 passing tests across 21 suites; two native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL.
- Coverage: 99.61% statements, 99.56% lines, 87.21% branches and 100% functions for measured application code.
- 174 passing tests across 34 suites; six native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL (two suites are entirely native).
- Coverage: 99.60% statements, 99.70% lines, 88.49% branches and 100% functions for measured application code.
- 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.
- Tests cover publication, private addresses, stock reconciliation, reservation expiry/retry/commit, append-only ledgers, organization boundaries and safe HTTP errors.
- Production dependency audit reports zero advisories after targeted transitive dependency overrides.
- All thirteen 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 eleven migrations remain unchanged; two timestamped migrations were appended.
- New tests cover fake-gateway idempotency and raw-byte signature checks, payment/refund decision policies, partial shipment/return bounds, exact inclusive/exclusive pricing, immutable pricing snapshots, atomic event creation, leased delivery, retries and safe logging.
- 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.
The user requested a provider-independent blueprint before selecting or purchasing a gateway. Payment/refund/shipping/return contracts and decision policies are tested, but there are no live provider endpoints or persisted capture/refund/fulfillment workflows yet. Pricing policies, pricing snapshots, the outbox and operational read/retry APIs are runnable. Gateway and notification delivery remain disabled.
No production deployment, real SMTP delivery or formal VAPT was performed. See [assessment preparation](vapt-readiness.md) for release checks.
Native PostgreSQL concurrency and remote Gitea CI remain pending. The new native test covers simultaneous outbox claims; prior concurrency tests remain available. Embedded tests do not establish multi-connection locking behavior.
Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/catalog-inventory, based on fetched main.
No store tax/shipping settings were invented or seeded. All configured rates in tests are synthetic. No real payment, notification, production deployment or formal VAPT was performed. See [blueprint and acceptance matrix](phase1e-blueprint.md) and [operations API](operations-api.md).
Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/commerce-operations, based on merged Phase 1D at 068b0f9.

View File

@ -31,6 +31,7 @@
"@nestjs/common": "^11.1.0",
"@nestjs/core": "^11.1.0",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/swagger": "^11.4.7",
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"dotenv": "^17.0.0",

View File

@ -22,6 +22,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.1.0
version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
'@nestjs/swagger':
specifier: ^11.4.7
version: 11.4.7(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2)
'@prisma/adapter-pg':
specifier: ^7.0.0
version: 7.10.0
@ -610,6 +613,9 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@napi-rs/wasm-runtime@1.2.3':
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@ -648,12 +654,42 @@ packages:
'@nestjs/websockets':
optional: true
'@nestjs/mapped-types@2.1.1':
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/platform-express@11.2.3':
resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==}
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/swagger@11.4.7':
resolution: {integrity: sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==}
peerDependencies:
'@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/common': ^11.0.1
'@nestjs/core': ^11.0.1
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
'@fastify/static':
optional: true
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/testing@11.2.3':
resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==}
peerDependencies:
@ -901,6 +937,9 @@ packages:
'@types/react':
optional: true
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@sinclair/typebox@0.34.52':
resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==}
@ -1245,6 +1284,9 @@ packages:
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
@ -2051,6 +2093,10 @@ packages:
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
hasBin: true
js-yaml@5.3.0:
resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==}
hasBin: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@ -2649,6 +2695,9 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
swagger-ui-dist@5.32.13:
resolution: {integrity: sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==}
synckit@0.11.13:
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
engines: {node: ^14.18.0 || >=16.0.0}
@ -3380,6 +3429,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.16.0': {}
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@ -3412,6 +3463,11 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
reflect-metadata: 0.2.2
'@nestjs/platform-express@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)':
dependencies:
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
@ -3424,6 +3480,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nestjs/swagger@11.4.7(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/core': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.1.1(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)
js-yaml: 5.3.0
lodash: 4.18.1
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.13
'@nestjs/testing@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)':
dependencies:
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
@ -3659,6 +3727,8 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
'@scarf/scarf@1.4.0': {}
'@sinclair/typebox@0.34.52': {}
'@sinonjs/commons@3.0.1':
@ -4022,6 +4092,8 @@ snapshots:
dependencies:
sprintf-js: 1.0.3
argparse@2.0.1: {}
asap@2.0.6: {}
asynckit@0.4.0: {}
@ -5045,6 +5117,10 @@ snapshots:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@5.3.0:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {}
json-parse-even-better-errors@2.3.1: {}
@ -5586,6 +5662,10 @@ snapshots:
dependencies:
has-flag: 4.0.0
swagger-ui-dist@5.32.13:
dependencies:
'@scarf/scarf': 1.4.0
synckit@0.11.13:
dependencies:
'@pkgr/core': 0.3.6

View File

@ -1,6 +1,7 @@
allowBuilds:
'@parcel/watcher': true
'@prisma/engines': true
'@scarf/scarf': false
esbuild: true
prisma: true
unrs-resolver: true

View File

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

95
prisma/checkout.prisma Normal file
View File

@ -0,0 +1,95 @@
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[]
pricing OrderPricing?
events CommerceEvent[]
@@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")
}

24
prisma/events.prisma Normal file
View File

@ -0,0 +1,24 @@
model CommerceEvent {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
orderId String @map("order_id") @db.Uuid
kind String @db.VarChar(80)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
order Order @relation(fields: [orderId, organizationId], references: [id, organizationId], onDelete: Restrict)
delivery EventDelivery?
@@unique([orderId, kind])
@@index([organizationId, createdAt, id])
@@map("commerce_events")
}
model EventDelivery {
eventId String @id @map("event_id") @db.Uuid
attempts Int @default(0)
availableAt DateTime @default(now()) @map("available_at") @db.Timestamptz(3)
leaseToken String? @map("lease_token") @db.Uuid
leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamptz(3)
deliveredAt DateTime? @map("delivered_at") @db.Timestamptz(3)
lastErrorCode String? @map("last_error_code") @db.VarChar(80)
event CommerceEvent @relation(fields: [eventId], references: [id], onDelete: Restrict)
@@index([deliveredAt, availableAt, leaseExpiresAt])
@@map("event_deliveries")
}

View File

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

View File

@ -5,5 +5,12 @@
"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",
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e",
"20260913140451_supplier_materials": "c1f59e082ddf97443c17d52745068d9e6cbafb3d4e1088126a150f555c1cf0a1"
}

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

@ -0,0 +1,94 @@
-- CreateEnum
CREATE TYPE "TaxMode" AS ENUM ('INCLUSIVE', 'EXCLUSIVE');
-- CreateTable
CREATE TABLE "commerce_events" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"order_id" UUID NOT NULL,
"kind" VARCHAR(80) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "commerce_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "event_deliveries" (
"event_id" UUID NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"available_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lease_token" UUID,
"lease_expires_at" TIMESTAMPTZ(3),
"delivered_at" TIMESTAMPTZ(3),
"last_error_code" VARCHAR(80),
CONSTRAINT "event_deliveries_pkey" PRIMARY KEY ("event_id")
);
-- CreateTable
CREATE TABLE "pricing_policies" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"currency" CHAR(3) NOT NULL,
"country_code" CHAR(2) NOT NULL,
"region" VARCHAR(100) NOT NULL DEFAULT '',
"tax_mode" "TaxMode" NOT NULL,
"merchandise_tax_bps" INTEGER NOT NULL,
"shipping_fee" DECIMAL(12,2) NOT NULL,
"shipping_tax_bps" INTEGER NOT NULL,
"free_shipping_minimum" DECIMAL(12,2),
"active" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "pricing_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "order_pricing" (
"order_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"policy_id" UUID NOT NULL,
"policy_snapshot" JSONB NOT NULL,
"merchandise_tax" DECIMAL(16,2) NOT NULL,
"shipping_net" DECIMAL(16,2) NOT NULL,
"shipping_tax" DECIMAL(16,2) NOT NULL,
"tax_total" DECIMAL(16,2) NOT NULL,
"payable_total" DECIMAL(16,2) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "order_pricing_pkey" PRIMARY KEY ("order_id")
);
-- CreateIndex
CREATE INDEX "commerce_events_organization_id_created_at_id_idx" ON "commerce_events"("organization_id", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "commerce_events_order_id_kind_key" ON "commerce_events"("order_id", "kind");
-- CreateIndex
CREATE INDEX "event_deliveries_delivered_at_available_at_lease_expires_at_idx" ON "event_deliveries"("delivered_at", "available_at", "lease_expires_at");
-- CreateIndex
CREATE INDEX "pricing_policies_organization_id_currency_country_code_regi_idx" ON "pricing_policies"("organization_id", "currency", "country_code", "region", "active");
-- CreateIndex
CREATE UNIQUE INDEX "pricing_policies_id_organization_id_key" ON "pricing_policies"("id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "order_pricing_order_id_organization_id_key" ON "order_pricing"("order_id", "organization_id");
-- AddForeignKey
ALTER TABLE "commerce_events" ADD CONSTRAINT "commerce_events_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 "event_deliveries" ADD CONSTRAINT "event_deliveries_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "commerce_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pricing_policies" ADD CONSTRAINT "pricing_policies_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_pricing" ADD CONSTRAINT "order_pricing_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_pricing" ADD CONSTRAINT "order_pricing_policy_id_organization_id_fkey" FOREIGN KEY ("policy_id", "organization_id") REFERENCES "pricing_policies"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,63 @@
ALTER TABLE pricing_policies ADD CONSTRAINT pricing_rule_bounds CHECK (
merchandise_tax_bps BETWEEN 0 AND 10000 AND shipping_tax_bps BETWEEN 0 AND 10000
AND shipping_fee >= 0 AND (free_shipping_minimum IS NULL OR free_shipping_minimum >= 0)
AND currency IN ('INR','USD','EUR','GBP') AND country_code ~ '^[A-Z]{2}$' AND region = upper(region)
);
CREATE UNIQUE INDEX pricing_one_active_scope ON pricing_policies (organization_id, currency, country_code, region)
WHERE active = true;
CREATE FUNCTION protect_pricing_policy() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF (to_jsonb(NEW) - 'active') IS DISTINCT FROM (to_jsonb(OLD) - 'active') THEN
RAISE EXCEPTION 'Pricing policies are immutable; create a new version';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER pricing_policy_immutable BEFORE UPDATE ON pricing_policies
FOR EACH ROW EXECUTE FUNCTION protect_pricing_policy();
CREATE FUNCTION reject_commerce_snapshot_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Commerce snapshots and events are immutable';
END;
$$;
CREATE TRIGGER order_pricing_immutable BEFORE UPDATE OR DELETE ON order_pricing
FOR EACH ROW EXECUTE FUNCTION reject_commerce_snapshot_mutation();
CREATE TRIGGER commerce_events_immutable BEFORE UPDATE OR DELETE ON commerce_events
FOR EACH ROW EXECUTE FUNCTION reject_commerce_snapshot_mutation();
CREATE FUNCTION reconcile_order_pricing() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
p pricing_policies%ROWTYPE;
merchandise NUMERIC;
expected_tax NUMERIC;
shipping NUMERIC;
shipping_tax NUMERIC;
payable NUMERIC;
BEGIN
SELECT * INTO STRICT p FROM pricing_policies WHERE id = NEW.policy_id AND organization_id = NEW.organization_id;
SELECT merchandise_total INTO STRICT merchandise FROM orders WHERE id = NEW.order_id AND organization_id = NEW.organization_id;
expected_tax := ROUND(merchandise * p.merchandise_tax_bps /
(10000 + CASE WHEN p.tax_mode = 'INCLUSIVE' THEN p.merchandise_tax_bps ELSE 0 END), 2);
shipping := CASE WHEN p.free_shipping_minimum IS NOT NULL AND merchandise >= p.free_shipping_minimum
THEN 0 ELSE p.shipping_fee END;
shipping_tax := ROUND(shipping * p.shipping_tax_bps / 10000, 2);
payable := merchandise + shipping + shipping_tax + CASE WHEN p.tax_mode = 'EXCLUSIVE' THEN expected_tax ELSE 0 END;
IF NEW.merchandise_tax <> expected_tax OR NEW.shipping_net <> shipping OR NEW.shipping_tax <> shipping_tax
OR NEW.tax_total <> expected_tax + shipping_tax OR NEW.payable_total <> payable THEN
RAISE EXCEPTION 'Final pricing does not match the selected policy';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER order_pricing_reconciled BEFORE INSERT ON order_pricing
FOR EACH ROW EXECUTE FUNCTION reconcile_order_pricing();
ALTER TABLE commerce_events ADD CONSTRAINT commerce_event_kind CHECK (kind IN ('order.created','order.cancelled'));
ALTER TABLE event_deliveries ADD CONSTRAINT delivery_attempt_bounds CHECK (attempts BETWEEN 0 AND 5);
ALTER TABLE event_deliveries ADD CONSTRAINT delivery_lease_pair CHECK ((lease_token IS NULL) = (lease_expires_at IS NULL));
UPDATE roles SET permissions = ARRAY(
SELECT DISTINCT permission FROM unnest(permissions || ARRAY[
'pricing.manage', 'operations.read', 'notifications.retry'
]::text[]) AS permission ORDER BY permission
) WHERE is_system = true;

View File

@ -0,0 +1,59 @@
CREATE TYPE "MaterialKind" AS ENUM ('WAX', 'FRAGRANCE', 'WICK', 'DYE', 'VESSEL', 'PACKAGING', 'LABEL', 'OTHER');
CREATE TYPE "MaterialUnit" AS ENUM ('GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE');
CREATE TABLE "suppliers" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"code" VARCHAR(32) NOT NULL,
"contact_name" VARCHAR(160) NOT NULL,
"email" VARCHAR(254) NOT NULL,
"phone" VARCHAR(24) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "suppliers_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "materials" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"code" VARCHAR(32) NOT NULL,
"kind" "MaterialKind" NOT NULL,
"unit" "MaterialUnit" NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "materials_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "supplier_materials" (
"supplier_id" UUID NOT NULL,
"material_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"supplier_sku" VARCHAR(64) NOT NULL,
"lead_time_days" INTEGER NOT NULL,
"min_order_quantity" DECIMAL(12,3) NOT NULL,
"unit_price" DECIMAL(12,2) NOT NULL,
"currency" CHAR(3) NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "supplier_materials_pkey" PRIMARY KEY ("supplier_id", "material_id")
);
CREATE UNIQUE INDEX "suppliers_organization_id_code_key" ON "suppliers"("organization_id", "code");
CREATE UNIQUE INDEX "suppliers_id_organization_id_key" ON "suppliers"("id", "organization_id");
CREATE INDEX "suppliers_organization_id_active_name_id_idx" ON "suppliers"("organization_id", "active", "name", "id");
CREATE UNIQUE INDEX "materials_organization_id_code_key" ON "materials"("organization_id", "code");
CREATE UNIQUE INDEX "materials_id_organization_id_key" ON "materials"("id", "organization_id");
CREATE INDEX "materials_organization_id_active_kind_name_id_idx" ON "materials"("organization_id", "active", "kind", "name", "id");
CREATE INDEX "supplier_materials_material_id_organization_id_active_idx" ON "supplier_materials"("material_id", "organization_id", "active");
ALTER TABLE "suppliers" ADD CONSTRAINT "suppliers_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "materials" ADD CONSTRAINT "materials_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_supplier_id_organization_id_fkey" FOREIGN KEY ("supplier_id", "organization_id") REFERENCES "suppliers"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_material_id_organization_id_fkey" FOREIGN KEY ("material_id", "organization_id") REFERENCES "materials"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "supplier_materials" ADD CONSTRAINT "supplier_materials_bounds" CHECK ("lead_time_days" BETWEEN 0 AND 365 AND "min_order_quantity" > 0 AND "unit_price" >= 0 AND "currency" IN ('INR', 'USD', 'EUR', 'GBP'));
UPDATE "roles" SET "permissions" = ARRAY(SELECT DISTINCT permission FROM unnest("permissions" || ARRAY['procurement.read', 'procurement.manage']::text[]) AS permission ORDER BY permission) WHERE "is_system" = true;

41
prisma/pricing.prisma Normal file
View File

@ -0,0 +1,41 @@
enum TaxMode {
INCLUSIVE
EXCLUSIVE
}
model PricingPolicy {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(100)
currency String @db.Char(3)
countryCode String @map("country_code") @db.Char(2)
region String @default("") @db.VarChar(100)
taxMode TaxMode @map("tax_mode")
merchandiseTaxBps Int @map("merchandise_tax_bps")
shippingFee Decimal @map("shipping_fee") @db.Decimal(12,2)
shippingTaxBps Int @map("shipping_tax_bps")
freeShippingMinimum Decimal? @map("free_shipping_minimum") @db.Decimal(12,2)
active Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
prices OrderPricing[]
@@unique([id, organizationId])
@@index([organizationId, currency, countryCode, region, active])
@@map("pricing_policies")
}
model OrderPricing {
orderId String @id @map("order_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
policyId String @map("policy_id") @db.Uuid
policySnapshot Json @map("policy_snapshot")
merchandiseTax Decimal @map("merchandise_tax") @db.Decimal(16,2)
shippingNet Decimal @map("shipping_net") @db.Decimal(16,2)
shippingTax Decimal @map("shipping_tax") @db.Decimal(16,2)
taxTotal Decimal @map("tax_total") @db.Decimal(16,2)
payableTotal Decimal @map("payable_total") @db.Decimal(16,2)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
order Order @relation(fields: [orderId, organizationId], references: [id, organizationId], onDelete: Restrict)
policy PricingPolicy @relation(fields: [policyId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([orderId, organizationId])
@@map("order_pricing")
}

77
prisma/procurement.prisma Normal file
View File

@ -0,0 +1,77 @@
enum MaterialKind {
WAX
FRAGRANCE
WICK
DYE
VESSEL
PACKAGING
LABEL
OTHER
}
enum MaterialUnit {
GRAM
KILOGRAM
MILLILITRE
LITRE
PIECE
METRE
}
model Supplier {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(160)
code String @db.VarChar(32)
contactName String @map("contact_name") @db.VarChar(160)
email String @db.VarChar(254)
phone String @db.VarChar(24)
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
materials SupplierMaterial[]
@@unique([organizationId, code])
@@unique([id, organizationId])
@@index([organizationId, active, name, id])
@@map("suppliers")
}
model Material {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(160)
code String @db.VarChar(32)
kind MaterialKind
unit MaterialUnit
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
suppliers SupplierMaterial[]
@@unique([organizationId, code])
@@unique([id, organizationId])
@@index([organizationId, active, kind, name, id])
@@map("materials")
}
model SupplierMaterial {
supplierId String @map("supplier_id") @db.Uuid
materialId String @map("material_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
supplierSku String @map("supplier_sku") @db.VarChar(64)
leadTimeDays Int @map("lead_time_days")
minOrderQuantity Decimal @map("min_order_quantity") @db.Decimal(12, 3)
unitPrice Decimal @map("unit_price") @db.Decimal(12, 2)
currency String @db.Char(3)
active Boolean @default(true)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
supplier Supplier @relation(fields: [supplierId, organizationId], references: [id, organizationId], onDelete: Restrict)
material Material @relation(fields: [materialId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@id([supplierId, materialId])
@@index([materialId, organizationId, active])
@@map("supplier_materials")
}

View File

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

View File

@ -1,6 +1,9 @@
import { CheckoutModule } from './checkout/checkout.module';
import { OperationsModule } from './operations/operations.module';
import { CatalogModule } from './catalog/catalog.module';
import { AddressesModule } from './addresses/addresses.module';
import { InventoryModule } from './inventory/inventory.module';
import { ProcurementModule } from './procurement/procurement.module';
import { Module } from '@nestjs/common';
import { EnvironmentModule } from './config/environment.module';
import { IdentityModule } from './identity/identity.module';
@ -14,6 +17,9 @@ import { HealthModule } from './health/health.module';
CatalogModule,
AddressesModule,
InventoryModule,
ProcurementModule,
CheckoutModule,
OperationsModule,
],
})
export class AppModule {}

View File

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

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,88 @@
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';
import { snapshotPrice } from '../pricing/snapshot-price';
import { enqueueEvent } from '../events/enqueue-event';
@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, pricing: 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);
const pricing = await snapshotPrice(tx, order);
await enqueueEvent(tx, actor.organizationId, order.id, 'order.created');
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, pricing });
});
}
}

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,48 @@
import type {
Order,
OrderLine,
OrderPricing,
} 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[]; pricing?: OrderPricing | null },
) {
return {
id: order.id,
status: orderStatus(order),
currency: order.currency,
subtotal: order.subtotal,
discount: order.discount,
merchandiseTotal: order.merchandiseTotal,
pricingStatus: order.pricing ? 'FINALIZED' : 'UNFINALIZED',
taxTotal: order.pricing?.taxTotal ?? null,
shippingTotal: order.pricing?.shippingNet ?? null,
payableTotal: order.pricing?.payableTotal ?? null,
pricing: order.pricing
? {
policyId: order.pricing.policyId,
policy: order.pricing.policySnapshot,
merchandiseTax: order.pricing.merchandiseTax,
shippingTax: order.pricing.shippingTax,
}
: 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,
})),
};
}

101
src/checkout/order.store.ts Normal file
View File

@ -0,0 +1,101 @@
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';
import { enqueueEvent } from '../events/enqueue-event';
@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' } }, pricing: true },
});
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,
pricing: 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, pricing: true },
});
await enqueueEvent(tx, actor.organizationId, id, 'order.cancelled');
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

@ -81,4 +81,19 @@ export const COMMERCE_ERRORS = {
'Product variant is not available for reservation',
'Non-sellable variant reservation rejected',
],
SUPPLIER_NOT_FOUND: [
404,
'Supplier not found',
'Scoped supplier lookup failed',
],
MATERIAL_NOT_FOUND: [
404,
'Material not found',
'Scoped material lookup failed',
],
SUPPLIER_MATERIAL_NOT_FOUND: [
404,
'Supplier material is unavailable',
'Scoped supplier-material compatibility lookup failed',
],
} as const;

View File

@ -1,4 +1,11 @@
import { CHECKOUT_ERRORS } from './checkout-errors';
import { OPERATIONS_ERRORS } from './operations-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,
...OPERATIONS_ERRORS,
} as const;
export type ErrorCode = keyof typeof ERRORS;

View File

@ -0,0 +1,62 @@
export const OPERATIONS_ERRORS = {
PAYMENT_REFERENCE_MISMATCH: [
409,
'Payment references do not match the order',
'Payment reference verification failed',
],
PAYMENT_CURRENCY_MISMATCH: [
409,
'Payment currency does not match',
'Captured payment currency mismatch',
],
PAYMENT_AMOUNT_MISMATCH: [
409,
'Payment amount does not match',
'Captured payment amount mismatch',
],
REFUND_AMOUNT_INVALID: [
409,
'Refund exceeds the available captured amount',
'Refund amount or aggregate bound rejected',
],
SHIPMENT_PAYMENT_REQUIRED: [
409,
'Payment is required before shipping',
'Unpaid shipment attempt rejected',
],
SHIPMENT_QUANTITY_INVALID: [
409,
'Shipment quantity exceeds remaining items',
'Shipment quantity bound rejected',
],
RETURN_QUANTITY_INVALID: [
409,
'Return quantity exceeds eligible delivered items',
'Return quantity bound rejected',
],
RETURN_WINDOW_CLOSED: [
409,
'Return request is outside the configured window',
'Return timing rule rejected request',
],
PRICING_POLICY_NOT_FOUND: [
404,
'Pricing policy not found',
'Scoped pricing policy lookup failed',
],
EVENT_NOT_FOUND: [
404,
'Commerce event not found',
'Scoped commerce event lookup failed',
],
EVENT_NOT_RETRYABLE: [
409,
'This event cannot be retried',
'Delivered or actively leased event retry rejected',
],
DELIVERY_UNAVAILABLE: [
503,
'Notification delivery is not configured',
'No commerce notification adapter configured',
],
} as const;

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
import { AppError } from './errors/app-error';
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: z.ZodType<T>) {}
constructor(readonly schema: z.ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {

View File

@ -8,6 +8,10 @@ const schema = z
.enum(['development', 'test', 'production'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
SWAGGER_ENABLED: z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional(),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10),
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
CORS_ORIGINS: z

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

@ -0,0 +1,132 @@
import 'reflect-metadata';
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { z } from 'zod';
import { configureApp } from '../configure-app';
import { parseEnvironment } from '../config/environment';
import { SchemaPipe } from '../common/validation.pipe';
import { Public } from '../identity/access.decorator';
import { configureSwagger } from './configure-swagger';
@Controller('sample')
class SampleController {
@Public()
@Post()
create(
@Body(
new SchemaPipe(
z.strictObject({
email: z.email(),
date: z.iso.datetime().transform((value) => new Date(value)),
}),
),
)
input: unknown,
) {
return input;
}
@Get()
list(
@Query(
new SchemaPipe(
z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
),
)
input: unknown,
) {
return input;
}
@Post('logout')
@HttpCode(204)
logout() {}
}
describe('Swagger documentation', () => {
async function fixture(nodeEnv: string, enabled?: string) {
const module = await Test.createTestingModule({
controllers: [SampleController],
}).compile();
const app = module.createNestApplication();
app.useLogger(false);
const env = parseEnvironment({
DATABASE_URL: 'postgresql://local/test',
NODE_ENV: nodeEnv,
SWAGGER_ENABLED: enabled,
});
configureApp(app, env);
configureSwagger(app, env);
await app.init();
return { app, api: request(app.getHttpServer()) };
}
it('serves UI, local assets and accurate input/auth/error documentation', async () => {
const { app, api } = await fixture('development');
try {
const ui = await api.get('/api/docs/').expect(200);
expect(ui.text).toContain('swagger-ui');
expect(ui.headers['content-security-policy']).not.toContain(
'upgrade-insecure-requests',
);
await api.get('/api/docs/swagger-ui-bundle.js').expect(200);
const init = await api.get('/api/docs/swagger-ui-init.js').expect(200);
expect(init.text).toContain('"persistAuthorization": false');
const { body: doc } = await api.get('/api/docs-json').expect(200);
const sample = doc.paths['/api/v1/sample'];
expect(sample.post.security).toEqual([]);
expect(sample.get.security).toEqual([{ bearer: [] }]);
expect(
sample.post.requestBody.content['application/json'].schema,
).toMatchObject({
required: ['email', 'date'],
properties: { email: { format: 'email' }, date: { type: 'string' } },
});
expect(sample.get.parameters).toContainEqual(
expect.objectContaining({
name: 'limit',
in: 'query',
schema: expect.objectContaining({ maximum: 100, default: 20 }),
}),
);
expect(
doc.paths['/api/v1/sample/logout'].post.responses['204'],
).toBeDefined();
expect(doc.components.schemas.ApiError.properties.code.enum).toContain(
'REQUEST_INVALID',
);
expect(
(await api.get('/api/v1/sample')).headers['content-security-policy'],
).toContain('upgrade-insecure-requests');
} finally {
await app.close();
}
});
it.each([
['production', undefined],
['test', undefined],
['development', 'false'],
])('hides docs in %s when enabled=%s', async (mode, enabled) => {
const { app, api } = await fixture(mode!, enabled);
try {
await api.get('/api/docs-json').expect(404);
await api.get('/api/docs').expect(404);
} finally {
await app.close();
}
});
it('allows an explicit opt-in and rejects invalid settings', async () => {
const { app, api } = await fixture('production', 'true');
try {
await api.get('/api/docs-json').expect(200);
} finally {
await app.close();
}
expect(() =>
parseEnvironment({
DATABASE_URL: 'postgresql://local/test',
SWAGGER_ENABLED: 'yes',
}),
).toThrow('SWAGGER_ENABLED');
});
});

View File

@ -0,0 +1,51 @@
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import type { Environment } from '../config/environment';
import { documentErrors } from './document-errors';
import { enrichOperations } from './enrich-operations';
export function configureSwagger(
app: INestApplication,
environment: Environment,
): void {
if (!(environment.SWAGGER_ENABLED ?? environment.NODE_ENV === 'development'))
return;
const config = new DocumentBuilder()
.setTitle('Mani Candles API')
.setDescription(
'Log in using the Auth endpoints, then paste the accessToken into Authorize. Requests run against this server and may change data. Input schemas come from runtime validation; cross-field business rules are enforced by the API. Payment and shipping integrations currently remain test blueprints.',
)
.setVersion('1')
.addBearerAuth({
type: 'http',
scheme: 'bearer',
description: 'Opaque session access token returned by login.',
})
.build();
const document = SwaggerModule.createDocument(app, config, {
operationIdFactory: (controller, method) => `${controller}_${method}`,
});
enrichOperations(app, document);
documentErrors(document);
app.use(
'/api/docs',
helmet.contentSecurityPolicy({
directives: { upgradeInsecureRequests: null },
}),
);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: '/api/docs-json',
raw: ['json'],
customSiteTitle: 'Mani Candles API',
swaggerOptions: {
persistAuthorization: false,
validatorUrl: null,
queryConfigEnabled: false,
docExpansion: 'none',
filter: true,
displayRequestDuration: true,
tagsSorter: 'alpha',
},
});
}

View File

@ -0,0 +1,37 @@
import type { OpenAPIObject } from '@nestjs/swagger';
import { ERRORS } from '../common/errors/error-catalog';
export function documentErrors(document: OpenAPIObject): void {
document.components ??= {};
document.components.schemas ??= {};
document.components.schemas.ApiError = {
type: 'object',
required: ['statusCode', 'code', 'message', 'requestId'],
properties: {
statusCode: { type: 'integer' },
code: { type: 'string', enum: Object.keys(ERRORS) },
message: { type: 'string' },
requestId: { type: 'string', format: 'uuid' },
fields: { type: 'array', items: { type: 'string' } },
},
};
for (const path of Object.values(document.paths)) {
for (const operation of Object.values(path)) {
if (
!operation ||
typeof operation !== 'object' ||
!('responses' in operation)
)
continue;
operation.responses.default = {
description:
'Error response. A distinct code identifies the failure; requestId correlates with server logs. Validation errors can include field paths. Possible errors vary by endpoint.',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ApiError' },
},
},
};
}
}
}

View File

@ -0,0 +1,86 @@
import type { INestApplication } from '@nestjs/common';
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants';
import { ModulesContainer } from '@nestjs/core';
import type {
OpenAPIObject,
OperationObject,
SchemaObject,
} from '@nestjs/swagger';
import { z } from 'zod';
import { SchemaPipe } from '../common/validation.pipe';
import {
PUBLIC_ROUTE,
REQUIRED_PERMISSION,
} from '../identity/access.decorator';
type Argument = { data?: string; pipes: unknown[] };
/** Reuse runtime validation metadata so documentation cannot drift from input DTOs. */
export function enrichOperations(
app: INestApplication,
document: OpenAPIObject,
): void {
const operations = new Map<string, OperationObject>();
for (const path of Object.values(document.paths)) {
for (const value of Object.values(path)) {
if (value && typeof value === 'object' && 'operationId' in value)
operations.set(value.operationId as string, value as OperationObject);
}
}
for (const module of app.get(ModulesContainer).values()) {
for (const { metatype } of module.controllers.values()) {
if (!metatype) continue;
const prototype = metatype.prototype as Record<string, object>;
for (const method of Object.getOwnPropertyNames(prototype)) {
const operation = operations.get(`${metatype.name}_${method}`);
if (!operation) continue;
const handler = prototype[method];
const isPublic =
Reflect.getMetadata(PUBLIC_ROUTE, handler) ??
Reflect.getMetadata(PUBLIC_ROUTE, metatype);
const permission =
Reflect.getMetadata(REQUIRED_PERMISSION, handler) ??
Reflect.getMetadata(REQUIRED_PERMISSION, metatype);
operation.security = isPublic ? [] : [{ bearer: [] }];
operation.summary = method.replace(/([a-z])([A-Z])/g, '$1 $2');
operation.description = permission
? `Required permission: ${permission}.`
: isPublic
? 'Public endpoint.'
: 'Requires a valid session; access is scoped to the authenticated principal.';
const argumentsMetadata: Record<string, Argument> =
Reflect.getMetadata(ROUTE_ARGS_METADATA, metatype, method) ?? {};
for (const [key, argument] of Object.entries(argumentsMetadata)) {
const pipe = argument.pipes.find(
(candidate) => candidate instanceof SchemaPipe,
);
if (!(pipe instanceof SchemaPipe)) continue;
const schema = z.toJSONSchema(pipe.schema, {
target: 'openapi-3.0',
io: 'input',
}) as SchemaObject;
if (key.startsWith('3:')) {
operation.requestBody = {
required: true,
content: { 'application/json': { schema } },
};
} else if (key.startsWith('4:')) {
operation.parameters = (operation.parameters ?? []).filter(
(item) => '$ref' in item || item.in !== 'query',
);
for (const [name, property] of Object.entries(
schema.properties ?? {},
)) {
operation.parameters.push({
name,
in: 'query',
required: schema.required?.includes(name) ?? false,
schema: property,
});
}
}
}
}
}
}
}

View File

@ -0,0 +1,11 @@
export interface DeliveryMessage {
id: string;
organizationId: string;
orderId: string;
kind: string;
}
export abstract class DeliveryPort {
abstract assertConfigured(): void;
// Delivery is at-least-once. The adapter must deduplicate using message.id.
abstract deliver(message: DeliveryMessage): Promise<void>;
}

View File

@ -0,0 +1,42 @@
import { Injectable, Logger } from '@nestjs/common';
import { DeliveryPort } from './delivery.port';
import { DeliveryStore } from './delivery.store';
@Injectable()
export class DeliveryService {
private readonly logger = new Logger('CommerceDelivery');
constructor(
private readonly delivery: DeliveryPort,
private readonly store: DeliveryStore,
) {}
async dispatchOne(): Promise<boolean> {
this.delivery.assertConfigured();
const row = await this.store.claim();
if (!row) return false;
try {
await this.delivery.deliver({
id: row.event.id,
organizationId: row.event.organizationId,
orderId: row.event.orderId,
kind: row.event.kind,
});
const result = await this.store.acknowledge(row.eventId, row.leaseToken!);
if (!result.count)
this.logger.warn(
JSON.stringify({
event: 'DELIVERY_LEASE_LOST',
eventId: row.eventId,
}),
);
} catch {
await this.store.fail(row.eventId, row.leaseToken!, row.attempts);
this.logger.error(
JSON.stringify({
event: 'DELIVERY_FAILED',
eventId: row.eventId,
attempt: row.attempts,
}),
);
}
return true;
}
}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../database/database.service';
@Injectable()
export class DeliveryStore {
constructor(private readonly db: DatabaseService) {}
claim() {
return this.db.$transaction(async (tx) => {
const now = new Date();
const rows = await tx.$queryRaw<{ event_id: string }[]>`
SELECT event_id FROM event_deliveries
WHERE delivered_at IS NULL AND attempts < 5 AND available_at <= ${now}
AND (lease_expires_at IS NULL OR lease_expires_at <= ${now})
ORDER BY available_at, event_id FOR UPDATE SKIP LOCKED LIMIT 1`;
if (!rows[0]) return null;
return tx.eventDelivery.update({
where: { eventId: rows[0].event_id },
data: {
attempts: { increment: 1 },
leaseToken: randomUUID(),
leaseExpiresAt: new Date(now.getTime() + 60000),
},
include: { event: true },
});
});
}
acknowledge(eventId: string, leaseToken: string) {
return this.db.eventDelivery.updateMany({
where: {
eventId,
leaseToken,
deliveredAt: null,
leaseExpiresAt: { gt: new Date() },
},
data: {
deliveredAt: new Date(),
leaseToken: null,
leaseExpiresAt: null,
lastErrorCode: null,
},
});
}
fail(eventId: string, leaseToken: string, attempts: number) {
return this.db.eventDelivery.updateMany({
where: { eventId, leaseToken, deliveredAt: null },
data: {
leaseToken: null,
leaseExpiresAt: null,
lastErrorCode: 'DELIVERY_FAILED',
availableAt: new Date(
Date.now() + Math.min(3600, 30 * 2 ** (attempts - 1)) * 1000,
),
},
});
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { DeliveryPort } from './delivery.port';
@Injectable()
export class DisabledDelivery extends DeliveryPort {
assertConfigured(): void {
throw new AppError('DELIVERY_UNAVAILABLE');
}
async deliver(): Promise<void> {
this.assertConfigured();
}
}

View File

@ -0,0 +1,13 @@
import type { Prisma } from '../generated/prisma/client';
export async function enqueueEvent(
tx: Prisma.TransactionClient,
organizationId: string,
orderId: string,
kind: 'order.created' | 'order.cancelled',
) {
return tx.commerceEvent.upsert({
where: { orderId_kind: { orderId, kind } },
create: { organizationId, orderId, kind, delivery: { create: {} } },
update: {},
});
}

View File

@ -0,0 +1,63 @@
import { AppError } from '../common/errors/app-error';
export function assertShipment(
paid: boolean,
ordered: number,
shipped: number,
requested: number,
) {
if (!paid) throw new AppError('SHIPMENT_PAYMENT_REQUIRED');
if (
![ordered, shipped, requested].every(Number.isSafeInteger) ||
ordered < 1 ||
shipped < 0 ||
requested < 1 ||
shipped + requested > ordered
)
throw new AppError('SHIPMENT_QUANTITY_INVALID');
}
export type TrackingStatus = 'PLANNED' | 'SHIPPED' | 'DELIVERED';
const rank: Record<TrackingStatus, number> = {
PLANNED: 0,
SHIPPED: 1,
DELIVERED: 2,
};
export function advanceTracking(
current: TrackingStatus,
incoming: TrackingStatus,
): TrackingStatus {
return rank[incoming] > rank[current] ? incoming : current;
}
export function assertReturn(input: {
delivered: number;
returned: number;
pending: number;
requested: number;
deliveredAt: Date;
now: Date;
windowDays: number;
}) {
const quantities = [
input.delivered,
input.returned,
input.pending,
input.requested,
];
if (
!quantities.every(Number.isSafeInteger) ||
quantities.some((value) => value < 0) ||
input.requested < 1 ||
input.returned + input.pending + input.requested > input.delivered
)
throw new AppError('RETURN_QUANTITY_INVALID');
if (
!Number.isSafeInteger(input.windowDays) ||
input.windowDays < 0 ||
!Number.isFinite(input.deliveredAt.getTime()) ||
!Number.isFinite(input.now.getTime()) ||
input.now < input.deliveredAt ||
input.now.getTime() - input.deliveredAt.getTime() >
input.windowDays * 86400000
)
throw new AppError('RETURN_WINDOW_CLOSED');
}

View File

@ -1,4 +1,10 @@
export const PERMISSIONS = [
'pricing.manage',
'operations.read',
'notifications.retry',
'coupons.manage',
'orders.read',
'orders.manage',
'users.read',
'users.create',
'users.approve',
@ -14,5 +20,7 @@ export const PERMISSIONS = [
'inventory.adjust',
'inventory.reserve',
'inventory.commit',
'procurement.read',
'procurement.manage',
] as const;
export type Permission = (typeof PERMISSIONS)[number];

View File

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

View File

@ -5,11 +5,13 @@ import { AppModule } from './app.module';
import { ENVIRONMENT } from './config/environment.module';
import type { Environment } from './config/environment';
import { configureApp } from './configure-app';
import { configureSwagger } from './documentation/configure-swagger';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const environment = app.get<Environment>(ENVIRONMENT);
configureApp(app, environment);
configureSwagger(app, environment);
await app.listen(environment.PORT);
}

View File

@ -0,0 +1,41 @@
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 { pageSchema, type PageInput } from '../identity/identity.schemas';
import { SchemaPipe } from '../common/validation.pipe';
import { OperationsStore } from './operations.store';
@Controller('admin/operations')
export class OperationsController {
constructor(private readonly operations: OperationsStore) {}
@Get('summary')
@RequirePermission('operations.read')
summary(@CurrentPrincipal() actor: Principal) {
return this.operations.summary(actor);
}
@Get('events')
@RequirePermission('operations.read')
events(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.operations.events(actor, page);
}
@Post('events/:id/retry')
@RequirePermission('notifications.retry')
retry(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.operations.retry(actor, id);
}
}

View File

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { PricingController } from '../pricing/pricing.controller';
import { PricingStore } from '../pricing/pricing.store';
import { OperationsController } from './operations.controller';
import { OperationsStore } from './operations.store';
import { DeliveryPort } from '../events/delivery.port';
import { DisabledDelivery } from '../events/disabled-delivery';
import { DeliveryStore } from '../events/delivery.store';
import { DeliveryService } from '../events/delivery.service';
@Module({
imports: [DatabaseModule, IdentityModule],
controllers: [PricingController, OperationsController],
providers: [
PricingStore,
OperationsStore,
DeliveryStore,
DeliveryService,
{ provide: DeliveryPort, useClass: DisabledDelivery },
],
})
export class OperationsModule {}

View File

@ -0,0 +1,119 @@
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 type { PageInput } from '../identity/identity.schemas';
import { AppError } from '../common/errors/app-error';
import { recordAudit } from '../identity/audit';
@Injectable()
export class OperationsStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
async summary(actor: Principal) {
const organizationId = actor.organizationId;
const now = new Date();
return this.db.$transaction(
async (tx) => ({
pendingOrders: await tx.order.count({
where: {
organizationId,
status: 'PENDING_PAYMENT',
expiresAt: { gt: now },
},
}),
expiredOrders: await tx.order.count({
where: {
organizationId,
status: 'PENDING_PAYMENT',
expiresAt: { lte: now },
},
}),
cancelledOrders: await tx.order.count({
where: { organizationId, status: 'CANCELLED' },
}),
unpricedActiveOrders: await tx.order.count({
where: {
organizationId,
status: 'PENDING_PAYMENT',
expiresAt: { gt: now },
pricing: null,
},
}),
pendingDeliveries: await tx.eventDelivery.count({
where: {
event: { organizationId },
deliveredAt: null,
attempts: { lt: 5 },
},
}),
failedDeliveries: await tx.eventDelivery.count({
where: {
event: { organizationId },
deliveredAt: null,
attempts: { gte: 5 },
},
}),
}),
{ isolationLevel: 'RepeatableRead' },
);
}
events(actor: Principal, page: PageInput) {
return this.db.commerceEvent.findMany({
where: { organizationId: actor.organizationId },
take: page.limit,
skip: page.offset,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: {
id: true,
orderId: true,
kind: true,
createdAt: true,
delivery: {
select: {
attempts: true,
availableAt: true,
deliveredAt: true,
lastErrorCode: true,
},
},
},
});
}
retry(actor: Principal, id: string) {
return this.access.mutate(actor, 'notifications.retry', async (tx) => {
const event = await tx.commerceEvent.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!event) throw new AppError('EVENT_NOT_FOUND');
const updated = await tx.eventDelivery.updateMany({
where: {
eventId: id,
deliveredAt: null,
OR: [
{ leaseExpiresAt: null },
{ leaseExpiresAt: { lte: new Date() } },
],
},
data: {
attempts: 0,
availableAt: new Date(),
leaseToken: null,
leaseExpiresAt: null,
lastErrorCode: null,
},
});
if (!updated.count) throw new AppError('EVENT_NOT_RETRYABLE');
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'notification.retry.requested',
id,
);
return { queued: true };
});
}
}

View File

@ -0,0 +1,26 @@
// Provider-neutral contract only. No production implementation is registered.
export interface PaymentRequest {
orderId: string;
amountMinor: string;
currency: string;
idempotencyKey: string;
}
export interface CapturedPayment {
eventId: string;
paymentId: string;
orderId: string;
amountMinor: string;
currency: string;
}
export interface RefundRequest {
paymentId: string;
amountMinor: string;
idempotencyKey: string;
}
export interface GatewayPort {
createPayment(input: PaymentRequest): Promise<{ paymentId: string }>;
verifyCapture(rawBody: Buffer, signature: string): CapturedPayment;
requestRefund(
input: RefundRequest,
): Promise<{ refundId: string; status: 'PENDING' }>;
}

View File

@ -0,0 +1,44 @@
import { AppError } from '../common/errors/app-error';
import type { CapturedPayment } from './gateway.contract';
export interface ExpectedPayment {
orderId: string;
paymentId: string;
amountMinor: string;
currency: string;
expiresAt: Date;
cancelled: boolean;
alreadyCaptured: boolean;
}
export function captureDecision(
expected: ExpectedPayment,
event: CapturedPayment,
now: Date,
) {
if (
event.orderId !== expected.orderId ||
event.paymentId !== expected.paymentId
)
throw new AppError('PAYMENT_REFERENCE_MISMATCH');
if (event.currency !== expected.currency)
throw new AppError('PAYMENT_CURRENCY_MISMATCH');
if (event.amountMinor !== expected.amountMinor)
throw new AppError('PAYMENT_AMOUNT_MISMATCH');
if (expected.alreadyCaptured) return 'DUPLICATE' as const;
if (expected.cancelled || expected.expiresAt <= now)
return 'REVIEW_AND_REFUND' as const;
return 'COMMIT_RESERVED_STOCK' as const;
}
export function refundAmount(
captured: bigint,
completed: bigint,
pending: bigint,
requested: bigint,
) {
if (
[captured, completed, pending].some((value) => value < 0n) ||
requested <= 0n ||
completed + pending + requested > captured
)
throw new AppError('REFUND_AMOUNT_INVALID');
return requested.toString();
}

View File

@ -0,0 +1,50 @@
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, type PageInput } from '../identity/identity.schemas';
import {
pricingSchema,
pricingStatusSchema,
type PricingInput,
} from './pricing.schema';
import { PricingStore } from './pricing.store';
@Controller('admin/pricing-policies')
@RequirePermission('pricing.manage')
export class PricingController {
constructor(private readonly pricing: PricingStore) {}
@Get()
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.pricing.list(actor, page);
}
@Post()
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(pricingSchema)) input: PricingInput,
) {
return this.pricing.create(actor, input);
}
@Patch(':id/status')
status(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(pricingStatusSchema)) input: { active: boolean },
) {
return this.pricing.status(actor, id, input.active);
}
}

View File

@ -0,0 +1,42 @@
import { decimal, minor } from '../checkout/money';
export type PriceRule = {
taxMode: 'INCLUSIVE' | 'EXCLUSIVE';
merchandiseTaxBps: number;
shippingTaxBps: number;
shippingFee: string;
freeShippingMinimum: string | null;
};
function roundRatio(amount: bigint, numerator: number, denominator: number) {
return (
(amount * BigInt(numerator) + BigInt(denominator) / 2n) /
BigInt(denominator)
);
}
export function calculatePrice(merchandiseTotal: string, rule: PriceRule) {
const merchandise = minor(merchandiseTotal);
const taxDivisor =
rule.taxMode === 'INCLUSIVE' ? 10000 + rule.merchandiseTaxBps : 10000;
const merchandiseTax = roundRatio(
merchandise,
rule.merchandiseTaxBps,
taxDivisor,
);
const shippingNet =
rule.freeShippingMinimum !== null &&
merchandise >= minor(rule.freeShippingMinimum)
? 0n
: minor(rule.shippingFee);
const shippingTax = roundRatio(shippingNet, rule.shippingTaxBps, 10000);
const payable =
merchandise +
shippingNet +
shippingTax +
(rule.taxMode === 'EXCLUSIVE' ? merchandiseTax : 0n);
return {
merchandiseTax: decimal(merchandiseTax),
shippingNet: decimal(shippingNet),
shippingTax: decimal(shippingTax),
taxTotal: decimal(merchandiseTax + shippingTax),
payableTotal: decimal(payable),
};
}

View File

@ -0,0 +1,25 @@
import { z } from 'zod';
import { text } from '../common/input';
import { CURRENCIES } from '../common/currency';
const money = z.string().regex(/^(0|[1-9]\d{0,9})\.\d{2}$/);
export const pricingSchema = z
.object({
name: text(100),
currency: z.enum(CURRENCIES),
countryCode: z
.string()
.trim()
.toUpperCase()
.regex(/^[A-Z]{2}$/),
region: text(100, 0)
.transform((value) => value.toUpperCase())
.default(''),
taxMode: z.enum(['INCLUSIVE', 'EXCLUSIVE']),
merchandiseTaxBps: z.number().int().min(0).max(10000),
shippingFee: money,
shippingTaxBps: z.number().int().min(0).max(10000),
freeShippingMinimum: money.nullable().default(null),
})
.strict();
export const pricingStatusSchema = z.object({ active: z.boolean() }).strict();
export type PricingInput = z.infer<typeof pricingSchema>;

View File

@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { AccessStore } from '../identity/access.store';
import { DatabaseService } from '../database/database.service';
import type { Principal } from '../identity/identity.types';
import { recordAudit } from '../identity/audit';
import { AppError } from '../common/errors/app-error';
import type { PageInput } from '../identity/identity.schemas';
import type { PricingInput } from './pricing.schema';
@Injectable()
export class PricingStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal, page: PageInput) {
return this.db.pricingPolicy.findMany({
where: { organizationId: actor.organizationId },
take: page.limit,
skip: page.offset,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
}
create(actor: Principal, input: PricingInput) {
return this.access.mutate(actor, 'pricing.manage', async (tx) => {
const policy = await tx.pricingPolicy.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'pricing.created',
policy.id,
);
return policy;
});
}
status(actor: Principal, id: string, active: boolean) {
return this.access.mutate(actor, 'pricing.manage', async (tx) => {
const row = await tx.pricingPolicy.findFirst({
where: { id, organizationId: actor.organizationId },
});
if (!row) throw new AppError('PRICING_POLICY_NOT_FOUND');
if (active)
await tx.pricingPolicy.updateMany({
where: {
organizationId: actor.organizationId,
currency: row.currency,
countryCode: row.countryCode,
region: row.region,
active: true,
},
data: { active: false },
});
const updated = await tx.pricingPolicy.update({
where: { id },
data: { active },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'pricing.status.changed',
id,
);
return updated;
});
}
}

View File

@ -0,0 +1,45 @@
import type { Order, Prisma } from '../generated/prisma/client';
import { calculatePrice } from './pricing.policy';
export async function snapshotPrice(
tx: Prisma.TransactionClient,
order: Order,
) {
const address = order.addressSnapshot as {
countryCode: string;
region: string;
};
const policy = await tx.pricingPolicy.findFirst({
where: {
organizationId: order.organizationId,
currency: order.currency,
active: true,
countryCode: address.countryCode,
region: { in: ['', address.region.toUpperCase()] },
},
orderBy: { region: 'desc' },
});
if (!policy) return null;
const rule = {
taxMode: policy.taxMode,
merchandiseTaxBps: policy.merchandiseTaxBps,
shippingFee: policy.shippingFee.toString(),
shippingTaxBps: policy.shippingTaxBps,
freeShippingMinimum: policy.freeShippingMinimum?.toString() ?? null,
};
return tx.orderPricing.create({
data: {
orderId: order.id,
organizationId: order.organizationId,
policyId: policy.id,
policySnapshot: {
...rule,
name: policy.name,
countryCode: policy.countryCode,
region: policy.region,
currency: policy.currency,
},
...calculatePrice(order.merchandiseTotal.toString(), rule),
},
});
}

View File

@ -0,0 +1,105 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import {
CurrentPrincipal,
RequirePermission,
} from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { ProcurementStore } from './procurement.store';
import {
materialSchema,
procurementQuery,
supplierMaterialSchema,
supplierSchema,
type MaterialInput,
type ProcurementQuery,
type SupplierInput,
type SupplierMaterialInput,
} from './procurement.schemas';
@Controller()
export class ProcurementController {
constructor(private readonly procurement: ProcurementStore) {}
@Get('suppliers')
@RequirePermission('procurement.read')
listSuppliers(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery,
) {
return this.procurement.listSuppliers(actor, query);
}
@Post('suppliers')
@RequirePermission('procurement.manage')
createSupplier(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(supplierSchema)) input: SupplierInput,
) {
return this.procurement.saveSupplier(actor, input);
}
@Put('suppliers/:id')
@RequirePermission('procurement.manage')
updateSupplier(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(supplierSchema)) input: SupplierInput,
) {
return this.procurement.saveSupplier(actor, input, id);
}
@Get('suppliers/:supplierId/materials')
@RequirePermission('procurement.read')
listSupplierMaterials(
@CurrentPrincipal() actor: Principal,
@Param('supplierId', ParseUUIDPipe) supplierId: string,
) {
return this.procurement.listSupplierMaterials(actor, supplierId);
}
@Put('suppliers/:supplierId/materials/:materialId')
@RequirePermission('procurement.manage')
saveSupplierMaterial(
@CurrentPrincipal() actor: Principal,
@Param('supplierId', ParseUUIDPipe) supplierId: string,
@Param('materialId', ParseUUIDPipe) materialId: string,
@Body(new SchemaPipe(supplierMaterialSchema)) input: SupplierMaterialInput,
) {
return this.procurement.saveSupplierMaterial(
actor,
supplierId,
materialId,
input,
);
}
@Get('materials')
@RequirePermission('procurement.read')
listMaterials(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(procurementQuery)) query: ProcurementQuery,
) {
return this.procurement.listMaterials(actor, query);
}
@Post('materials')
@RequirePermission('procurement.manage')
createMaterial(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(materialSchema)) input: MaterialInput,
) {
return this.procurement.saveMaterial(actor, input);
}
@Put('materials/:id')
@RequirePermission('procurement.manage')
updateMaterial(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(materialSchema)) input: MaterialInput,
) {
return this.procurement.saveMaterial(actor, input, id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { ProcurementController } from './procurement.controller';
import { ProcurementStore } from './procurement.store';
@Module({
imports: [DatabaseModule, IdentityModule],
providers: [ProcurementStore],
controllers: [ProcurementController],
})
export class ProcurementModule {}

View File

@ -0,0 +1,76 @@
import { z } from 'zod';
import { CURRENCIES } from '../common/currency';
import { text } from '../common/input';
import { pageSchema } from '../identity/identity.schemas';
const code = z
.string()
.trim()
.toUpperCase()
.max(32)
.regex(/^[A-Z0-9][A-Z0-9_-]*$/);
const decimal = (scale: number) =>
z
.string()
.regex(/^(0|[1-9]\d{0,8})\.\d+$/)
.refine((value) => value.split('.').at(-1)?.length === scale);
export const supplierSchema = z
.object({
name: text(160),
code,
contactName: text(160),
email: z
.email()
.max(254)
.transform((value) => value.toLowerCase()),
phone: z
.string()
.trim()
.max(24)
.regex(/^\+?[0-9 ()-]{7,24}$/),
active: z.boolean().default(true),
})
.strict();
export const materialSchema = z
.object({
name: text(160),
code,
kind: z.enum([
'WAX',
'FRAGRANCE',
'WICK',
'DYE',
'VESSEL',
'PACKAGING',
'LABEL',
'OTHER',
]),
unit: z.enum(['GRAM', 'KILOGRAM', 'MILLILITRE', 'LITRE', 'PIECE', 'METRE']),
active: z.boolean().default(true),
})
.strict();
export const supplierMaterialSchema = z
.object({
supplierSku: z
.string()
.trim()
.max(64)
.regex(/^[^<>\u0000-\u001F\u007F]*$/),
leadTimeDays: z.number().int().min(0).max(365),
minOrderQuantity: decimal(3).refine((value) => value !== '0.000'),
unitPrice: decimal(2),
currency: z.enum(CURRENCIES).default('INR'),
active: z.boolean().default(true),
})
.strict();
export const procurementQuery = pageSchema
.extend({
active: z.coerce.boolean().optional(),
search: text(100).optional(),
})
.strict();
export type SupplierInput = z.infer<typeof supplierSchema>;
export type MaterialInput = z.infer<typeof materialSchema>;
export type SupplierMaterialInput = z.infer<typeof supplierMaterialSchema>;
export type ProcurementQuery = z.infer<typeof procurementQuery>;

View File

@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { DatabaseService } from '../database/database.service';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import type {
MaterialInput,
ProcurementQuery,
SupplierInput,
SupplierMaterialInput,
} from './procurement.schemas';
@Injectable()
export class ProcurementStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
listSuppliers(actor: Principal, query: ProcurementQuery) {
return this.db.supplier.findMany({
where: {
organizationId: actor.organizationId,
...(query.active === undefined ? {} : { active: query.active }),
...(query.search
? { name: { contains: query.search, mode: 'insensitive' } }
: {}),
},
orderBy: [{ name: 'asc' }, { id: 'asc' }],
take: query.limit,
skip: query.offset,
});
}
listMaterials(actor: Principal, query: ProcurementQuery) {
return this.db.material.findMany({
where: {
organizationId: actor.organizationId,
...(query.active === undefined ? {} : { active: query.active }),
...(query.search
? { name: { contains: query.search, mode: 'insensitive' } }
: {}),
},
orderBy: [{ name: 'asc' }, { id: 'asc' }],
take: query.limit,
skip: query.offset,
});
}
saveSupplier(actor: Principal, input: SupplierInput, id?: string) {
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
if (
id &&
!(await tx.supplier.findFirst({
where: { id, organizationId: actor.organizationId },
}))
)
throw new AppError('SUPPLIER_NOT_FOUND');
const supplier = id
? await tx.supplier.update({ where: { id }, data: input })
: await tx.supplier.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'supplier.updated' : 'supplier.created',
supplier.id,
);
return supplier;
});
}
saveMaterial(actor: Principal, input: MaterialInput, id?: string) {
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
if (
id &&
!(await tx.material.findFirst({
where: { id, organizationId: actor.organizationId },
}))
)
throw new AppError('MATERIAL_NOT_FOUND');
const material = id
? await tx.material.update({ where: { id }, data: input })
: await tx.material.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'material.updated' : 'material.created',
material.id,
);
return material;
});
}
saveSupplierMaterial(
actor: Principal,
supplierId: string,
materialId: string,
input: SupplierMaterialInput,
) {
return this.access.mutate(actor, 'procurement.manage', async (tx) => {
if (
!(await tx.supplier.findFirst({
where: { id: supplierId, organizationId: actor.organizationId },
}))
)
throw new AppError('SUPPLIER_NOT_FOUND');
if (
!(await tx.material.findFirst({
where: { id: materialId, organizationId: actor.organizationId },
}))
)
throw new AppError('MATERIAL_NOT_FOUND');
const relation = await tx.supplierMaterial.upsert({
where: { supplierId_materialId: { supplierId, materialId } },
create: {
...input,
supplierId,
materialId,
organizationId: actor.organizationId,
},
update: input,
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'supplier_material.saved',
supplierId,
);
return relation;
});
}
async listSupplierMaterials(actor: Principal, supplierId: string) {
if (
!(await this.db.supplier.findFirst({
where: { id: supplierId, organizationId: actor.organizationId },
}))
)
throw new AppError('SUPPLIER_NOT_FOUND');
return this.db.supplierMaterial.findMany({
where: { supplierId, organizationId: actor.organizationId },
include: { material: true },
orderBy: { material: { name: 'asc' } },
});
}
}

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

View File

@ -0,0 +1,73 @@
import { Logger } from '@nestjs/common';
import { DeliveryService } from '../src/events/delivery.service';
import type { DeliveryStore } from '../src/events/delivery.store';
describe('delivery lease and logging policy', () => {
afterEach(() => jest.restoreAllMocks());
it('warns when delivery succeeds after lease ownership is lost', async () => {
const warning = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const store = {
claim: jest.fn().mockResolvedValue({
eventId: 'event',
leaseToken: 'lease',
attempts: 1,
event: {
id: 'event',
organizationId: 'org',
orderId: 'order',
kind: 'order.created',
},
}),
acknowledge: jest.fn().mockResolvedValue({ count: 0 }),
fail: jest.fn(),
};
const service = new DeliveryService(
{ assertConfigured() {}, async deliver() {} },
store as unknown as DeliveryStore,
);
expect(await service.dispatchOne()).toBe(true);
expect(warning).toHaveBeenCalledWith(
JSON.stringify({ event: 'DELIVERY_LEASE_LOST', eventId: 'event' }),
);
expect(store.fail).not.toHaveBeenCalled();
});
it('never logs adapter exception text or notification content', async () => {
const logging = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
const store = {
claim: jest.fn().mockResolvedValue({
eventId: 'event',
leaseToken: 'lease',
attempts: 2,
event: {
id: 'event',
organizationId: 'org',
orderId: 'order',
kind: 'order.created',
},
}),
acknowledge: jest.fn(),
fail: jest.fn().mockResolvedValue({ count: 1 }),
};
const service = new DeliveryService(
{
assertConfigured() {},
async deliver() {
throw new Error('private-address-and-secret');
},
},
store as unknown as DeliveryStore,
);
await service.dispatchOne();
expect(logging).toHaveBeenCalledWith(
JSON.stringify({
event: 'DELIVERY_FAILED',
eventId: 'event',
attempt: 2,
}),
);
expect(JSON.stringify(logging.mock.calls)).not.toContain('private-address');
});
});

View File

@ -0,0 +1,28 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture } from './helpers/checkout';
import { DeliveryStore } from '../src/events/delivery.store';
const native = process.env.TEST_DATABASE_URL ? describe : describe.skip;
native('native PostgreSQL event claims', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
it('assigns distinct events to simultaneous workers', async () => {
for (let index = 0; index < 2; index++) {
const f = await checkoutFixture(ctx);
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
}
const store = ctx.app.get(DeliveryStore);
const claims = await Promise.all([store.claim(), store.claim()]);
expect(claims.every(Boolean)).toBe(true);
expect(new Set(claims.map((claim) => claim!.eventId)).size).toBe(2);
});
});

200
test/events.spec.ts Normal file
View File

@ -0,0 +1,200 @@
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture } from './helpers/checkout';
import { secondActor } from './helpers/commerce';
import { DeliveryService } from '../src/events/delivery.service';
import { DeliveryStore } from '../src/events/delivery.store';
import { DisabledDelivery } from '../src/events/disabled-delivery';
import * as audit from '../src/identity/audit';
describe('transactional commerce event delivery', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
await ctx.db.eventDelivery.updateMany({
data: { deliveredAt: new Date(), leaseToken: null, leaseExpiresAt: null },
});
});
afterEach(() => jest.restoreAllMocks());
async function event() {
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);
return {
...f,
order: order.body,
event: await ctx.db.commerceEvent.findFirstOrThrow({
where: { orderId: order.body.id },
}),
};
}
it('emits one event per committed transition and none after rollback', async () => {
const f = await event();
await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
await ctx
.api()
.post('/api/v1/orders/' + f.order.id + '/cancel')
.auth(f.actor.token, { type: 'bearer' })
.expect(201);
await ctx
.api()
.post('/api/v1/orders/' + f.order.id + '/cancel')
.auth(f.actor.token, { type: 'bearer' })
.expect(201);
expect(
await ctx.db.commerceEvent.count({ where: { orderId: f.order.id } }),
).toBe(2);
const next = await checkoutFixture(ctx);
jest
.spyOn(audit, 'recordAudit')
.mockRejectedValueOnce(new Error('Synthetic final write failure'));
await ctx
.api()
.post('/api/v1/checkout')
.auth(next.actor.token, { type: 'bearer' })
.send(next.input)
.expect(500);
expect(
await ctx.db.commerceEvent.count({
where: { order: { userId: next.actor.userId } },
}),
).toBe(0);
await expect(
ctx.executeSql(`DELETE FROM commerce_events WHERE id = '${f.event.id}'`),
).rejects.toThrow();
});
it('keeps delivery disabled by default and uses a test adapter with stable event IDs', async () => {
const f = await event();
await expect(
ctx.app.get(DeliveryService).dispatchOne(),
).rejects.toMatchObject({ code: 'DELIVERY_UNAVAILABLE' });
await expect(new DisabledDelivery().deliver()).rejects.toThrow();
const adapter = {
assertConfigured: jest.fn(),
deliver: jest.fn().mockResolvedValue(undefined),
};
const service = new DeliveryService(adapter, ctx.app.get(DeliveryStore));
expect(await service.dispatchOne()).toBe(true);
expect(adapter.deliver).toHaveBeenCalledWith({
id: f.event.id,
orderId: f.order.id,
organizationId: f.actor.organizationId,
kind: 'order.created',
});
expect(await service.dispatchOne()).toBe(false);
expect(
(
await ctx.db.eventDelivery.findUniqueOrThrow({
where: { eventId: f.event.id },
})
).deliveredAt,
).not.toBeNull();
});
it('backs off failures, bounds attempts and permits authorized manual retries', async () => {
const f = await event();
const store = ctx.app.get(DeliveryStore);
const service = new DeliveryService(
{
assertConfigured() {},
deliver: jest
.fn()
.mockRejectedValue(new Error('Private transport error')),
},
store,
);
await service.dispatchOne();
const failed = await ctx.db.eventDelivery.findUniqueOrThrow({
where: { eventId: f.event.id },
});
expect(failed).toMatchObject({
attempts: 1,
lastErrorCode: 'DELIVERY_FAILED',
leaseToken: null,
});
expect(failed.availableAt.getTime()).toBeGreaterThan(Date.now());
expect(await store.claim()).toBeNull();
await ctx.db.eventDelivery.update({
where: { eventId: f.event.id },
data: { attempts: 5, availableAt: new Date(0) },
});
expect(await store.claim()).toBeNull();
const summary = await ctx
.api()
.get('/api/v1/admin/operations/summary')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(summary.body.failedDeliveries).toBe(1);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(f.actor.token, { type: 'bearer' })
.expect(403);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(ctx.token, { type: 'bearer' })
.expect(201);
expect((await store.claim())?.attempts).toBe(1);
});
it('rejects stale lease acknowledgements and protects cross-organization event access', async () => {
const f = await event();
const store = ctx.app.get(DeliveryStore);
const first = (await store.claim())!;
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(ctx.token, { type: 'bearer' })
.expect(409);
await ctx.db.eventDelivery.update({
where: { eventId: f.event.id },
data: { leaseExpiresAt: new Date(0) },
});
const next = (await store.claim())!;
expect(next.leaseToken).not.toBe(first.leaseToken);
expect((await store.acknowledge(f.event.id, first.leaseToken!)).count).toBe(
0,
);
expect((await store.fail(f.event.id, first.leaseToken!, 1)).count).toBe(0);
await store.acknowledge(f.event.id, next.leaseToken!);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(ctx.token, { type: 'bearer' })
.expect(409);
const other = await secondActor(ctx, false, [
'operations.read',
'notifications.retry',
]);
const list = await ctx
.api()
.get('/api/v1/admin/operations/events')
.auth(other.token, { type: 'bearer' })
.expect(200);
expect(list.body).toEqual([]);
await ctx
.api()
.post('/api/v1/admin/operations/events/' + f.event.id + '/retry')
.auth(other.token, { type: 'bearer' })
.expect(404);
const own = await ctx
.api()
.get('/api/v1/admin/operations/events?limit=1')
.auth(ctx.token, { type: 'bearer' })
.expect(200);
expect(own.body[0].delivery.leaseToken).toBeUndefined();
});
});

View File

@ -0,0 +1,44 @@
import {
assertShipment,
assertReturn,
advanceTracking,
} from '../src/fulfillment/fulfillment.policy';
describe('shipping and return blueprint policies', () => {
it('requires payment and bounds partial shipments', () => {
expect(() => assertShipment(true, 5, 2, 3)).not.toThrow();
expect(() => assertShipment(false, 5, 0, 1)).toThrow();
for (const quantity of [0, -1, 0.5, 4])
expect(() => assertShipment(true, 5, 2, quantity)).toThrow();
expect(() => assertShipment(true, 0, 0, 1)).toThrow();
expect(() => assertShipment(true, 5, -1, 1)).toThrow();
});
it('does not regress tracking on duplicated or out-of-order events', () => {
expect(advanceTracking('PLANNED', 'SHIPPED')).toBe('SHIPPED');
expect(advanceTracking('SHIPPED', 'DELIVERED')).toBe('DELIVERED');
expect(advanceTracking('DELIVERED', 'SHIPPED')).toBe('DELIVERED');
expect(advanceTracking('DELIVERED', 'DELIVERED')).toBe('DELIVERED');
});
it('counts existing and pending returns and enforces the configured window', () => {
const input = {
delivered: 5,
returned: 1,
pending: 1,
requested: 3,
deliveredAt: new Date(0),
now: new Date(86400000),
windowDays: 1,
};
expect(() => assertReturn(input)).not.toThrow();
for (const change of [
{ requested: 4 },
{ requested: 0 },
{ requested: 0.5 },
{ delivered: -1 },
{ now: new Date(86400001) },
{ now: new Date(-1) },
{ windowDays: -1 },
{ deliveredAt: new Date('invalid') },
])
expect(() => assertReturn({ ...input, ...change })).toThrow();
});
});

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

View File

@ -0,0 +1,62 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import type {
GatewayPort,
PaymentRequest,
RefundRequest,
} from '../../src/payments/gateway.contract';
import { commandHash } from '../../src/inventory/inventory.policy';
const captureSchema = z
.object({
eventId: z.uuid(),
paymentId: z.uuid(),
orderId: z.uuid(),
amountMinor: z.string().regex(/^[1-9]\d{0,15}$/),
currency: z.enum(['INR', 'USD', 'EUR', 'GBP']),
})
.strict();
// Deliberately confined to tests: this signature format is not a real provider protocol.
export class FakeGateway implements GatewayPort {
private readonly payments = new Map<
string,
{ hash: string; paymentId: string }
>();
private readonly refunds = new Map<
string,
{ hash: string; refundId: string }
>();
constructor(private readonly secret: string) {}
async createPayment(input: PaymentRequest) {
const hash = commandHash(input.orderId, input.amountMinor, input.currency);
const prior = this.payments.get(input.idempotencyKey);
if (prior && prior.hash !== hash)
throw new Error('Gateway idempotency conflict');
const result = prior ?? { hash, paymentId: randomUUID() };
this.payments.set(input.idempotencyKey, result);
return { paymentId: result.paymentId };
}
sign(raw: Buffer) {
return createHmac('sha256', this.secret).update(raw).digest('hex');
}
verifyCapture(raw: Buffer, signature: string) {
if (
raw.length > 32768 ||
!/^[a-f0-9]{64}$/.test(signature) ||
!timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(this.sign(raw), 'hex'),
)
)
throw new Error('Invalid gateway signature');
return captureSchema.parse(JSON.parse(raw.toString('utf8')));
}
async requestRefund(input: RefundRequest) {
const hash = commandHash(input.paymentId, input.amountMinor);
const prior = this.refunds.get(input.idempotencyKey);
if (prior && prior.hash !== hash)
throw new Error('Refund idempotency conflict');
const result = prior ?? { hash, refundId: randomUUID() };
this.refunds.set(input.idempotencyKey, result);
return { refundId: result.refundId, status: 'PENDING' as const };
}
}

View File

@ -9,9 +9,10 @@ import { DatabaseService } from '../../src/database/database.service';
import { BootstrapService } from '../../src/identity/bootstrap.service';
import { RecoveryMailer } from '../../src/identity/recovery-mailer';
import { configureApp } from '../../src/configure-app';
import { configureSwagger } from '../../src/documentation/configure-swagger';
export const ownerPassword = 'correct horse battery staple';
export async function identityApp() {
export async function identityApp(swagger = false) {
const database = await testDatabase();
const env = parseEnvironment({
DATABASE_URL: database.connectionUrl,
@ -31,6 +32,7 @@ export async function identityApp() {
const app = module.createNestApplication();
app.useLogger(false);
configureApp(app, env);
if (swagger) configureSwagger(app, { ...env, SWAGGER_ENABLED: true });
await app.init();
const db = app.get(DatabaseService);
const owner = await app.get(BootstrapService).createOwner('Mani Candles', {

35
test/helpers/pricing.ts Normal file
View File

@ -0,0 +1,35 @@
import { randomUUID } from 'node:crypto';
import type { IdentityApp } from './identity-app';
export function pricingInput() {
// Synthetic test rules; these are not Mani Candles tax or shipping settings.
return {
name: 'Test-' + randomUUID(),
currency: 'INR',
countryCode: 'IN',
region: '',
taxMode: 'EXCLUSIVE',
merchandiseTaxBps: 1800,
shippingFee: '50.00',
shippingTaxBps: 1800,
freeShippingMinimum: null,
};
}
export async function activatePricing(
ctx: IdentityApp,
changes: Record<string, unknown> = {},
) {
const policy = await ctx
.api()
.post('/api/v1/admin/pricing-policies')
.auth(ctx.token, { type: 'bearer' })
.send({ ...pricingInput(), ...changes })
.expect(201);
expect(policy.body.active).toBe(false);
await ctx
.api()
.patch('/api/v1/admin/pricing-policies/' + policy.body.id + '/status')
.auth(ctx.token, { type: 'bearer' })
.send({ active: true })
.expect(200);
return policy.body;
}

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

View File

@ -0,0 +1,99 @@
import { randomUUID } from 'node:crypto';
import { FakeGateway } from './helpers/fake-gateway';
import { captureDecision, refundAmount } from '../src/payments/payment.policy';
describe('provider-neutral payment blueprint', () => {
const gateway = new FakeGateway('test-only-secret');
it('replays payment creation and rejects changed retry payloads', async () => {
const input = {
orderId: randomUUID(),
amountMinor: '12500',
currency: 'INR',
idempotencyKey: randomUUID(),
};
const first = await gateway.createPayment(input);
expect(await gateway.createPayment(input)).toEqual(first);
await expect(
gateway.createPayment({ ...input, amountMinor: '1' }),
).rejects.toThrow('idempotency');
});
it('verifies exact raw bytes and rejects malformed, oversized and forged callbacks', () => {
const event = {
eventId: randomUUID(),
paymentId: randomUUID(),
orderId: randomUUID(),
amountMinor: '12500',
currency: 'INR',
};
const raw = Buffer.from(JSON.stringify(event));
expect(gateway.verifyCapture(raw, gateway.sign(raw))).toEqual(event);
for (const signature of ['', 'x'.repeat(64), '0'.repeat(64)])
expect(() => gateway.verifyCapture(raw, signature)).toThrow('signature');
expect(() =>
gateway.verifyCapture(
Buffer.concat([raw, Buffer.from(' ')]),
gateway.sign(raw),
),
).toThrow('signature');
const oversized = Buffer.alloc(32769);
expect(() =>
gateway.verifyCapture(oversized, gateway.sign(oversized)),
).toThrow('signature');
const malformed = Buffer.from('{}');
expect(() =>
gateway.verifyCapture(malformed, gateway.sign(malformed)),
).toThrow();
});
it('requires matching capture references, amount and currency before fulfillment', () => {
const now = new Date();
const event = {
eventId: randomUUID(),
paymentId: randomUUID(),
orderId: randomUUID(),
amountMinor: '12500',
currency: 'INR',
};
const expected = {
...event,
expiresAt: new Date(now.getTime() + 1000),
cancelled: false,
alreadyCaptured: false,
};
expect(captureDecision(expected, event, now)).toBe('COMMIT_RESERVED_STOCK');
expect(
captureDecision({ ...expected, alreadyCaptured: true }, event, now),
).toBe('DUPLICATE');
expect(captureDecision({ ...expected, cancelled: true }, event, now)).toBe(
'REVIEW_AND_REFUND',
);
expect(captureDecision({ ...expected, expiresAt: now }, event, now)).toBe(
'REVIEW_AND_REFUND',
);
for (const change of [
{ paymentId: randomUUID() },
{ orderId: randomUUID() },
{ amountMinor: '1' },
{ currency: 'USD' },
])
expect(() =>
captureDecision(expected, { ...event, ...change }, now),
).toThrow();
});
it('counts pending refunds against captured money and makes refund retries idempotent', async () => {
expect(refundAmount(1000n, 200n, 300n, 500n)).toBe('500');
for (const request of [0n, -1n, 501n])
expect(() => refundAmount(1000n, 200n, 300n, request)).toThrow();
expect(() => refundAmount(1000n, -1n, 0n, 1n)).toThrow();
const request = {
paymentId: randomUUID(),
amountMinor: '500',
idempotencyKey: randomUUID(),
};
expect(await gateway.requestRefund(request)).toEqual(
await gateway.requestRefund(request),
);
await expect(
gateway.requestRefund({ ...request, amountMinor: '501' }),
).rejects.toThrow('idempotency');
});
});

View File

@ -0,0 +1,75 @@
import { calculatePrice, type PriceRule } from '../src/pricing/pricing.policy';
import { pricingSchema } from '../src/pricing/pricing.schema';
const rule: PriceRule = {
taxMode: 'EXCLUSIVE',
merchandiseTaxBps: 1800,
shippingFee: '50.00',
shippingTaxBps: 1800,
freeShippingMinimum: null,
};
describe('configurable pricing calculations', () => {
it('calculates exclusive merchandise tax and shipping tax exactly', () => {
expect(calculatePrice('100.00', rule)).toEqual({
merchandiseTax: '18.00',
shippingNet: '50.00',
shippingTax: '9.00',
taxTotal: '27.00',
payableTotal: '177.00',
});
});
it('extracts inclusive tax without adding it twice and handles odd basis points', () => {
expect(
calculatePrice('118.00', { ...rule, taxMode: 'INCLUSIVE' }),
).toMatchObject({ merchandiseTax: '18.00', payableTotal: '177.00' });
expect(
calculatePrice('100.01', {
...rule,
taxMode: 'INCLUSIVE',
merchandiseTaxBps: 1,
}),
).toMatchObject({ merchandiseTax: '0.01' });
});
it('applies free shipping at the discounted merchandise threshold and rounds small values', () => {
const free = { ...rule, freeShippingMinimum: '100.00' };
expect(calculatePrice('100.00', free)).toMatchObject({
shippingNet: '0.00',
shippingTax: '0.00',
payableTotal: '118.00',
});
expect(calculatePrice('99.99', free).shippingNet).toBe('50.00');
expect(
calculatePrice('0.01', {
...rule,
merchandiseTaxBps: 5000,
shippingFee: '0.00',
}).merchandiseTax,
).toBe('0.01');
expect(
calculatePrice('0', { ...free, freeShippingMinimum: '0.00' })
.payableTotal,
).toBe('0.00');
});
it('requires explicit bounded rules and rejects active or unknown fields on creation', () => {
const input = {
...rule,
name: 'Test policy',
currency: 'INR',
countryCode: 'in',
};
expect(pricingSchema.parse(input)).toMatchObject({
countryCode: 'IN',
region: '',
});
for (const change of [
{ merchandiseTaxBps: -1 },
{ shippingTaxBps: 10001 },
{ shippingFee: '-1.00' },
{ active: true },
{ countryCode: 'IND' },
{ currency: 'XXX' },
])
expect(pricingSchema.safeParse({ ...input, ...change }).success).toBe(
false,
);
});
});

Some files were not shown because too many files have changed in this diff Show More