diff --git a/README.md b/README.md index c533be2..8adcf9b 100644 --- a/README.md +++ b/README.md @@ -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). 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. diff --git a/docs/checkout-api.md b/docs/checkout-api.md index cd5f8ac..bee460b 100644 --- a/docs/checkout-api.md +++ b/docs/checkout-api.md @@ -28,9 +28,9 @@ Orders snapshot SKU, product/variant names, unit prices, quantities, line totals ## 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 diff --git a/docs/error-contract.md b/docs/error-contract.md index 175389d..711e7af 100644 --- a/docs/error-contract.md +++ b/docs/error-contract.md @@ -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. 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. diff --git a/docs/migrations.md b/docs/migrations.md index 4797ab5..daf69b1 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -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 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. diff --git a/docs/operations-api.md b/docs/operations-api.md new file mode 100644 index 0000000..4fcfcf4 --- /dev/null +++ b/docs/operations-api.md @@ -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 0–10000. 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. diff --git a/docs/phase1e-blueprint.md b/docs/phase1e-blueprint.md new file mode 100644 index 0000000..586abd6 --- /dev/null +++ b/docs/phase1e-blueprint.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e5af32..7979820 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. - 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: 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 diff --git a/docs/vapt-readiness.md b/docs/vapt-readiness.md index 177b493..64113f3 100644 --- a/docs/vapt-readiness.md +++ b/docs/vapt-readiness.md @@ -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. 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. diff --git a/docs/verification.md b/docs/verification.md index abefc03..7fd2248 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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). -- Coverage: 99.54% statements, 99.65% lines, 87.83% branches and 100% functions for measured application code. +- 174 passing tests across 34 suites; six native PostgreSQL concurrency tests are skipped without TEST_DATABASE_URL (two suites are entirely native). +- Coverage: 99.60% statements, 99.70% lines, 88.49% branches and 100% functions for measured application code. - Formatting, migration checksums, Prisma validation, strict TypeScript checks and production compilation pass. -- All 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. -- 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. +- All thirteen migrations execute through Prisma migrate deploy; status is up to date and schema diff reports no drift against the disposable embedded PostgreSQL engine. The prior eleven migrations remain unchanged; two timestamped migrations were appended. +- New tests cover fake-gateway idempotency and raw-byte signature checks, payment/refund decision policies, partial shipment/return bounds, exact inclusive/exclusive pricing, immutable pricing snapshots, atomic event creation, leased delivery, retries and safe logging. - Production dependency audit reports no known vulnerabilities. No dependency versions changed in this phase. -Native PostgreSQL concurrency and remote Gitea CI remain pending. CI provisions PostgreSQL 17. 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 . 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 . Branch: feat/commerce-operations, based on merged Phase 1D at 068b0f9. diff --git a/prisma/checkout.prisma b/prisma/checkout.prisma index 5e93729..3d5cd40 100644 --- a/prisma/checkout.prisma +++ b/prisma/checkout.prisma @@ -68,6 +68,8 @@ model Order { 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]) diff --git a/prisma/events.prisma b/prisma/events.prisma new file mode 100644 index 0000000..78e6421 --- /dev/null +++ b/prisma/events.prisma @@ -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") +} diff --git a/prisma/migration-checksums.json b/prisma/migration-checksums.json index 9e5c06e..3abdb0f 100644 --- a/prisma/migration-checksums.json +++ b/prisma/migration-checksums.json @@ -9,5 +9,7 @@ "20260910182626_checkout_orders": "aa924db3868c96475c8255800788de42d8e7447cf0a2c84b789b116e533582cb", "20260910182800_checkout_integrity": "e88b972ab16a4cd95f6fb4f097ce41ddfc7917899dc3803a1e7757c3e56c19b2", "20260910183016_checkout_snapshot_guards": "f1c7a95b5a620e06a518a96d457fb493241bd2d5e7deb6b5788ad85c8f3b59f7", - "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336" + "20260910184357_order_reconciliation": "c32e7a917618e02ed1658abb740a7f4e0513a47e0734ad29d90fff325fd05336", + "20260911110906_pricing_events": "60a4a5a0a05821c2a9785496cd2e9bc0f839e5fb2ae3c59275655481c96eb66b", + "20260911111403_operations_integrity": "f40bddf9e29d6518bc765cbaca7688c04cb95d5ff729c52e7dc775eefa1e521e" } diff --git a/prisma/migrations/20260911110906_pricing_events/migration.sql b/prisma/migrations/20260911110906_pricing_events/migration.sql new file mode 100644 index 0000000..8dab894 --- /dev/null +++ b/prisma/migrations/20260911110906_pricing_events/migration.sql @@ -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; diff --git a/prisma/migrations/20260911111403_operations_integrity/migration.sql b/prisma/migrations/20260911111403_operations_integrity/migration.sql new file mode 100644 index 0000000..4a609b7 --- /dev/null +++ b/prisma/migrations/20260911111403_operations_integrity/migration.sql @@ -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; diff --git a/prisma/pricing.prisma b/prisma/pricing.prisma new file mode 100644 index 0000000..5417a95 --- /dev/null +++ b/prisma/pricing.prisma @@ -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") +} + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 157b82b..2e2d547 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model Organization { catalogGroups CatalogGroup[] warehouses Warehouse[] coupons Coupon[] + pricingPolicies PricingPolicy[] @@map("organizations") } model User { diff --git a/src/app.module.ts b/src/app.module.ts index 8710228..80aa6cf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ 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'; @@ -16,6 +17,7 @@ import { HealthModule } from './health/health.module'; AddressesModule, InventoryModule, CheckoutModule, + OperationsModule, ], }) export class AppModule {} diff --git a/src/checkout/checkout.store.ts b/src/checkout/checkout.store.ts index 90593fb..00d1cf7 100644 --- a/src/checkout/checkout.store.ts +++ b/src/checkout/checkout.store.ts @@ -8,6 +8,8 @@ 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 { @@ -29,7 +31,7 @@ export class CheckoutStore { idempotencyKey: input.idempotencyKey, }, }, - include: { lines: true }, + include: { lines: true, pricing: true }, }); if (previous) { assertReplay(previous.requestHash, requestHash); @@ -66,6 +68,8 @@ export class CheckoutStore { 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 }, @@ -78,7 +82,7 @@ export class CheckoutStore { 'order.created', order.id, ); - return orderView(order); + return orderView({ ...order, pricing }); }); } } diff --git a/src/checkout/order-view.ts b/src/checkout/order-view.ts index 5ed7d3e..73d451f 100644 --- a/src/checkout/order-view.ts +++ b/src/checkout/order-view.ts @@ -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) { return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date() ? 'EXPIRED' : order.status; } -export function orderView(order: Order & { lines: OrderLine[] }) { +export function orderView( + order: Order & { lines: OrderLine[]; pricing?: OrderPricing | null }, +) { return { id: order.id, status: orderStatus(order), @@ -12,10 +18,18 @@ export function orderView(order: Order & { lines: OrderLine[] }) { subtotal: order.subtotal, discount: order.discount, merchandiseTotal: order.merchandiseTotal, - pricingStatus: 'UNFINALIZED', - taxTotal: null, - shippingTotal: null, - payableTotal: null, + 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, diff --git a/src/checkout/order.store.ts b/src/checkout/order.store.ts index d880975..6e57c98 100644 --- a/src/checkout/order.store.ts +++ b/src/checkout/order.store.ts @@ -6,6 +6,7 @@ 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 { @@ -50,7 +51,7 @@ export class OrderStore { organizationId: actor.organizationId, ...(!staff ? { userId: actor.userId } : {}), }, - include: { lines: { orderBy: { variantId: 'asc' } } }, + include: { lines: { orderBy: { variantId: 'asc' } }, pricing: true }, }); if (!order) throw new AppError('ORDER_NOT_FOUND'); return orderView(order); @@ -68,6 +69,7 @@ export class OrderStore { }, include: { lines: true, + pricing: true, reservations: { orderBy: { stockItemId: 'asc' } }, }, }); @@ -82,8 +84,9 @@ export class OrderStore { const updated = await tx.order.update({ where: { id }, data: { status: 'CANCELLED' }, - include: { lines: true }, + include: { lines: true, pricing: true }, }); + await enqueueEvent(tx, actor.organizationId, id, 'order.cancelled'); await recordAudit( tx, actor.organizationId, diff --git a/src/common/errors/error-catalog.ts b/src/common/errors/error-catalog.ts index 8bcdbcd..25360b4 100644 --- a/src/common/errors/error-catalog.ts +++ b/src/common/errors/error-catalog.ts @@ -1,9 +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; diff --git a/src/common/errors/operations-errors.ts b/src/common/errors/operations-errors.ts new file mode 100644 index 0000000..9b4dbd3 --- /dev/null +++ b/src/common/errors/operations-errors.ts @@ -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; diff --git a/src/events/delivery.port.ts b/src/events/delivery.port.ts new file mode 100644 index 0000000..ef3b829 --- /dev/null +++ b/src/events/delivery.port.ts @@ -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; +} diff --git a/src/events/delivery.service.ts b/src/events/delivery.service.ts new file mode 100644 index 0000000..f8757e4 --- /dev/null +++ b/src/events/delivery.service.ts @@ -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 { + 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; + } +} diff --git a/src/events/delivery.store.ts b/src/events/delivery.store.ts new file mode 100644 index 0000000..0acd8d6 --- /dev/null +++ b/src/events/delivery.store.ts @@ -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, + ), + }, + }); + } +} diff --git a/src/events/disabled-delivery.ts b/src/events/disabled-delivery.ts new file mode 100644 index 0000000..db80310 --- /dev/null +++ b/src/events/disabled-delivery.ts @@ -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 { + this.assertConfigured(); + } +} diff --git a/src/events/enqueue-event.ts b/src/events/enqueue-event.ts new file mode 100644 index 0000000..d999146 --- /dev/null +++ b/src/events/enqueue-event.ts @@ -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: {}, + }); +} diff --git a/src/fulfillment/fulfillment.policy.ts b/src/fulfillment/fulfillment.policy.ts new file mode 100644 index 0000000..fafba1c --- /dev/null +++ b/src/fulfillment/fulfillment.policy.ts @@ -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 = { + 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'); +} diff --git a/src/identity/permissions.ts b/src/identity/permissions.ts index febcbd0..62a63c1 100644 --- a/src/identity/permissions.ts +++ b/src/identity/permissions.ts @@ -1,4 +1,7 @@ export const PERMISSIONS = [ + 'pricing.manage', + 'operations.read', + 'notifications.retry', 'coupons.manage', 'orders.read', 'orders.manage', diff --git a/src/operations/operations.controller.ts b/src/operations/operations.controller.ts new file mode 100644 index 0000000..d242e65 --- /dev/null +++ b/src/operations/operations.controller.ts @@ -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); + } +} diff --git a/src/operations/operations.module.ts b/src/operations/operations.module.ts new file mode 100644 index 0000000..6c1031b --- /dev/null +++ b/src/operations/operations.module.ts @@ -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 {} diff --git a/src/operations/operations.store.ts b/src/operations/operations.store.ts new file mode 100644 index 0000000..3abc7a3 --- /dev/null +++ b/src/operations/operations.store.ts @@ -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 }; + }); + } +} diff --git a/src/payments/gateway.contract.ts b/src/payments/gateway.contract.ts new file mode 100644 index 0000000..7c9f9ac --- /dev/null +++ b/src/payments/gateway.contract.ts @@ -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' }>; +} diff --git a/src/payments/payment.policy.ts b/src/payments/payment.policy.ts new file mode 100644 index 0000000..4d83111 --- /dev/null +++ b/src/payments/payment.policy.ts @@ -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(); +} diff --git a/src/pricing/pricing.controller.ts b/src/pricing/pricing.controller.ts new file mode 100644 index 0000000..c650042 --- /dev/null +++ b/src/pricing/pricing.controller.ts @@ -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); + } +} diff --git a/src/pricing/pricing.policy.ts b/src/pricing/pricing.policy.ts new file mode 100644 index 0000000..3b1ba80 --- /dev/null +++ b/src/pricing/pricing.policy.ts @@ -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), + }; +} diff --git a/src/pricing/pricing.schema.ts b/src/pricing/pricing.schema.ts new file mode 100644 index 0000000..dbf98ac --- /dev/null +++ b/src/pricing/pricing.schema.ts @@ -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; diff --git a/src/pricing/pricing.store.ts b/src/pricing/pricing.store.ts new file mode 100644 index 0000000..ffc5c42 --- /dev/null +++ b/src/pricing/pricing.store.ts @@ -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; + }); + } +} diff --git a/src/pricing/snapshot-price.ts b/src/pricing/snapshot-price.ts new file mode 100644 index 0000000..f09cda4 --- /dev/null +++ b/src/pricing/snapshot-price.ts @@ -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), + }, + }); +} diff --git a/test/delivery-service.spec.ts b/test/delivery-service.spec.ts new file mode 100644 index 0000000..dc567aa --- /dev/null +++ b/test/delivery-service.spec.ts @@ -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'); + }); +}); diff --git a/test/events-concurrency.spec.ts b/test/events-concurrency.spec.ts new file mode 100644 index 0000000..79a2a64 --- /dev/null +++ b/test/events-concurrency.spec.ts @@ -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); + }); +}); diff --git a/test/events.spec.ts b/test/events.spec.ts new file mode 100644 index 0000000..ee2decf --- /dev/null +++ b/test/events.spec.ts @@ -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(); + }); +}); diff --git a/test/fulfillment-blueprint.spec.ts b/test/fulfillment-blueprint.spec.ts new file mode 100644 index 0000000..550d635 --- /dev/null +++ b/test/fulfillment-blueprint.spec.ts @@ -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(); + }); +}); diff --git a/test/helpers/fake-gateway.ts b/test/helpers/fake-gateway.ts new file mode 100644 index 0000000..51d7caf --- /dev/null +++ b/test/helpers/fake-gateway.ts @@ -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 }; + } +} diff --git a/test/helpers/pricing.ts b/test/helpers/pricing.ts new file mode 100644 index 0000000..7f7e838 --- /dev/null +++ b/test/helpers/pricing.ts @@ -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 = {}, +) { + 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; +} diff --git a/test/payment-blueprint.spec.ts b/test/payment-blueprint.spec.ts new file mode 100644 index 0000000..19c6e92 --- /dev/null +++ b/test/payment-blueprint.spec.ts @@ -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'); + }); +}); diff --git a/test/pricing-policy.spec.ts b/test/pricing-policy.spec.ts new file mode 100644 index 0000000..bdf0e48 --- /dev/null +++ b/test/pricing-policy.spec.ts @@ -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, + ); + }); +}); diff --git a/test/pricing.spec.ts b/test/pricing.spec.ts new file mode 100644 index 0000000..ec08686 --- /dev/null +++ b/test/pricing.spec.ts @@ -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); + }); +});