feat/commerce-operations #4

Merged
mihir merged 5 commits from feat/commerce-operations into main 2026-09-11 17:01:26 +05:30
48 changed files with 1879 additions and 22 deletions

View File

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

View File

@ -28,9 +28,9 @@ Orders snapshot SKU, product/variant names, unit prices, quantities, line totals
## Pricing and payment boundary ## Pricing and payment boundary
Orders start as PENDING_PAYMENT but expose pricingStatus UNFINALIZED, paymentAvailable false, and null taxTotal, shippingTotal and payableTotal. Merchandise total is subtotal minus discount; it is not a final amount to charge. Null charges must never be rendered as free shipping or zero tax. 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. Before enabling payment, finalize and snapshot those rules through a new migration and implement the verified payment lifecycle in Phase 1E. A fully discounted order still requires that workflow. 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 ## Coupon rules

View File

@ -8,3 +8,4 @@ Login failures deliberately share a public message to prevent enumeration; inter
Responses include X-Request-Id and Cache-Control: no-store. Throttled responses include Retry-After seconds. Client request IDs are not trusted. Configure restricted log access, retention and alerting at deployment. Responses include X-Request-Id and Cache-Control: no-store. Throttled responses include Retry-After seconds. Client request IDs are not trusted. Configure restricted log access, retention and alerting at deployment.
Checkout codes are defined in src/common/errors/checkout-errors.ts. Coupon eligibility failures share a safe public response with separate internal diagnostic reasons. Checkout codes are defined in src/common/errors/checkout-errors.ts. Coupon eligibility failures share a safe public response with separate internal diagnostic reasons.
Phase 1E codes live in src/common/errors/operations-errors.ts. Delivery logs use fixed DELIVERY_FAILED and DELIVERY_LEASE_LOST events with event IDs, never raw adapter errors.

View File

@ -13,3 +13,4 @@ Production uses `pnpm db:deploy`, then `pnpm db:status`. Never use db push in pr
Phase 1C appends four migrations after the original three: catalog/addresses, inventory, commerce integrity and inventory actor scope. SQL maintains additional integrity constraints and the append-only ledger trigger. Phase 1C appends four migrations after the original three: catalog/addresses, inventory, commerce integrity and inventory actor scope. SQL maintains additional integrity constraints and the append-only ledger trigger.
Phase 1D appends checkout tables, reservation ownership, immutable snapshot guards and deferred order/line reconciliation. Earlier migrations are unchanged. 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.

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.

View File

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

View File

