Compare commits

...

25 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
mihir 1ed11afffe docs(commerce): document APIs migration policy and security verification 2026-09-10 11:23:24 +05:30
mihir b3624cc026 feat(inventory): add immutable ledger and transactional reservations 2026-09-10 11:23:23 +05:30
mihir 4457cfdf1a feat(addresses): manage private customer addresses and defaults 2026-09-10 11:23:23 +05:30
mihir 14900fe83e feat(catalog): add products variants and published storefront 2026-09-10 11:23:23 +05:30
mihir cad3f4ec29 feat(security): classify API errors and enforce bounded requests 2026-09-10 11:23:23 +05:30
mihir eb976d2c8c feat(data): add timestamped commerce migrations and history checks 2026-09-10 11:23:22 +05:30
mihir a27a9d87a7 fix(deps): patch vulnerable transitive dependencies 2026-09-10 11:23:22 +05:30
Mihir Motiyani 4fbe1391b8 Merge pull request 'feat/identity-access' (#1) from feat/identity-access into main
Reviewed-on: #1
2026-09-09 19:30:05 +05:30
171 changed files with 8788 additions and 139 deletions

View File

@ -12,3 +12,7 @@ RECOVERY_TTL_MINUTES=15
# SMTP_PASSWORD=replace_me
# 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

@ -27,6 +27,7 @@ jobs:
node-version: '24'
- run: npm install --global pnpm@11.19.0
- run: pnpm install --frozen-lockfile
- run: pnpm security:audit
- run: pnpm db:generate
- run: node scripts/verify-migrations.mjs
- run: pnpm check

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ coverage/
!.env.example
src/generated/
*.log
.tmp/

View File

@ -1,6 +1,6 @@
# Mani Candles backend
Phase 1A provides the service foundation. Phase 1B adds staff account provisioning, authentication, sessions, recovery, configurable RBAC, and audit records. Commerce APIs follow in Phase 1C.
Phase 1A provides the service foundation. Phase 1B adds staff account provisioning, authentication, sessions, recovery, configurable RBAC, and audit records. Phase 1C implements core commerce APIs.
## Setup
@ -29,3 +29,11 @@ Development: run `pnpm dev` to compile on changes and `pnpm start:watch` in a se
Production must use a managed secret store, TLS termination, a restricted database account, backups, and the deployment migration command. Do not expose this foundation as a completed commerce platform.
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.

27
docs/commerce-api.md Normal file
View File

@ -0,0 +1,27 @@
# Phase 1C commerce API
All routes use /api/v1. Protected routes require a bearer session and current permissions. Organization and user scope come from the session. PUT replaces editable input fields. Module schemas define the field contracts.
| Routes | Permission |
| ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| GET /products, /products/:id, /catalog-groups | catalog.read |
| POST /products, PUT /products/:id, POST /products/:id/variants, PUT /products/:id/variants/:variantId | catalog.manage |
| POST /catalog-groups, PUT /catalog-groups/:id | catalog.manage |
| PATCH /products/:id/status | catalog.publish |
| GET /storefront/:organizationId/products and /:id | Public, throttled |
| GET/POST /addresses, PUT/DELETE /addresses/:id | Authenticated, own addresses |
| GET /inventory/warehouses, /inventory/stock-items, /inventory/stock-items/:id, /inventory/stock-items/:id/ledger | inventory.read |
| POST /inventory/warehouses, /inventory/stock-items | inventory.manage |
| POST /inventory/adjustments | inventory.adjust |
| POST /inventory/reservations, GET /inventory/reservations/:id, POST /inventory/reservations/:id/release | inventory.reserve; reads/releases belong to creator |
| POST /inventory/reservations/:id/commit | inventory.commit, same organization |
Products begin as drafts. Publishing requires an active variant; archived products cannot be edited. Groups are categories or collections. Public lists contain summaries; details expose published products and active variants. Price input is a positive decimal string with two fractional digits, stored as exact Decimal(12,2) with explicit currency. Output may omit trailing zeroes. Lists and nested inputs are bounded.
Addresses are private, limited to 20 per user and have one default enforced by a partial unique index. Deleting the default selects a replacement when possible. Private address values are excluded from audits.
Stock quantities are integers. Opening stock is an adjustment. Adjustments require stockItemId, nonzero delta, reason and UUID idempotencyKey. Reservations require stockItemId, positive quantity, UUID idempotencyKey and optional ttlMinutes (160, default 15). Matching retries return the original result; changed input conflicts.
Availability equals on-hand stock minus unexpired active reservations. Stock row locks serialize changes. Expired holds stop consuming availability and cannot be committed. Release and commit are idempotent. Commit decrements stock and appends one ledger entry in the same transaction. A database trigger rejects ledger updates/deletes.
Existing system roles receive permissions through migration; custom roles require explicit grants. This phase covers finished goods. Checkout/orders and pricing snapshots follow in Phase 1D; procurement and production in Phase 2.

View File

@ -9,3 +9,4 @@ Controllers translate HTTP; services own use cases; repositories own persistence
For schema changes, run `pnpm db:migrate --name descriptive_change` against a disposable development database. Review generated SQL and commit it with the feature. Never edit an applied migration or use `db push` in production. Use expand/backfill/contract for destructive changes; back up and test recovery before deployment. Apply `pnpm db:deploy` once in the release pipeline, then verify migration status and readiness. Test migrations from an empty database and the prior release snapshot.
Money uses exact decimal or minor units with explicit currency. Inventory uses ledger entries and transactions. Orders, payments and shipments have separate states. External writes need idempotency and authenticated webhooks. Private documents require authorization and isolated processing. These rules are implemented and tested within the corresponding milestones.
See [append-only migration workflow](migrations.md) for timestamp names and checksum recording.

11
docs/error-contract.md Normal file
View File

@ -0,0 +1,11 @@
# API errors and diagnostics
Errors return `{ statusCode, code, message, requestId }`; validation errors may include fields. Clients should branch on stable codes. Definitions live in src/common/errors/platform-errors.ts and commerce-errors.ts.
Each defined error has a distinct code and message. The global filter logs the event, diagnostic, server-generated request ID, method and route template. Unexpected faults include a fingerprint. Raw exceptions, SQL, credentials, request bodies and address values are not logged or returned.
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.

17
docs/migrations.md Normal file
View File

@ -0,0 +1,17 @@
# Append-only migrations
Never edit or rename a committed migration. Correct mistakes with the next migration. New directories use Prisma's UTC timestamp prefix: YYYYMMDDHHmmss_description.
1. Change the relevant schema file under prisma/.
2. Run `pnpm db:migrate --name descriptive_change` against a disposable development database and review the generated SQL.
3. Run `node scripts/check-migrations.mjs --record-new`. This verifies recorded checksums before adding new entries.
4. Run `pnpm db:generate`, `pnpm db:test` and `pnpm check`. Commit schema, SQL, checksums and tests together.
The check normalizes line endings and rejects modified or missing recorded migrations and invalid new timestamp names. Review manifest changes against the base branch; replacing an old checksum is not an acceptable repair.
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

@ -6,9 +6,9 @@ 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: 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.
- 1C (implemented; native concurrency CI pending): catalog, variants, collections, addresses, inventory ledger and reservations. Test concurrent reservation and stock 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.

15
docs/vapt-readiness.md Normal file
View File

@ -0,0 +1,15 @@
# Security assessment preparation
This phase supplies controls and regression evidence for assessment; no formal VAPT or certification has been completed.
Controls include current session/permission checks, organization-scoped lookups and composite foreign keys, private address ownership, bounded schemas, parameterized queries, security headers, explicit CORS, 32 KB bodies, durable rate limits and redacted errors/logs. Stock mutations use transactions, row locks, idempotency and an immutable ledger. Migration checksums guard history.
Tests cover denied/cross-organization access, mass assignment, malformed and oversized JSON, query bounds, hostile text, error redaction, forged request IDs and inventory state transitions. Production dependency audit reports zero advisories at verification; CI rejects high/critical findings.
Before release, run native PostgreSQL concurrency tests and Gitea CI, validate TLS and trusted-proxy configuration, test rate limits at the actual network boundary, restrict database/log access, provision secrets and backups, verify restore procedures and commission authenticated and unauthenticated VAPT against the deployed environment.
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,16 +1,16 @@
# Verification record — Phase 1B
# Verification record — Phase 1E blueprint
Completed locally:
- 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 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.
- 86 passing tests across 13 suites; one native PostgreSQL concurrency test is intentionally skipped without TEST_DATABASE_URL.
- 100% statements, lines and functions; 86.11% branches for hand-written application code. Generated code, Nest module declarations and CLI/HTTP entrypoint wrappers are excluded. Bootstrap business logic is tested.
- Formatting, Prisma schema validation, strict TypeScript/unused-code checks and production compilation pass.
- All three migrations execute through Prisma migrate deploy; migrate status reports up to date and schema diff reports no drift against a disposable embedded PostgreSQL engine.
- Migration tests preserve a pre-existing organization row, reject cross-organization role assignments, enforce normalized email and one owner, and prevent audit updates/deletes.
- API tests cover pending/suspended accounts, revoked/expired sessions, current permissions, privilege escalation, recovery replay, throttling and transaction rollback.
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.
Native PostgreSQL concurrency and the remote Gitea workflow have not been verified locally. The workflow provisions PostgreSQL 17 and enables the native test path. A Docker-capable Gitea runner and Actions access are required.
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.
Recovery email is tested using a mock SMTP adapter; no external email was sent. Live SMTP, a frontend recovery page and production deployment/owner bootstrap remain environment setup tasks. Recovery email is synchronous pending the later notification queue milestone.
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 identity: mihir <motiyanimihir@gmail.com>. Work is based on the fetched main branch and lives on feat/identity-access.
Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/commerce-operations, based on merged Phase 1D at 068b0f9.

View File

@ -19,16 +19,19 @@
"db:validate": "prisma validate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"check": "pnpm migrations:check && pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"start:watch": "node --watch dist/main.js",
"db:status": "prisma migrate status",
"bootstrap:owner": "node dist/cli/bootstrap-owner.js",
"db:test": "node scripts/test-migrations.mjs"
"db:test": "node scripts/test-migrations.mjs",
"migrations:check": "node scripts/check-migrations.mjs",
"security:audit": "pnpm audit --prod --audit-level=high"
},
"dependencies": {
"@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

@ -4,6 +4,11 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@prisma/config>deepmerge-ts': 8.0.0
prisma>mysql2: 3.23.1
'@nestjs/platform-express>multer': 2.3.0
importers:
.:
@ -17,12 +22,15 @@ 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
'@prisma/client':
specifier: ^7.0.0
version: 7.10.0(prisma@7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)
version: 7.10.0(prisma@7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)
dotenv:
specifier: ^17.0.0
version: 17.4.2
@ -80,7 +88,7 @@ importers:
version: 3.9.6
prisma:
specifier: ^7.0.0
version: 7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
version: 7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
supertest:
specifier: ^7.0.0
version: 7.2.2(supports-color@8.1.1)
@ -605,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}
@ -643,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:
@ -896,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==}
@ -1240,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==}
@ -1514,8 +1561,8 @@ packages:
babel-plugin-macros:
optional: true
deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
deepmerge-ts@8.0.0:
resolution: {integrity: sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==}
engines: {node: '>=16.0.0'}
deepmerge@4.3.1:
@ -2046,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'}
@ -2173,13 +2224,15 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multer@2.2.0:
resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==}
multer@2.3.0:
resolution: {integrity: sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==}
engines: {node: '>= 10.16.0'}
mysql2@3.15.3:
resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==}
mysql2@3.23.1:
resolution: {integrity: sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==}
engines: {node: '>= 8.0'}
peerDependencies:
'@types/node': '>= 8'
named-placeholders@1.1.6:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
@ -2515,9 +2568,6 @@ packages:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
@ -2571,9 +2621,9 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
sql-escaper@1.5.1:
resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
stack-utils@2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
@ -2645,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}
@ -3376,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
@ -3408,18 +3463,35 @@ 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)
'@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)
cors: 2.8.6
express: 5.2.1(supports-color@8.1.1)
multer: 2.2.0
multer: 2.3.0
path-to-regexp: 8.4.2
tslib: 2.8.1
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)
@ -3506,17 +3578,17 @@ snapshots:
'@prisma/client-runtime-utils@7.10.0': {}
'@prisma/client@7.10.0(prisma@7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)':
'@prisma/client@7.10.0(prisma@7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@prisma/client-runtime-utils': 7.10.0
optionalDependencies:
prisma: 7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
prisma: 7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
typescript: 5.9.3
'@prisma/config@7.10.0':
dependencies:
c12: 3.3.4
deepmerge-ts: 7.1.5
deepmerge-ts: 8.0.0
effect: 3.20.0
empathic: 2.0.0
transitivePeerDependencies:
@ -3655,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':
@ -4018,6 +4092,8 @@ snapshots:
dependencies:
sprintf-js: 1.0.3
argparse@2.0.1: {}
asap@2.0.6: {}
asynckit@0.4.0: {}
@ -4294,7 +4370,7 @@ snapshots:
dedent@1.7.2: {}
deepmerge-ts@7.1.5: {}
deepmerge-ts@8.0.0: {}
deepmerge@4.3.1: {}
@ -5041,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: {}
@ -5123,15 +5203,16 @@ snapshots:
ms@2.1.3: {}
multer@2.2.0:
multer@2.3.0:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
type-is: 1.6.18
mysql2@3.15.3:
mysql2@3.23.1(@types/node@24.13.3):
dependencies:
'@types/node': 24.13.3
aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1
@ -5139,8 +5220,7 @@ snapshots:
long: 5.3.2
lru.min: 1.1.5
named-placeholders: 1.1.6
seq-queue: 0.0.5
sqlstring: 2.3.3
sql-escaper: 1.5.1
named-placeholders@1.1.6:
dependencies:
@ -5309,17 +5389,18 @@ snapshots:
'@jest/schemas': 30.5.0
ansi-styles: 5.2.0
prisma@7.10.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
prisma@7.10.0(@types/node@24.13.3)(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
dependencies:
'@prisma/config': 7.10.0
'@prisma/dev': 0.24.17(typescript@5.9.3)
'@prisma/engines': 7.10.0
'@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
mysql2: 3.15.3
mysql2: 3.23.1(@types/node@24.13.3)
postgres: 3.4.7
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@types/node'
- '@types/react'
- '@types/react-dom'
- magicast
@ -5443,8 +5524,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
seq-queue@0.0.5: {}
serve-static@2.2.1(supports-color@8.1.1):
dependencies:
encodeurl: 2.0.0
@ -5502,7 +5581,7 @@ snapshots:
sprintf-js@1.0.3: {}
sqlstring@2.3.3: {}
sql-escaper@1.5.1: {}
stack-utils@2.0.6:
dependencies:
@ -5583,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,11 @@
allowBuilds:
'@parcel/watcher': true
'@prisma/engines': true
'@scarf/scarf': false
esbuild: true
prisma: true
unrs-resolver: true
overrides:
'@prisma/config>deepmerge-ts': 8.0.0
'prisma>mysql2': 3.23.1
'@nestjs/platform-express>multer': 2.3.0

View File

@ -2,7 +2,7 @@ import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
schema: 'prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: process.env.DATABASE_URL },
});

20
prisma/addresses.prisma Normal file
View File

@ -0,0 +1,20 @@
model Address {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
recipient String @db.VarChar(160)
line1 String @db.VarChar(200)
line2 String @default("") @db.VarChar(200)
city String @db.VarChar(100)
region String @db.VarChar(100)
postalCode String @map("postal_code") @db.VarChar(20)
countryCode String @map("country_code") @db.Char(2)
phone String @db.VarChar(16)
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Cascade)
@@index([userId, organizationId, createdAt, id])
@@map("addresses")
}

67
prisma/catalog.prisma Normal file
View File

@ -0,0 +1,67 @@
enum ProductStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum GroupKind {
CATEGORY
COLLECTION
}
model Product {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(160)
slug String @db.VarChar(160)
description String @default("") @db.VarChar(5000)
status ProductStatus @default(DRAFT)
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)
variants ProductVariant[]
groups ProductGroup[]
@@unique([organizationId, slug])
@@unique([id, organizationId])
@@index([organizationId, status, createdAt, id])
@@map("products")
}
model ProductVariant {
id String @id @default(uuid()) @db.Uuid
productId String @map("product_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
sku String @db.VarChar(64)
name String @db.VarChar(160)
price Decimal @db.Decimal(12,2)
currency String @default("INR") @db.Char(3)
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])
@@index([productId, active])
@@map("product_variants")
}
model CatalogGroup {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(100)
slug String @db.VarChar(100)
kind GroupKind
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
products ProductGroup[]
@@unique([organizationId, kind, slug])
@@unique([id, organizationId])
@@map("catalog_groups")
}
model ProductGroup {
productId String @map("product_id") @db.Uuid
groupId String @map("group_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Cascade)
group CatalogGroup @relation(fields: [groupId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@id([productId, groupId])
@@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")
}

66
prisma/inventory.prisma Normal file
View File

@ -0,0 +1,66 @@
enum ReservationStatus {
ACTIVE
RELEASED
COMMITTED
}
model Warehouse {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(100)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
items StockItem[]
@@unique([organizationId, name])
@@unique([id, organizationId])
@@map("warehouses")
}
model StockItem {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
variantId String @map("variant_id") @db.Uuid
warehouseId String @map("warehouse_id") @db.Uuid
onHand Int @default(0) @map("on_hand")
variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict)
warehouse Warehouse @relation(fields: [warehouseId, organizationId], references: [id, organizationId], onDelete: Restrict)
entries StockLedger[]
reservations StockReservation[]
@@unique([variantId, warehouseId])
@@unique([id, organizationId])
@@index([organizationId, id])
@@map("stock_items")
}
model StockLedger {
id String @id @default(uuid()) @db.Uuid
stockItemId String @map("stock_item_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
actorId String @map("actor_id") @db.Uuid
actor User @relation(fields: [actorId, organizationId], references: [id, organizationId], onDelete: Restrict)
delta Int
reason String @db.VarChar(200)
idempotencyKey String @map("idempotency_key") @db.Uuid
requestHash String @map("request_hash") @db.Char(64)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, idempotencyKey])
@@index([stockItemId, createdAt, id])
@@map("stock_ledger")
}
model StockReservation {
id String @id @default(uuid()) @db.Uuid
stockItemId String @map("stock_item_id") @db.Uuid
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
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)
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, idempotencyKey])
@@index([stockItemId, status, expiresAt])
@@index([userId, organizationId])
@@map("stock_reservations")
}