@ -12,3 +12,4 @@ Live SMTP and the recovery frontend remain pending. Recovery delivery is synchro
References: [OWASP ASVS](https://owasp.org/www-project-application-security-verification-standard/) and [Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html). Conformance has not been independently assessed. References: [OWASP ASVS](https://owasp.org/www-project-application-security-verification-standard/) and [Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html). Conformance has not been independently assessed.
Phase 1D adds version checks, scoped idempotency, immutable order snapshots, deferred total reconciliation, order-held stock, coupon quotas and resource bounds. Native checkout race tests are included but await TEST_DATABASE_URL. Tax/shipping pricing and payment enablement remain blocked on business rules and Phase 1E. Phase 1D adds version checks, scoped idempotency, immutable order snapshots, deferred total reconciliation, order-held stock, coupon quotas and resource bounds. Native checkout race tests are included but await TEST_DATABASE_URL. Tax/shipping pricing and payment enablement remain blocked on business rules and Phase 1E.
The Phase 1E gateway is a test-only fake. Signature tests against it do not establish security for a real provider. Production payment/refund/webhook and fulfillment workflows require the provider-stage controls and tests in [the blueprint](phase1e-blueprint.md). The default notification adapter refuses delivery.

View File

@ -1,14 +1,16 @@
# Verification record — Phase 1D # Verification record — Phase 1E blueprint
- 154 passing tests across 28 suites; five native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL (one suite is entirely native). - 174 passing tests across 34 suites; six native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL (two suites are entirely native).
- Coverage: 99.54% statements, 99.65% lines, 87.83% branches and 100% functions for measured application code. - 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. - Formatting, migration checksums, Prisma validation, strict TypeScript checks and production compilation pass.
- All eleven migrations execute through Prisma migrate deploy; status is up to date and schema diff reports no drift against the disposable embedded PostgreSQL engine. The prior seven migrations remain unchanged; four timestamped migrations were appended. - 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 private/versioned carts, exact discount arithmetic, coupon eligibility, immutable order snapshots, deferred subtotal reconciliation, authorization, stock allocation, idempotent checkout/cancellation and rollback after final-write failure. - 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. - Production dependency audit reports no known vulnerabilities. No dependency versions changed in this phase.
Native PostgreSQL concurrency and remote Gitea CI remain pending. CI provisions PostgreSQL 17. Native tests cover recovery consumption, inventory competition, checkout replay, coupon competition and checkout versus standalone inventory reservations. Embedded tests do not establish multi-connection locking behavior. 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.
Tax/shipping rules have not been supplied. Orders expose unfinalized pricing, null payable totals and disabled payments. Full checkout-to-payment acceptance remains pending those rules and Phase 1E. No production deployment, real SMTP delivery or formal VAPT was performed. See [checkout contract](checkout-api.md) and [security assessment preparation](vapt-readiness.md). Native PostgreSQL concurrency and remote Gitea CI remain pending. The new native test covers simultaneous outbox claims; prior concurrency tests remain available. Embedded tests do not establish multi-connection locking behavior.
Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/checkout-orders, based on merged Phase 1C at 72cb947. No store tax/shipping settings were invented or seeded. All configured rates in tests are synthetic. No real payment, notification, production deployment or formal VAPT was performed. See [blueprint and acceptance matrix](phase1e-blueprint.md) and [operations API](operations-api.md).
Git author: mihir <motiyanimihir@gmail.com>. Branch: feat/commerce-operations, based on merged Phase 1D at 068b0f9.

View File

@ -68,6 +68,8 @@ model Order {
coupon Coupon? @relation(fields: [couponId, organizationId], references: [id, organizationId], onDelete: Restrict) coupon Coupon? @relation(fields: [couponId, organizationId], references: [id, organizationId], onDelete: Restrict)
lines OrderLine[] lines OrderLine[]
reservations StockReservation[] reservations StockReservation[]
pricing OrderPricing?
events CommerceEvent[]
@@unique([userId, organizationId, idempotencyKey]) @@unique([userId, organizationId, idempotencyKey])
@@unique([id, organizationId]) @@unique([id, organizationId])
@@index([organizationId, userId, createdAt, id]) @@index([organizationId, userId, createdAt, id])

24
prisma/events.prisma Normal file
View File

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

View File

@ -9,5 +9,7 @@
"20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb", "20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb",
"20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2", "20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2",
"20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7", "20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7",
"20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336" "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336",
"20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b",
"20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e"
} }

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;

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

View File

@ -23,6 +23,7 @@ model Organization {
catalogGroups CatalogGroup[] catalogGroups CatalogGroup[]
warehouses Warehouse[] warehouses Warehouse[]
coupons Coupon[] coupons Coupon[]
pricingPolicies PricingPolicy[]
@@map("organizations") @@map("organizations")
} }
model User { model User {

View File

@ -1,4 +1,5 @@
import { CheckoutModule } from './checkout/checkout.module'; import { CheckoutModule } from './checkout/checkout.module';
import { OperationsModule } from './operations/operations.module';
import { CatalogModule } from './catalog/catalog.module'; import { CatalogModule } from './catalog/catalog.module';
import { AddressesModule } from './addresses/addresses.module'; import { AddressesModule } from './addresses/addresses.module';
import { InventoryModule } from './inventory/inventory.module'; import { InventoryModule } from './inventory/inventory.module';
@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module';
AddressesModule, AddressesModule,
InventoryModule, InventoryModule,
CheckoutModule, CheckoutModule,
OperationsModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -8,6 +8,8 @@ import type { CheckoutInput } from './checkout.schemas';
import { checkoutSnapshot } from './checkout-snapshot'; import { checkoutSnapshot } from './checkout-snapshot';
import { holdOrderStock } from './stock-allocation'; import { holdOrderStock } from './stock-allocation';
import { orderView } from './order-view'; import { orderView } from './order-view';
import { snapshotPrice } from '../pricing/snapshot-price';
import { enqueueEvent } from '../events/enqueue-event';
@Injectable() @Injectable()
export class CheckoutStore { export class CheckoutStore {
@ -29,7 +31,7 @@ export class CheckoutStore {
idempotencyKey: input.idempotencyKey, idempotencyKey: input.idempotencyKey,
}, },
}, },
include: { lines: true }, include: { lines: true, pricing: true },
}); });
if (previous) { if (previous) {
assertReplay(previous.requestHash, requestHash); assertReplay(previous.requestHash, requestHash);
@ -66,6 +68,8 @@ export class CheckoutStore {
include: { lines: true }, include: { lines: true },
}); });
await holdOrderStock(tx, actor, order.id, expiresAt, lines); 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.cartLine.deleteMany({ where: { cartId } });
await tx.cart.update({ await tx.cart.update({
where: { id: cartId }, where: { id: cartId },
@ -78,7 +82,7 @@ export class CheckoutStore {
'order.created', 'order.created',
order.id, order.id,
); );
return orderView(order); return orderView({ ...order, pricing });
}); });
} }
} }