View File

@ -0,0 +1,16 @@
{
"202609080003_identity_integrity": "872ee89b0b4a45f574d024d37b7f7d2536ce61df19d2ecefe59594edef06fc14",
"202609080001_create_organizations": "7ca0a406b175865c54d026df616c82f60c4fb276f37e6dd243602621ff8618f6",
"202609080002_identity_access": "e74393574bc5924497a59e71f36ad48310b401268560656102ed24fba84ff902",
"20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84",
"20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36",
"20260909141622_commerce_integrity": "30545784aa33f35783c0170b80de2bded32be8781e25e027c4e5e2e5da8348ac",
"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,122 @@
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "GroupKind" AS ENUM ('CATEGORY', 'COLLECTION');
-- CreateTable
CREATE TABLE "addresses" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"recipient" VARCHAR(160) NOT NULL,
"line1" VARCHAR(200) NOT NULL,
"line2" VARCHAR(200) NOT NULL DEFAULT '',
"city" VARCHAR(100) NOT NULL,
"region" VARCHAR(100) NOT NULL,
"postal_code" VARCHAR(20) NOT NULL,
"country_code" CHAR(2) NOT NULL,
"phone" VARCHAR(16) NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"slug" VARCHAR(160) NOT NULL,
"description" VARCHAR(5000) NOT NULL DEFAULT '',
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_variants" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"sku" VARCHAR(64) NOT NULL,
"name" VARCHAR(160) NOT NULL,
"price" DECIMAL(12,2) NOT NULL,
"currency" CHAR(3) NOT NULL DEFAULT 'INR',
"attributes" JSONB NOT NULL DEFAULT '{}',
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "product_variants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "catalog_groups" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"slug" VARCHAR(100) NOT NULL,
"kind" "GroupKind" NOT NULL,
CONSTRAINT "catalog_groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_groups" (
"product_id" UUID NOT NULL,
"group_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "product_groups_pkey" PRIMARY KEY ("product_id","group_id")
);
-- CreateIndex
CREATE INDEX "addresses_user_id_organization_id_created_at_id_idx" ON "addresses"("user_id", "organization_id", "created_at", "id");
-- CreateIndex
CREATE INDEX "products_organization_id_status_created_at_id_idx" ON "products"("organization_id", "status", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "products_organization_id_slug_key" ON "products"("organization_id", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "products_id_organization_id_key" ON "products"("id", "organization_id");
-- CreateIndex
CREATE INDEX "product_variants_product_id_active_idx" ON "product_variants"("product_id", "active");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_organization_id_sku_key" ON "product_variants"("organization_id", "sku");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_id_organization_id_key" ON "product_variants"("id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "catalog_groups_organization_id_kind_slug_key" ON "catalog_groups"("organization_id", "kind", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "catalog_groups_id_organization_id_key" ON "catalog_groups"("id", "organization_id");
-- CreateIndex
CREATE INDEX "product_groups_group_id_organization_id_idx" ON "product_groups"("group_id", "organization_id");
-- AddForeignKey
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "catalog_groups" ADD CONSTRAINT "catalog_groups_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_group_id_organization_id_fkey" FOREIGN KEY ("group_id", "organization_id") REFERENCES "catalog_groups"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,98 @@
-- CreateEnum
CREATE TYPE "ReservationStatus" AS ENUM ('ACTIVE', 'RELEASED', 'COMMITTED');
-- CreateTable
CREATE TABLE "warehouses" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
CONSTRAINT "warehouses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_items" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"variant_id" UUID NOT NULL,
"warehouse_id" UUID NOT NULL,
"on_hand" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "stock_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_ledger" (
"id" UUID NOT NULL,
"stock_item_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"actor_id" UUID NOT NULL,
"delta" INTEGER NOT NULL,
"reason" VARCHAR(200) NOT NULL,
"idempotency_key" UUID NOT NULL,
"request_hash" CHAR(64) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stock_ledger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_reservations" (
"id" UUID NOT NULL,
"stock_item_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"status" "ReservationStatus" NOT NULL DEFAULT 'ACTIVE',
"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 "stock_reservations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "warehouses_organization_id_name_key" ON "warehouses"("organization_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "warehouses_id_organization_id_key" ON "warehouses"("id", "organization_id");
-- CreateIndex
CREATE INDEX "stock_items_organization_id_id_idx" ON "stock_items"("organization_id", "id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_items_variant_id_warehouse_id_key" ON "stock_items"("variant_id", "warehouse_id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_items_id_organization_id_key" ON "stock_items"("id", "organization_id");
-- CreateIndex
CREATE INDEX "stock_ledger_stock_item_id_created_at_id_idx" ON "stock_ledger"("stock_item_id", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_ledger_organization_id_idempotency_key_key" ON "stock_ledger"("organization_id", "idempotency_key");
-- CreateIndex
CREATE INDEX "stock_reservations_stock_item_id_status_expires_at_idx" ON "stock_reservations"("stock_item_id", "status", "expires_at");
-- CreateIndex
CREATE INDEX "stock_reservations_user_id_organization_id_idx" ON "stock_reservations"("user_id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_reservations_organization_id_idempotency_key_key" ON "stock_reservations"("organization_id", "idempotency_key");
-- AddForeignKey
ALTER TABLE "warehouses" ADD CONSTRAINT "warehouses_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_items" ADD CONSTRAINT "stock_items_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_items" ADD CONSTRAINT "stock_items_warehouse_id_organization_id_fkey" FOREIGN KEY ("warehouse_id", "organization_id") REFERENCES "warehouses"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,27 @@
-- Positive prices, valid stock arithmetic and private-address defaults are
-- enforced in PostgreSQL in addition to request validation.
ALTER TABLE product_variants ADD CONSTRAINT variant_positive_price CHECK (price > 0);
ALTER TABLE stock_items ADD CONSTRAINT stock_nonnegative CHECK (on_hand >= 0);
ALTER TABLE stock_ledger ADD CONSTRAINT ledger_nonzero_delta CHECK (delta <> 0);
ALTER TABLE stock_reservations ADD CONSTRAINT reservation_positive_quantity CHECK (quantity > 0);
CREATE UNIQUE INDEX addresses_one_default ON addresses (user_id) WHERE is_default = true;
CREATE FUNCTION reject_stock_ledger_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Stock ledger entries are append-only';
END;
$$;
CREATE TRIGGER stock_ledger_append_only
BEFORE UPDATE OR DELETE ON stock_ledger
FOR EACH ROW EXECUTE FUNCTION reject_stock_ledger_mutation();
-- Existing system-owner roles need the new permissions. Custom roles retain
-- their explicit grants and must be updated through the authorized API.
UPDATE roles SET permissions = ARRAY(
SELECT DISTINCT permission FROM unnest(permissions || ARRAY[
'catalog.read', 'catalog.manage', 'catalog.publish',
'inventory.read', 'inventory.manage', 'inventory.adjust',
'inventory.reserve', 'inventory.commit'
]::text[]) AS permission ORDER BY permission
) WHERE is_system = true;

View File

@ -0,0 +1,5 @@
-- AddForeignKey
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_actor_id_organization_id_fkey" FOREIGN KEY ("actor_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

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

@ -19,6 +19,13 @@ model Organization {
users User[]
roles Role[]
auditEvents AuditEvent[]
products Product[]
catalogGroups CatalogGroup[]
warehouses Warehouse[]
coupons Coupon[]
pricingPolicies PricingPolicy[]
suppliers Supplier[]
materials Material[]
@@map("organizations")
}
model User {
@ -35,6 +42,11 @@ model User {
roles UserRole[]
sessions Session[]
recoveryTokens RecoveryToken[]
addresses Address[]
stockEntries StockLedger[]
stockReservations StockReservation[]
carts Cart[]
orders Order[]
@@unique([organizationId, email])
@@unique([id, organizationId])
@@index([organizationId, createdAt, id])

View File

@ -0,0 +1,42 @@
import { createHash } from 'node:crypto';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
const root = resolve(
process.argv.find((value) => value.startsWith('--root='))?.slice(7) ?? '.',
);
const folder = join(root, 'prisma', 'migrations');
const manifest = join(root, 'prisma', 'migration-checksums.json');
const hashes = JSON.parse(await readFile(manifest, 'utf8'));
const canonicalHash = (sql) =>
createHash('sha256').update(sql.replaceAll('\r\n', '\n')).digest('hex');
for (const [name, expected] of Object.entries(hashes)) {
const actual = canonicalHash(
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
);
if (actual !== expected)
throw new Error(`Immutable migration changed: ${name}`);
}
const names = (await readdir(folder, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const stamps = new Set();
for (const name of names) {
const stamp = name.split('_')[0];
if (stamps.has(stamp))
throw new Error(`Duplicate migration timestamp: ${stamp}`);
stamps.add(stamp);
if (hashes[name]) continue;
if (!/^\d{14}_[a-z0-9_]+$/.test(name))
throw new Error(`New migration requires YYYYMMDDHHmmss timestamp: ${name}`);
if (!process.argv.includes('--record-new'))
throw new Error(`New migration checksum not recorded: ${name}`);
hashes[name] = canonicalHash(
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
);
}
if (process.argv.includes('--record-new'))
await writeFile(manifest, JSON.stringify(hashes, null, 2) + '\n');
console.log(`Verified ${Object.keys(hashes).length} immutable migrations`);

View File

@ -10,7 +10,7 @@ const commands = [
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'prisma',
'--exit-code',
],
];

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import { text } from '../common/input';
export const addressSchema = z
.object({
recipient: text(160),
line1: text(200),
line2: text(200, 0).default(''),
city: text(100),
region: text(100),
postalCode: z
.string()
.trim()
.min(1)
.max(20)
.regex(/^[A-Za-z0-9 -]+$/),
countryCode: z
.string()
.toUpperCase()
.regex(/^[A-Z]{2}$/),
phone: z.string().regex(/^\+[1-9]\d{6,14}$/),
isDefault: z.boolean().default(false),
})
.strict();
export type AddressInput = z.infer<typeof addressSchema>;

View File

@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import type { Prisma } from '../generated/prisma/client';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import type { AddressInput } from './address.schema';
async function ensureDefault(tx: Prisma.TransactionClient, userId: string) {
if (await tx.address.count({ where: { userId, isDefault: true } })) return;
const first = await tx.address.findFirst({
where: { userId },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
if (first)
await tx.address.update({
where: { id: first.id },
data: { isDefault: true },
});
}
@Injectable()
export class AddressStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal) {
return this.db.address.findMany({
where: { userId: actor.userId, organizationId: actor.organizationId },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }, { id: 'asc' }],
take: 20,
});
}
save(actor: Principal, input: AddressInput, id?: string) {
return this.access.mutate(actor, null, async (tx) => {
const where = {
userId: actor.userId,
organizationId: actor.organizationId,
};
if (id && !(await tx.address.findFirst({ where: { ...where, id } })))
throw new AppError('ADDRESS_NOT_FOUND');
if (!id && (await tx.address.count({ where })) >= 20)
throw new AppError('ADDRESS_LIMIT');
if (input.isDefault)
await tx.address.updateMany({ where, data: { isDefault: false } });
const address = id
? await tx.address.update({ where: { id }, data: input })
: await tx.address.create({ data: { ...where, ...input } });
await ensureDefault(tx, actor.userId);
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'address.updated' : 'address.created',
address.id,
);
return tx.address.findUniqueOrThrow({ where: { id: address.id } });
});
}
remove(actor: Principal, id: string) {
return this.access.mutate(actor, null, async (tx) => {
const removed = await tx.address.deleteMany({
where: {
id,
userId: actor.userId,
organizationId: actor.organizationId,
},
});
if (!removed.count) throw new AppError('ADDRESS_NOT_FOUND');
await ensureDefault(tx, actor.userId);
await recordAudit(
tx,
actor.organizationId,
actor.userId,
'address.deleted',
id,
);
});
}
}

View File

@ -0,0 +1,48 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
Put,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { CurrentPrincipal } from '../identity/access.decorator';
import type { Principal } from '../identity/identity.types';
import { AddressStore } from './address.store';
import { addressSchema, type AddressInput } from './address.schema';
@Controller('addresses')
export class AddressesController {
constructor(private readonly addresses: AddressStore) {}
@Get()
list(@CurrentPrincipal() actor: Principal) {
return this.addresses.list(actor);
}
@Post()
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(addressSchema)) input: AddressInput,
) {
return this.addresses.save(actor, input);
}
@Put(':id')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(addressSchema)) input: AddressInput,
) {
return this.addresses.save(actor, input, id);
}
@Delete(':id')
@HttpCode(204)
remove(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.addresses.remove(actor, 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 { AddressStore } from './address.store';
import { AddressesController } from './addresses.controller';
@Module({
imports: [DatabaseModule, IdentityModule],
providers: [AddressStore],
controllers: [AddressesController],
})
export class AddressesModule {}

View File

@ -1,7 +1,25 @@
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';
import { HealthModule } from './health/health.module';
@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] })
@Module({
imports: [
EnvironmentModule,
HealthModule,
IdentityModule,
CatalogModule,
AddressesModule,
InventoryModule,
ProcurementModule,
CheckoutModule,
OperationsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,17 @@
import type { Prisma } from '../generated/prisma/client';
import type { CatalogQuery } from './catalog.schemas';
export function catalogWhere(
organizationId: string,
query: CatalogQuery,
publishedOnly = false,
): Prisma.ProductWhereInput {
return {
organizationId,
...(publishedOnly ? { status: 'PUBLISHED' } : {}),
...(query.search
? { name: { contains: query.search, mode: 'insensitive' } }
: {}),
...(query.groupId ? { groups: { some: { groupId: query.groupId } } } : {}),
};
}

View File

@ -0,0 +1,31 @@
import type { Prisma } from '../generated/prisma/client';
import { AppError } from '../common/errors/app-error';
export async function scopedProduct(
tx: Prisma.TransactionClient,
organizationId: string,
id: string,
) {
const product = await tx.product.findFirst({ where: { id, organizationId } });
if (!product) throw new AppError('PRODUCT_NOT_FOUND');
return product;
}
export async function editableProduct(
tx: Prisma.TransactionClient,
organizationId: string,
id: string,
) {
const product = await scopedProduct(tx, organizationId, id);
if (product.status === 'ARCHIVED') throw new AppError('PRODUCT_ARCHIVED');
return product;
}
export async function validateGroups(
tx: Prisma.TransactionClient,
organizationId: string,
groupIds: string[],
) {
const count = await tx.catalogGroup.count({
where: { organizationId, id: { in: groupIds } },
});
if (count !== groupIds.length) throw new AppError('GROUP_NOT_FOUND');
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { IdentityModule } from '../identity/identity.module';
import { ProductStore } from './product.store';
import { VariantStore } from './variant.store';
import { GroupStore } from './group.store';
import { StorefrontStore } from './storefront.store';
import { ProductsController } from './products.controller';
import { GroupsController } from './groups.controller';
import { StorefrontController } from './storefront.controller';
import { StorefrontGuard } from './storefront.guard';
@Module({
imports: [DatabaseModule, IdentityModule],
providers: [
ProductStore,
VariantStore,
GroupStore,
StorefrontStore,
StorefrontGuard,
],
controllers: [ProductsController, GroupsController, StorefrontController],
})
export class CatalogModule {}

View File

@ -0,0 +1,65 @@
import { z } from 'zod';
import { CURRENCIES } from '../common/currency';
import { text, ids } from '../common/input';
import { pageSchema } from '../identity/identity.schemas';
const slug = (max: number) =>
z
.string()
.max(max)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
export const productSchema = z
.object({
name: text(160),
slug: slug(160),
description: text(5000, 0).default(''),
groupIds: ids.default([]),
})
.strict();
export const productStatusSchema = z
.object({ status: z.enum(['DRAFT', 'PUBLISHED', 'ARCHIVED']) })
.strict();
export const groupSchema = z
.object({
name: text(100),
slug: slug(100),
kind: z.enum(['CATEGORY', 'COLLECTION']),
})
.strict();
export const variantSchema = z
.object({
sku: z
.string()
.trim()
.toUpperCase()
.max(64)
.regex(/^[A-Z0-9][A-Z0-9_-]*$/),
name: text(160),
price: z
.string()
.regex(/^(0|[1-9]\d{0,9})\.\d{2}$/)
.refine((value) => value !== '0.00'),
currency: z.enum(CURRENCIES).default('INR'),
active: z.boolean().default(true),
attributes: z
.record(
z
.string()
.regex(/^[a-z][a-z0-9_]{0,31}$/)
.refine((key) => !['constructor', 'prototype'].includes(key)),
text(100),
)
.refine((value) => Object.keys(value).length <= 20)
.default({}),
})
.strict();
export const catalogQuery = pageSchema
.extend({
search: text(100).optional(),
groupId: z.uuid().optional(),
})
.strict();
export type ProductInput = z.infer<typeof productSchema>;
export type VariantInput = z.infer<typeof variantSchema>;
export type GroupInput = z.infer<typeof groupSchema>;
export type CatalogQuery = z.infer<typeof catalogQuery>;

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
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 { PageInput } from '../identity/identity.schemas';
import { AppError } from '../common/errors/app-error';
import type { GroupInput } from './catalog.schemas';
@Injectable()
export class GroupStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal, page: PageInput) {
return this.db.catalogGroup.findMany({
where: { organizationId: actor.organizationId },
orderBy: { id: 'asc' },
take: page.limit,
skip: page.offset,
});
}
save(actor: Principal, input: GroupInput, id?: string) {
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
if (
id &&
!(await tx.catalogGroup.findFirst({
where: { id, organizationId: actor.organizationId },
}))
) {
throw new AppError('GROUP_NOT_FOUND');
}
const group = id
? await tx.catalogGroup.update({ where: { id }, data: input })
: await tx.catalogGroup.create({
data: { ...input, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'catalog_group.updated' : 'catalog_group.created',
group.id,
);
return group;
});
}
}

View File

@ -0,0 +1,49 @@
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 { pageSchema, type PageInput } from '../identity/identity.schemas';
import type { Principal } from '../identity/identity.types';
import { GroupStore } from './group.store';
import { groupSchema, type GroupInput } from './catalog.schemas';
@Controller('catalog-groups')
export class GroupsController {
constructor(private readonly groups: GroupStore) {}
@Get()
@RequirePermission('catalog.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(pageSchema)) page: PageInput,
) {
return this.groups.list(actor, page);
}
@Post()
@RequirePermission('catalog.manage')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(groupSchema)) input: GroupInput,
) {
return this.groups.save(actor, input);
}
@Put(':id')
@RequirePermission('catalog.manage')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(groupSchema)) input: GroupInput,
) {
return this.groups.save(actor, input, id);
}
}

View File

@ -0,0 +1,97 @@
import { catalogWhere } from './catalog-query';
import { Injectable } from '@nestjs/common';
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 { AppError } from '../common/errors/app-error';
import {
editableProduct,
scopedProduct,
validateGroups,
} from './catalog.helpers';
import type { CatalogQuery, ProductInput } from './catalog.schemas';
@Injectable()
export class ProductStore {
constructor(
private readonly db: DatabaseService,
private readonly access: AccessStore,
) {}
list(actor: Principal, query: CatalogQuery) {
return this.db.product.findMany({
where: catalogWhere(actor.organizationId, query),
select: { id: true, name: true, slug: true, status: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: query.limit,
skip: query.offset,
});
}
async get(actor: Principal, id: string) {
await scopedProduct(this.db, actor.organizationId, id);
return this.db.product.findUnique({
where: { id },
include: {
variants: { orderBy: { id: 'asc' } },
groups: { include: { group: true } },
},
});
}
save(actor: Principal, input: ProductInput, id?: string) {
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
if (id) await editableProduct(tx, actor.organizationId, id);
await validateGroups(tx, actor.organizationId, input.groupIds);
const { groupIds, ...data } = input;
const product = id
? await tx.product.update({ where: { id }, data })
: await tx.product.create({
data: { ...data, organizationId: actor.organizationId },
});
await tx.productGroup.deleteMany({ where: { productId: product.id } });
await tx.productGroup.createMany({
data: groupIds.map((groupId) => ({
productId: product.id,
groupId,
organizationId: actor.organizationId,
})),
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'product.updated' : 'product.created',
product.id,
);
return product;
});
}
setStatus(
actor: Principal,
id: string,
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED',
) {
return this.access.mutate(actor, 'catalog.publish', async (tx) => {
await editableProduct(tx, actor.organizationId, id);
if (
status === 'PUBLISHED' &&
!(await tx.productVariant.count({
where: { productId: id, active: true },
}))
) {
throw new AppError('PRODUCT_NOT_PUBLISHABLE');
}
const product = await tx.product.update({
where: { id },
data: { status },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
`product.${status.toLowerCase()}`,
id,
);
return product;
});
}
}

View File

@ -0,0 +1,98 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
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 { ProductStore } from './product.store';
import { VariantStore } from './variant.store';
import {
catalogQuery,
productSchema,
productStatusSchema,
variantSchema,
type CatalogQuery,
type ProductInput,
type VariantInput,
} from './catalog.schemas';
@Controller('products')
export class ProductsController {
constructor(
private readonly products: ProductStore,
private readonly variants: VariantStore,
) {}
@Get()
@RequirePermission('catalog.read')
list(
@CurrentPrincipal() actor: Principal,
@Query(new SchemaPipe(catalogQuery)) query: CatalogQuery,
) {
return this.products.list(actor, query);
}
@Get(':id')
@RequirePermission('catalog.read')
get(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.products.get(actor, id);
}
@Post()
@RequirePermission('catalog.manage')
create(
@CurrentPrincipal() actor: Principal,
@Body(new SchemaPipe(productSchema)) input: ProductInput,
) {
return this.products.save(actor, input);
}
@Put(':id')
@RequirePermission('catalog.manage')
update(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(productSchema)) input: ProductInput,
) {
return this.products.save(actor, input, id);
}
@Patch(':id/status')
@RequirePermission('catalog.publish')
status(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(productStatusSchema))
input: { status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED' },
) {
return this.products.setStatus(actor, id, input.status);
}
@Post(':id/variants')
@RequirePermission('catalog.manage')
createVariant(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Body(new SchemaPipe(variantSchema)) input: VariantInput,
) {
return this.variants.save(actor, id, input);
}
@Put(':id/variants/:variantId')
@RequirePermission('catalog.manage')
updateVariant(
@CurrentPrincipal() actor: Principal,
@Param('id', ParseUUIDPipe) id: string,
@Param('variantId', ParseUUIDPipe) variantId: string,
@Body(new SchemaPipe(variantSchema)) input: VariantInput,
) {
return this.variants.save(actor, id, input, variantId);
}
}

View File

@ -0,0 +1,34 @@
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
UseGuards,
} from '@nestjs/common';
import { SchemaPipe } from '../common/validation.pipe';
import { Public } from '../identity/access.decorator';
import { StorefrontStore } from './storefront.store';
import { StorefrontGuard } from './storefront.guard';
import { catalogQuery, type CatalogQuery } from './catalog.schemas';
@Public()
@UseGuards(StorefrontGuard)
@Controller('storefront/:organizationId/products')
export class StorefrontController {
constructor(private readonly storefront: StorefrontStore) {}
@Get()
list(
@Param('organizationId', ParseUUIDPipe) organizationId: string,
@Query(new SchemaPipe(catalogQuery)) query: CatalogQuery,
) {
return this.storefront.list(organizationId, query);
}
@Get(':id')
get(
@Param('organizationId', ParseUUIDPipe) organizationId: string,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.storefront.get(organizationId, id);
}
}

View File

@ -0,0 +1,13 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { RateLimitService } from '../identity/rate-limit.service';
@Injectable()
export class StorefrontGuard implements CanActivate {
constructor(private readonly limits: RateLimitService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>();
await this.limits.consume(`storefront:${request.ip}`, 120, 60);
return true;
}
}

View File

@ -0,0 +1,50 @@
import { catalogWhere } from './catalog-query';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { AppError } from '../common/errors/app-error';
import type { CatalogQuery } from './catalog.schemas';
const storefrontView = {
id: true,
name: true,
slug: true,
description: true,
variants: {
where: { active: true },
orderBy: { id: 'asc' as const },
select: {
id: true,
sku: true,
name: true,
price: true,
currency: true,
attributes: true,
},
},
groups: {
select: {
group: { select: { id: true, name: true, slug: true, kind: true } },
},
},
} as const;
@Injectable()
export class StorefrontStore {
constructor(private readonly db: DatabaseService) {}
list(organizationId: string, query: CatalogQuery) {
return this.db.product.findMany({
where: catalogWhere(organizationId, query, true),
select: { id: true, name: true, slug: true },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: query.limit,
skip: query.offset,
});
}
async get(organizationId: string, id: string) {
const product = await this.db.product.findFirst({
where: { id, organizationId, status: 'PUBLISHED' },
select: storefrontView,
});
if (!product) throw new AppError('PRODUCT_NOT_FOUND');
return product;
}
}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { AccessStore } from '../identity/access.store';
import { recordAudit } from '../identity/audit';
import type { Principal } from '../identity/identity.types';
import { AppError } from '../common/errors/app-error';
import { editableProduct } from './catalog.helpers';
import type { VariantInput } from './catalog.schemas';
@Injectable()
export class VariantStore {
constructor(private readonly access: AccessStore) {}
save(actor: Principal, productId: string, input: VariantInput, id?: string) {
return this.access.mutate(actor, 'catalog.manage', async (tx) => {
const product = await editableProduct(
tx,
actor.organizationId,
productId,
);
if (
id &&
!(await tx.productVariant.findFirst({
where: { id, productId, organizationId: actor.organizationId },
}))
) {
throw new AppError('VARIANT_NOT_FOUND');
}
if (
!id &&
(await tx.productVariant.count({ where: { productId } })) >= 100
)
throw new AppError('VARIANT_LIMIT');
if (
id &&
!input.active &&
product.status === 'PUBLISHED' &&
!(await tx.productVariant.count({
where: { productId, active: true, id: { not: id } },
}))
) {
throw new AppError('VARIANT_REQUIRED');
}
const variant = id
? await tx.productVariant.update({ where: { id }, data: input })
: await tx.productVariant.create({
data: { ...input, productId, organizationId: actor.organizationId },
});
await recordAudit(
tx,
actor.organizationId,
actor.userId,
id ? 'variant.updated' : 'variant.created',
variant.id,
);
return variant;
});
}
}

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,51 @@
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { randomUUID, createHash } from 'node:crypto';
import type { Request, Response } from 'express';
import { classifyError } from './classify-error';
import { ERRORS } from './error-catalog';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('ApiException');
catch(error: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const request = http.getRequest<Request>();
const response = http.getResponse<Response>();
const failure = classifyError(error);
const requestId =
(response.locals.requestId as string | undefined) ?? randomUUID();
const record = {
event: failure.code,
message: ERRORS[failure.code][2],
diagnostic: failure.diagnostic,
requestId,
method: request.method,
route: request.route?.path ?? 'unmatched',
// Fingerprints distinguish unexpected faults without logging raw errors, queries or secrets.
faultId:
failure.code === 'INTERNAL_FAILURE' && error instanceof Error
? createHash('sha256')
.update(error.stack ?? error.name)
.digest('hex')
.slice(0, 16)
: undefined,
};
if (failure.getStatus() >= 500) this.logger.error(JSON.stringify(record));
else this.logger.warn(JSON.stringify(record));
if (response.headersSent) return;
response.setHeader('X-Request-Id', requestId);
response.setHeader('Cache-Control', 'no-store');
if (failure.getStatus() === 429)
response.setHeader(
'Retry-After',
String(failure.retryAfterSeconds ?? 60),
);
response.status(failure.getStatus()).json({
statusCode: failure.getStatus(),
code: failure.code,
message: failure.message,
requestId,
...(failure.fields ? { fields: failure.fields } : {}),
});
}
}

View File

@ -0,0 +1,16 @@
import { HttpException } from '@nestjs/common';
import { ERRORS, type ErrorCode } from './error-catalog';
export class AppError extends HttpException {
constructor(
readonly code: ErrorCode,
readonly diagnostic?: string,
readonly fields?: string[],
readonly retryAfterSeconds?: number,
) {
super(
{ code, message: ERRORS[code][1], ...(fields ? { fields } : {}) },
ERRORS[code][0],
);
}
}

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

@ -0,0 +1,42 @@
import { HttpException } from '@nestjs/common';
import { Prisma } from '../../generated/prisma/client';
import { AppError } from './app-error';
import type { ErrorCode } from './error-catalog';
export function classifyError(error: unknown): AppError {
if (error instanceof AppError) return error;
if (error instanceof Prisma.PrismaClientKnownRequestError) {
const codes: Record<string, ErrorCode> = {
P2002: 'RECORD_CONFLICT',
P2003: 'REFERENCE_INVALID',
P2004: 'DATA_CONSTRAINT',
P2025: 'RECORD_NOT_FOUND',
P2024: 'DATABASE_BUSY',
P2028: 'DATABASE_BUSY',
P2034: 'TRANSACTION_CONFLICT',
};
return new AppError(codes[error.code] ?? 'INTERNAL_FAILURE', error.code);
}
if (error instanceof Prisma.PrismaClientInitializationError)
return new AppError('DATABASE_BUSY');
if (error instanceof HttpException) {
const codes: Record<number, ErrorCode> = {
400: 'REQUEST_MALFORMED',
401: 'AUTH_REQUIRED',
403: 'ACCESS_DENIED',
404: 'ROUTE_NOT_FOUND',
413: 'REQUEST_TOO_LARGE',
429: 'RATE_LIMITED',
503: 'DATABASE_BUSY',
};
return new AppError(codes[error.getStatus()] ?? 'INTERNAL_FAILURE');
}
// Express parser errors are not Nest HttpExceptions.
if (typeof error === 'object' && error !== null && 'type' in error) {
if (error.type === 'entity.too.large')
return new AppError('REQUEST_TOO_LARGE');
if (error.type === 'entity.parse.failed')
return new AppError('REQUEST_MALFORMED');
}
return new AppError('INTERNAL_FAILURE');
}

View File

@ -0,0 +1,99 @@
export const COMMERCE_ERRORS = {
PRODUCT_NOT_FOUND: [404, 'Product not found', 'Scoped product lookup failed'],
PRODUCT_NOT_PUBLISHABLE: [
409,
'Add an active priced variant before publishing',
'Product publish eligibility failed',
],
PRODUCT_ARCHIVED: [
409,
'Archived products cannot be edited',
'Archived product mutation rejected',
],
VARIANT_NOT_FOUND: [
404,
'Product variant not found',
'Scoped product variant lookup failed',
],
VARIANT_LIMIT: [
409,
'Product variant limit reached',
'Variant resource quota exceeded',
],
VARIANT_REQUIRED: [
409,
'A published product needs an active variant',
'Last sellable variant deactivation rejected',
],
GROUP_NOT_FOUND: [
404,
'Catalogue group not found',
'Scoped catalogue group lookup failed',
],
ADDRESS_NOT_FOUND: [
404,
'Address not found',
'Private address lookup failed',
],
ADDRESS_LIMIT: [
409,
'Address limit reached',
'Account address quota exceeded',
],
WAREHOUSE_NOT_FOUND: [
404,
'Warehouse not found',
'Scoped warehouse lookup failed',
],
STOCK_NOT_FOUND: [404, 'Stock item not found', 'Scoped stock lookup failed'],
STOCK_INSUFFICIENT: [
409,
'Insufficient available stock',
'Inventory availability check rejected operation',
],
STOCK_CAPACITY: [
409,
'Stock balance limit would be exceeded',
'Inventory integer bound rejected operation',
],
IDEMPOTENCY_CONFLICT: [
409,
'Idempotency key was used for a different request',
'Idempotency payload mismatch',
],
RESERVATION_NOT_FOUND: [
404,
'Stock reservation not found',
'Scoped reservation lookup failed',
],
RESERVATION_EXPIRED: [
409,
'Stock reservation has expired',
'Expired reservation commit rejected',
],
RESERVATION_CLOSED: [
409,
'Stock reservation is already closed',
'Invalid reservation state transition',
],
PRODUCT_UNAVAILABLE: [
409,
'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

@ -0,0 +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,
...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

@ -0,0 +1,122 @@
export const PLATFORM_ERRORS = {
REQUEST_INVALID: [400, 'Invalid request', 'Request schema validation failed'],
REQUEST_MALFORMED: [
400,
'Malformed request syntax',
'HTTP request parser rejected input',
],
REQUEST_TOO_LARGE: [
413,
'Request body exceeds the allowed size',
'HTTP payload limit exceeded',
],
ROUTE_NOT_FOUND: [404, 'API route not found', 'Unmatched HTTP route'],
AUTH_REQUIRED: [
401,
'Authentication is required',
'Bearer credential missing or malformed',
],
AUTH_INVALID_CREDENTIALS: [
401,
'Invalid credentials',
'Login credential verification failed',
],
SESSION_INVALID: [
401,
'Session is invalid or expired',
'Session authentication rejected',
],
ACCESS_DENIED: [
403,
'You do not have permission for this action',
'Permission check rejected operation',
],
SCOPE_DENIED: [
403,
'Account scope is not authorized',
'Transaction principal or organization mismatch',
],
RECORD_CONFLICT: [
409,
'A record with these details already exists',
'Database uniqueness conflict',
],
REFERENCE_INVALID: [
409,
'A related record is unavailable',
'Database foreign-key constraint rejected operation',
],
DATA_CONSTRAINT: [
409,
'Operation violates a data integrity rule',
'Database check constraint rejected operation',
],
RECORD_NOT_FOUND: [
404,
'Requested record is unavailable',
'Database record lookup failed',
],
DATABASE_BUSY: [
503,
'Database is temporarily busy; retry later',
'Database timeout or connection unavailable',
],
TRANSACTION_CONFLICT: [
409,
'Concurrent update detected; retry this operation',
'Database transaction conflict',
],
INTERNAL_FAILURE: [
500,
'An unexpected error occurred',
'Unhandled application failure',
],
USER_NOT_FOUND: [404, 'User not found', 'Scoped user lookup failed'],
ROLE_NOT_FOUND: [404, 'Role not found', 'Scoped role lookup failed'],
ROLE_GRANT_DENIED: [
403,
'Cannot grant permissions you do not hold',
'Role grant would exceed actor permissions',
],
ROLE_IMMUTABLE: [
403,
'System role is immutable',
'Attempt to modify protected system role',
],
ROLE_ASSIGNMENT_DENIED: [
403,
'Cannot change these role assignments',
'Protected or self role assignment rejected',
],
ROLE_SYSTEM_DENIED: [
403,
'System role cannot be assigned',
'Attempt to assign owner role',
],
USER_STATUS_DENIED: [
403,
'Cannot change this account status',
'Protected or self approval change rejected',
],
OWNER_EXISTS: [
409,
'Owner already exists',
'Installation bootstrap repeated',
],
RECOVERY_INVALID: [
400,
'Invalid or expired recovery token',
'Recovery token consumption rejected',
],
RECOVERY_UNAVAILABLE: [
503,
'Password recovery is not configured',
'SMTP recovery configuration missing',
],
RATE_LIMITED: [429, 'Too many requests', 'Durable request quota exceeded'],
DATABASE_NOT_READY: [
503,
'Service is not ready',
'Database readiness probe failed',
],
} as const;

12
src/common/input.ts Normal file
View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const text = (max: number, min = 1) =>
z
.string()
.trim()
.min(min)
.max(max)
.regex(/^[^<>\u0000-\u001F\u007F]*$/, 'Plain text only');
export const ids = z
.array(z.uuid())
.max(20)
.refine((values) => new Set(values).size === values.length);

View File

@ -1,17 +1,15 @@
import { BadRequestException, PipeTransform } from '@nestjs/common';
import type { PipeTransform } from '@nestjs/common';
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) {
throw new BadRequestException({
message: 'Invalid request',
fields: [
throw new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', [
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
],
});
]);
}
return result.data;
}

View File

@ -8,6 +8,11 @@ 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
.string()

View File

@ -1,6 +1,10 @@
import type { INestApplication } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet';
import { randomUUID } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
import type { Environment } from './config/environment';
import { ApiExceptionFilter } from './common/errors/api-exception.filter';
export function configureApp(
app: INestApplication,
@ -8,6 +12,19 @@ export function configureApp(
): void {
app.setGlobalPrefix('api/v1');
app.use(helmet());
app.use((_request: Request, response: Response, next: NextFunction) => {
response.locals.requestId = randomUUID();
response.setHeader('X-Request-Id', response.locals.requestId);
response.setHeader('Cache-Control', 'no-store');
next();
});
(app as NestExpressApplication).useBodyParser('json', { limit: '32kb' });
(app as NestExpressApplication).useBodyParser('urlencoded', {
limit: '32kb',
extended: false,
parameterLimit: 100,
});
app.useGlobalFilters(new ApiExceptionFilter());
app.enableCors({ origin: environment.CORS_ORIGINS, credentials: true });
app.enableShutdownHooks();
}

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

@ -20,7 +20,7 @@ export class DatabaseService
connectionString: environment.DATABASE_URL,
connectionTimeoutMillis: 3000,
query_timeout: 3000,
max: 10,
max: environment.DATABASE_POOL_SIZE,
}),
});
}

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,5 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { AppError } from '../common/errors/app-error';
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
@Injectable()
@ -13,7 +14,7 @@ export class HealthService {
try {
await this.database.ping();
} catch {
throw new ServiceUnavailableException('Service is not ready');
throw new AppError('DATABASE_NOT_READY');
}
return { status: 'ok' };
}

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