View File

@ -1,10 +1,16 @@
import type { Order, OrderLine } from '../generated/prisma/client'; import type {
Order,
OrderLine,
OrderPricing,
} from '../generated/prisma/client';
export function orderStatus(order: Pick<Order, 'status' | 'expiresAt'>) { export function orderStatus(order: Pick<Order, 'status' | 'expiresAt'>) {
return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date() return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date()
? 'EXPIRED' ? 'EXPIRED'
: order.status; : order.status;
} }
export function orderView(order: Order & { lines: OrderLine[] }) { export function orderView(
order: Order & { lines: OrderLine[]; pricing?: OrderPricing | null },
) {
return { return {
id: order.id, id: order.id,
status: orderStatus(order), status: orderStatus(order),
@ -12,10 +18,18 @@ export function orderView(order: Order & { lines: OrderLine[] }) {
subtotal: order.subtotal, subtotal: order.subtotal,
discount: order.discount, discount: order.discount,
merchandiseTotal: order.merchandiseTotal, merchandiseTotal: order.merchandiseTotal,
pricingStatus: 'UNFINALIZED', pricingStatus: order.pricing ? 'FINALIZED' : 'UNFINALIZED',
taxTotal: null, taxTotal: order.pricing?.taxTotal ?? null,
shippingTotal: null, shippingTotal: order.pricing?.shippingNet ?? null,
payableTotal: 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, paymentAvailable: false,
address: order.addressSnapshot, address: order.addressSnapshot,
coupon: order.couponSnapshot, coupon: order.couponSnapshot,

View File

@ -6,6 +6,7 @@ import { recordAudit } from '../identity/audit';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { lockStock } from '../inventory/stock-lock'; import { lockStock } from '../inventory/stock-lock';
import { orderView, orderStatus } from './order-view'; import { orderView, orderStatus } from './order-view';
import { enqueueEvent } from '../events/enqueue-event';
@Injectable() @Injectable()
export class OrderStore { export class OrderStore {
@ -50,7 +51,7 @@ export class OrderStore {
organizationId: actor.organizationId, organizationId: actor.organizationId,
...(!staff ? { userId: actor.userId } : {}), ...(!staff ? { userId: actor.userId } : {}),
}, },
include: { lines: { orderBy: { variantId: 'asc' } } }, include: { lines: { orderBy: { variantId: 'asc' } }, pricing: true },
}); });
if (!order) throw new AppError('ORDER_NOT_FOUND'); if (!order) throw new AppError('ORDER_NOT_FOUND');
return orderView(order); return orderView(order);
@ -68,6 +69,7 @@ export class OrderStore {
}, },
include: { include: {
lines: true, lines: true,
pricing: true,
reservations: { orderBy: { stockItemId: 'asc' } }, reservations: { orderBy: { stockItemId: 'asc' } },
}, },
}); });
@ -82,8 +84,9 @@ export class OrderStore {
const updated = await tx.order.update({ const updated = await tx.order.update({
where: { id }, where: { id },
data: { status: 'CANCELLED' }, data: { status: 'CANCELLED' },
include: { lines: true }, include: { lines: true, pricing: true },
}); });
await enqueueEvent(tx, actor.organizationId, id, 'order.cancelled');
await recordAudit( await recordAudit(
tx, tx,
actor.organizationId, actor.organizationId,

View File

@ -1,9 +1,11 @@
import { CHECKOUT_ERRORS } from './checkout-errors'; import { CHECKOUT_ERRORS } from './checkout-errors';
import { OPERATIONS_ERRORS } from './operations-errors';
import { PLATFORM_ERRORS } from './platform-errors'; import { PLATFORM_ERRORS } from './platform-errors';
import { COMMERCE_ERRORS } from './commerce-errors'; import { COMMERCE_ERRORS } from './commerce-errors';
export const ERRORS = { export const ERRORS = {
...PLATFORM_ERRORS, ...PLATFORM_ERRORS,
...COMMERCE_ERRORS, ...COMMERCE_ERRORS,
...CHECKOUT_ERRORS, ...CHECKOUT_ERRORS,
...OPERATIONS_ERRORS,
} as const; } as const;
export type ErrorCode = keyof typeof ERRORS; 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,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,7 @@
export const PERMISSIONS = [ export const PERMISSIONS = [
'pricing.manage',
'operations.read',
'notifications.retry',
'coupons.manage', 'coupons.manage',
'orders.read', 'orders.read',
'orders.manage', 'orders.manage',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

131
test/pricing.spec.ts Normal file
View File

@ -0,0 +1,131 @@
import { randomUUID } from 'node:crypto';
import { identityApp, type IdentityApp } from './helpers/identity-app';
import { checkoutFixture } from './helpers/checkout';
import { activatePricing, pricingInput } from './helpers/pricing';
import { secondActor } from './helpers/commerce';
describe('versioned pricing snapshots', () => {
let ctx: IdentityApp;
beforeAll(async () => {
ctx = await identityApp();
}, 60000);
afterAll(async () => {
await ctx.close();
});
beforeEach(async () => {
await ctx.clearLimits();
await ctx.db.pricingPolicy.updateMany({ data: { active: false } });
});
it('finalizes configured totals and preserves the exact policy on retries and reads', async () => {
const policy = await activatePricing(ctx);
const f = await checkoutFixture(ctx);
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
expect(order.body).toMatchObject({
pricingStatus: 'FINALIZED',
taxTotal: '188.64',
shippingTotal: '50',
payableTotal: '1236.64',
paymentAvailable: false,
pricing: { policyId: policy.id },
});
await activatePricing(ctx, { merchandiseTaxBps: 0, shippingFee: '0.00' });
const replay = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
expect(replay.body).toEqual(order.body);
const detail = await ctx
.api()
.get('/api/v1/orders/' + order.body.id)
.auth(f.actor.token, { type: 'bearer' })
.expect(200);
expect(detail.body).toEqual(order.body);
await expect(
ctx.executeSql(
`UPDATE order_pricing SET payable_total = 0 WHERE order_id = '${order.body.id}'`,
),
).rejects.toThrow();
await expect(
ctx.executeSql(
`DELETE FROM order_pricing WHERE order_id = '${order.body.id}'`,
),
).rejects.toThrow();
await expect(
ctx.executeSql(
`UPDATE pricing_policies SET shipping_fee = 0 WHERE id = '${policy.id}'`,
),
).rejects.toThrow();
});
it('prefers a region-specific policy and never reprices earlier unconfigured orders', async () => {
const f = await checkoutFixture(ctx);
const old = await ctx
.api()
.post('/api/v1/checkout')
.auth(f.actor.token, { type: 'bearer' })
.send(f.input)
.expect(201);
expect(old.body.pricingStatus).toBe('UNFINALIZED');
await activatePricing(ctx);
const regional = await activatePricing(ctx, {
region: 'maharashtra',
merchandiseTaxBps: 0,
shippingFee: '0.00',
});
const next = await checkoutFixture(ctx);
const order = await ctx
.api()
.post('/api/v1/checkout')
.auth(next.actor.token, { type: 'bearer' })
.send(next.input)
.expect(201);
expect(order.body.pricing.policyId).toBe(regional.id);
expect(order.body.payableTotal).toBe('998');
const unchanged = await ctx
.api()
.get('/api/v1/orders/' + old.body.id)
.auth(f.actor.token, { type: 'bearer' })
.expect(200);
expect(unchanged.body.pricingStatus).toBe('UNFINALIZED');
});
it('enforces permissions and organization scoping for policy administration', async () => {
const customer = await secondActor(ctx);
await ctx
.api()
.post('/api/v1/admin/pricing-policies')
.auth(customer.token, { type: 'bearer' })
.send(pricingInput())
.expect(403);
const policy = await activatePricing(ctx);
const other = await secondActor(ctx, false, ['pricing.manage']);
const list = await ctx
.api()
.get('/api/v1/admin/pricing-policies')
.auth(other.token, { type: 'bearer' })
.expect(200);
expect(list.body).toEqual([]);
await ctx
.api()
.patch('/api/v1/admin/pricing-policies/' + policy.id + '/status')
.auth(other.token, { type: 'bearer' })
.send({ active: false })
.expect(404);
await ctx
.api()
.patch('/api/v1/admin/pricing-policies/' + randomUUID() + '/status')
.auth(ctx.token, { type: 'bearer' })
.send({ active: false })
.expect(404);
await ctx
.api()
.patch('/api/v1/admin/pricing-policies/' + policy.id + '/status')
.auth(ctx.token, { type: 'bearer' })
.send({ active: false })
.expect(200);
});
});