From a4588df11285f4329aa155eac6f6e7dd431b4155 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 24 Mar 2026 10:12:01 -0700 Subject: [PATCH 1/5] (SP: 1)[SHOP] closing documental baseline --- .../docs/payments/monobank/E0-gap-report.md | 174 ------------- frontend/docs/payments/monobank/F0-report.md | 104 -------- .../shop/checkout-notifications-contract.md | 188 ++++++++++++++ frontend/docs/shop/launch-scope-decisions.md | 230 ++++++++++++++++++ .../shop/legal-merchant-identity-content.md | 143 +++++++++++ frontend/docs/shop/payments-runbook.md | 199 +++++++++++++++ 6 files changed, 760 insertions(+), 278 deletions(-) delete mode 100644 frontend/docs/payments/monobank/E0-gap-report.md delete mode 100644 frontend/docs/payments/monobank/F0-report.md create mode 100644 frontend/docs/shop/checkout-notifications-contract.md create mode 100644 frontend/docs/shop/launch-scope-decisions.md create mode 100644 frontend/docs/shop/legal-merchant-identity-content.md create mode 100644 frontend/docs/shop/payments-runbook.md diff --git a/frontend/docs/payments/monobank/E0-gap-report.md b/frontend/docs/payments/monobank/E0-gap-report.md deleted file mode 100644 index e1e3556c..00000000 --- a/frontend/docs/payments/monobank/E0-gap-report.md +++ /dev/null @@ -1,174 +0,0 @@ -# Monobank E0 Gap Report (Facts vs Proposals) - -## FACTS vs PROPOSALS - -- **FACTS** in this document are verified directly from repo code. -- **PROPOSALS** are suggestions only; they do **not** imply any code changes. -- **No Stripe changes** are proposed; Stripe references are read‑only for - architecture parity. - ---- - -## FACTS — Order creation (entrypoints) - -- **Route handler:** `frontend/app/api/shop/checkout/route.ts` - - `POST` handler validates payload + Idempotency‑Key, resolves provider, then - calls `createOrderWithItems(...)`. -- **Service function:** `frontend/lib/services/orders/checkout.ts` - - `export async function createOrderWithItems(...)` is the order creation + - inventory reserve flow. - - Uses `hashIdempotencyRequest(...)` from - `frontend/lib/services/orders/_shared.ts` to enforce idempotency. - ---- - -## FACTS — Payment attempts creation (Stripe vs Monobank) - -### Stripe attempts - -- **Primary entrypoint:** `frontend/lib/services/orders/payment-attempts.ts` - - `export async function ensureStripePaymentIntentForOrder(...)` - - Internal helpers: - - `createActiveAttempt(...)` - - `upsertBackfillAttemptForExistingPI(...)` - - Uses `buildStripeAttemptIdempotencyKey(...)` from - `frontend/lib/services/orders/attempt-idempotency.ts`. -- **Caller:** `frontend/app/api/shop/checkout/route.ts` invokes - `ensureStripePaymentIntentForOrder(...)` in Stripe flow. - -### Monobank attempts - -- **Primary entrypoint:** `frontend/lib/services/orders/monobank.ts` - - `export async function createMonoAttemptAndInvoice(...)` - - Wrapper: `export async function createMonobankAttemptAndInvoice(...)` - (builds redirect + webhook URLs and calls `createMonoAttemptAndInvoice`). - - Internal helper: `createCreatingAttempt(...)` inserts a `payment_attempts` - row with status `creating`. - - Uses `buildMonobankAttemptIdempotencyKey(...)` from - `frontend/lib/services/orders/attempt-idempotency.ts`. -- **Caller:** `frontend/app/api/shop/checkout/route.ts` (Monobank branch uses - lazy import and calls `createMonobankAttemptAndInvoice(...)`). - ---- - -## FACTS — Orders + payment_attempts data contract (schema + usage) - -### Orders table (`frontend/db/schema/shop.ts`) - -- **Key fields:** - - `paymentStatus` (enum): - `pending | requires_payment | paid | failed | refunded` - - `paymentProvider` (text + CHECK): `'stripe' | 'monobank' | 'none'` - - `status` (enum): - `CREATED | INVENTORY_RESERVED | INVENTORY_FAILED | PAID | CANCELED` - - `paymentIntentId`, `pspChargeId`, `pspStatusReason`, `pspMetadata` - - `idempotencyKey`, `idempotencyRequestHash` - - `stockRestored`, `restockedAt`, `inventoryStatus` -- **Usage examples:** - - `createOrderWithItems(...)` writes `paymentProvider`, `paymentStatus`, - `status`, `idempotencyKey`, `idempotencyRequestHash`. - (`frontend/lib/services/orders/checkout.ts`) - -### payment_attempts table (`frontend/db/schema/shop.ts`) - -- **Key fields:** - - `provider` (CHECK): `'stripe' | 'monobank'` - - `status` (CHECK): `creating | active | succeeded | failed | canceled` - - `attemptNumber`, `currency`, `expectedAmountMinor` - - `idempotencyKey` (unique) - - `providerPaymentIntentId` (Stripe PI id / Monobank invoice id) - - `checkoutUrl`, `providerCreatedAt`, `providerModifiedAt` - - `lastErrorCode`, `lastErrorMessage`, `metadata` - - `createdAt`, `updatedAt`, `finalizedAt` -- **Usage examples:** - - Stripe: `ensureStripePaymentIntentForOrder(...)` creates/updates attempts - and sets `providerPaymentIntentId`. - (`frontend/lib/services/orders/payment-attempts.ts`) - - Monobank: `createMonoAttemptAndInvoice(...)` inserts attempt with - `status='creating'` and finalizes with `providerPaymentIntentId` + - `metadata.pageUrl`. - (`frontend/lib/services/orders/monobank.ts`) - ---- - -## FACTS — Idempotency - -### Orders - -- **Fields:** `orders.idempotencyKey` and `orders.idempotencyRequestHash` - (`frontend/db/schema/shop.ts`) -- **Enforcement path:** - - `Idempotency-Key` header parsed in `frontend/app/api/shop/checkout/route.ts` - - `createOrderWithItems(...)` checks existing order via - `getOrderByIdempotencyKey(...)` and verifies the request hash using - `hashIdempotencyRequest(...)`. - (`frontend/lib/services/orders/summary.ts`, `frontend/lib/services/orders/_shared.ts`, - `frontend/lib/services/orders/checkout.ts`) -- **Behavior (facts):** - - If an existing order is found and the request hash matches, the existing - order is returned. - - If the request hash does not match, `IdempotencyConflictError` is thrown and - the route returns a conflict response. - -### Payment attempts - -- **Unique constraint:** `payment_attempts_idempotency_key_unique` - (`frontend/db/schema/shop.ts`) -- **Builder helpers:** - - `buildStripeAttemptIdempotencyKey(provider, orderId, attemptNo)` - - `buildMonobankAttemptIdempotencyKey(orderId, attemptNo)` - (`frontend/lib/services/orders/attempt-idempotency.ts`) -- **Usage:** - - Stripe attempts: `createActiveAttempt(...)` / - `upsertBackfillAttemptForExistingPI(...)` - (`frontend/lib/services/orders/payment-attempts.ts`) - - Monobank attempts: `createCreatingAttempt(...)` - (`frontend/lib/services/orders/monobank.ts`) - ---- - -## FACTS — Stripe events dedupe/claim (read‑only) - -- **Route:** `frontend/app/api/shop/webhooks/stripe/route.ts` - - Uses `tryClaimStripeEvent(...)` to claim events via - `stripe_events.claimedAt/claimExpiresAt/claimedBy`. - - Flow (high‑level): insert `stripe_events` row (dedupe), claim lease, apply - updates, then mark `processedAt`. -- **Schema:** `stripe_events` in `frontend/db/schema/shop.ts` - - Fields include: `eventId`, `paymentIntentId`, `orderId`, `eventType`, - `paymentStatus`, `claimedAt`, `claimExpiresAt`, `claimedBy`, `processedAt`. - - Unique index: `stripe_events_event_id_idx`. - ---- - -## PROPOSAL — Monobank events parity (no Stripe changes) - -**Goal:** mirror Stripe’s event persistence model without touching -`stripe_events` or Stripe webhooks. - -- **Table strategy:** use a provider‑scoped events table (e.g., - `monobank_events` or generic `psp_events`) with `provider='monobank'`. -- **Dedupe:** `eventKey` and/or `raw_sha256` (e.g., `sha256(rawBytes)`) to - prevent double‑apply. -- **Claim/lease fields:** add `claimedAt`, `claimExpiresAt`, `claimedBy` - (TTL‑based) to allow multi‑instance safe applies. -- **Apply modes:** honor `apply | store | drop` modes if `MONO_WEBHOOK_MODE` - exists in config (`frontend/lib/env/monobank.ts`). -- **Explicit statement:** **No changes to `stripe_events` or Stripe webhook - route.** - ---- - -## FACTS — Gaps / TODO list (observed from code) - -- **No Monobank refund implementation:** - `frontend/lib/services/orders/refund.ts` is Stripe‑only (Monobank refunds are - not handled there). - (`frontend/app/api/shop/admin/orders/[id]/refund/route.ts` blocks monobank - refunds when `MONO_REFUND_ENABLED=false`.) -- **No Monobank event claim/lease fields:** `monobank_events` schema does not - include `claimedAt/claimExpiresAt/claimedBy` (present only in - `stripe_events`). -- **No explicit Monobank event processing marker:** `monobank_events` has - `appliedAt`/`appliedResult`, but no `processedAt` or claim TTL fields like - Stripe’s flow. diff --git a/frontend/docs/payments/monobank/F0-report.md b/frontend/docs/payments/monobank/F0-report.md deleted file mode 100644 index d861a45b..00000000 --- a/frontend/docs/payments/monobank/F0-report.md +++ /dev/null @@ -1,104 +0,0 @@ -# F0 Recon: Shop Checkout + Monobank Route Surface - -## 1) Checkout route (POST /api/shop/checkout) - -- Route file: `frontend/app/api/shop/checkout/route.ts` -- Handler: `export async function POST(request: NextRequest)` - -## 2) API response/error + logging helpers used by checkout - -- Checkout-local JSON helpers in `frontend/app/api/shop/checkout/route.ts`: - - `errorResponse(code, message, status, details?)` - - `buildCheckoutResponse({ order, itemCount, clientSecret, status })` -- Shared rate-limit response helper: - - `rateLimitResponse(...)` from `frontend/lib/security/rate-limit.ts` -- Logging helpers: - - `logWarn`, `logInfo`, `logError` from `frontend/lib/logging.ts` - -Related API pattern in other shop routes: - -- `noStoreJson(...)` local helper pattern appears in multiple route files - (example: `frontend/app/api/shop/catalog/route.ts`, - `frontend/app/api/shop/webhooks/monobank/route.ts`). - -## 3) Checkout rate limit helper wiring - -- Subject derivation: `getRateLimitSubject(request)` from - `frontend/lib/security/rate-limit.ts` -- Enforcement: `enforceRateLimit({ key, limit, windowSeconds })` -- Rejection response: `rateLimitResponse({ retryAfterSeconds, details })` -- Checkout key format: `checkout:${checkoutSubject}` in - `frontend/app/api/shop/checkout/route.ts` - -## 4) Existing checkout request shape + provider selection - -- Payload schema: `checkoutPayloadSchema` in `frontend/lib/validation/shop.ts` - - Shape: `{ items: CheckoutItemInput[]; userId?: string }` - - `items[]` fields: `productId`, `quantity`, optional `selectedSize`, optional - `selectedColor` - - Schema is strict. -- Provider selection in checkout route: - - Helper: `parseRequestedProvider(raw)` in - `frontend/app/api/shop/checkout/route.ts` - - Reads `paymentProvider` or `provider` from request body object. - - Accepts `stripe` or `monobank` (case-insensitive trim+lowercase). - - Invalid provider -> `422 PAYMENTS_PROVIDER_INVALID`. - - Default when omitted -> `stripe`. - -## 5) Existing idempotency behavior (extraction + storage) - -- Extraction in route: - - Helper: `getIdempotencyKey(request)` in - `frontend/app/api/shop/checkout/route.ts` - - Source: HTTP header `Idempotency-Key`. - - Validation schema: `idempotencyKeySchema` in - `frontend/lib/validation/shop.ts` - - 16..128 chars, regex `^[A-Za-z0-9_.-]+$`. -- Route-level behavior: - - Missing -> `400 MISSING_IDEMPOTENCY_KEY` - - Invalid format -> `400 INVALID_IDEMPOTENCY_KEY` (with zod-format details) -- Storage/usage: - - Orders dedupe key stored/read via `orders.idempotencyKey`: - - read path: `getOrderByIdempotencyKey(...)` in - `frontend/lib/services/orders/summary.ts` - - write/flow: `createOrderWithItems(...)` in - `frontend/lib/services/orders/checkout.ts` - - Request fingerprint stored as `orders.idempotencyRequestHash` in - `createOrderWithItems(...)`. - - Payment-attempt idempotency keys in `payment_attempts.idempotency_key`: - - Stripe: `buildStripeAttemptIdempotencyKey(...)` in - `frontend/lib/services/orders/attempt-idempotency.ts` - - Monobank: `buildMonobankAttemptIdempotencyKey(...)` in - `frontend/lib/services/orders/attempt-idempotency.ts` - -## 6) Existing response/error contract in checkout - -- Success response (`buildCheckoutResponse`): - - HTTP: `200` or `201` - - Body shape: - - `success: true` - - `order: { id, currency, totalAmount, itemCount, paymentStatus, paymentProvider, paymentIntentId, clientSecret }` - - top-level mirrors: `orderId`, `paymentStatus`, `paymentProvider`, - `paymentIntentId`, `clientSecret` -- Error response (`errorResponse`): - - Body shape: `{ code: string, message: string, details?: unknown }` - - Used status codes in this route: `400`, `409`, `422`, `500`, `502`, `503` -- Rate-limit response (`rateLimitResponse`): - - HTTP `429` - - Body shape: `{ success: false, code, retryAfterSeconds, details? }` - -## 7) Monobank services already present (names + paths) - -- Order/checkout side: - - `createMonoAttemptAndInvoice(...)` in - `frontend/lib/services/orders/monobank.ts` - - `createMonobankAttemptAndInvoice(...)` in - `frontend/lib/services/orders/monobank.ts` -- Webhook apply side: - - `applyMonoWebhookEvent(...)` in - `frontend/lib/services/orders/monobank-webhook.ts` -- PSP adapter side: - - `createMonobankInvoice(...)` in `frontend/lib/psp/monobank.ts` - - `cancelMonobankInvoice(...)` in `frontend/lib/psp/monobank.ts` - - `verifyMonobankWebhookSignature(...)` in `frontend/lib/psp/monobank.ts` - - Additional exported API methods are in `frontend/lib/psp/monobank.ts`. diff --git a/frontend/docs/shop/checkout-notifications-contract.md b/frontend/docs/shop/checkout-notifications-contract.md new file mode 100644 index 00000000..e62c1be3 --- /dev/null +++ b/frontend/docs/shop/checkout-notifications-contract.md @@ -0,0 +1,188 @@ +# Shop Checkout and Notifications Contract + +## Status + +Approved for launch implementation. + +## Purpose + +This document defines the launch contract between checkout, order persistence, +and customer notifications. + +Its purpose is to remove ambiguity around guest recipient identity and +notification reliability. + +--- + +## Core Decision + +Guest checkout requires a valid email address. + +This is a launch rule, not a UI preference. + +--- + +## Why This Exists + +Notification flows depend on a reliable recipient. If a guest order is created +without email, the notification pipeline can fail or dead-letter because there +is no guaranteed recipient identity. :contentReference[oaicite:2]{index=2} + +The system must not rely only on: + +- browser return pages, +- session state, +- manual manager copying, +- optional contact fields that may be absent. + +--- + +## Canonical Rules + +### 1. Guest Checkout + +Guest checkout is allowed only if a valid email is provided. + +Required: + +- checkout validation rejects guest orders without email +- guest email is persisted with the order/customer-contact record +- notification flows may rely on that persisted email as the canonical recipient + +Forbidden: + +- silent guest order creation without a contactable email +- treating email as optional for guest flows +- relying on browser-only success UX as the only customer confirmation + +### 2. Signed-In Checkout + +Signed-in checkout may use the authenticated user email according to current +account rules. + +Required: + +- the order must still persist a canonical contact email usable by notification + flows +- notification generation must use persisted order/account data, not manual + copying + +### 3. Notification Generation + +Customer notifications must be generated from persisted order/event data. + +Required sources: + +- canonical order state +- canonical payment events +- canonical shipment/order lifecycle events +- persisted recipient/contact data + +Forbidden: + +- manual reconstruction of recipient identity at send time +- depending on transient frontend-only data after checkout completes + +--- + +## Launch Notification Expectations + +### Required Launch Behavior + +The system must support reliable notification architecture based on: + +- templates +- projector/event mapping +- outbox delivery +- persisted order/event/contact data + +### Important Boundary + +At launch, not every notification type is fully implemented yet. This document +only defines the recipient/checkout contract needed so those flows are reliable +when enabled. + +--- + +## Checkout Validation Contract + +### Guest Orders + +Checkout must fail validation if: + +- guest email is missing +- guest email is structurally invalid + +### Signed-In Orders + +Checkout may use account identity, but the final order record must still contain +a canonical usable contact email. + +--- + +## Data Contract + +The following must be true for a successfully created order: + +- the order has a canonical recipient email +- the recipient email is persisted before downstream notification handling + depends on it +- notification workers do not need to guess recipient identity from optional + shipping-only fields + +If the system stores both customer email and shipping contact email, the +canonical notification recipient must be clearly defined and used consistently. + +--- + +## UX Contract + +The UI must not imply that: + +- guest email is optional +- success page alone is sufficient confirmation +- lack of email still results in normal guest notifications + +The UI may say: + +- order confirmations and status updates are sent to the provided email +- the email is required to complete guest checkout + +--- + +## Error Handling Contract + +If email is missing or invalid in a guest flow: + +- checkout must fail early with a controlled validation error +- the order must not be created in a partially notifiable state + +The system must not create an order first and discover missing recipient email +only later in the notification worker. + +--- + +## In Scope for Launch + +- mandatory guest email +- persisted canonical recipient email +- notification generation from order/event data +- validation-first rejection of non-notifiable guest orders + +## Out of Scope for Launch + +- guest checkout with no email +- browser-only confirmation model +- manual notification fallback as the primary designed path + +--- + +## Summary + +Launch contract: + +- guest checkout: **email required** +- signed-in checkout: **must still result in canonical persisted recipient + email** +- notifications: **generated from persisted order/event data** +- success page: **not the only confirmation channel** diff --git a/frontend/docs/shop/launch-scope-decisions.md b/frontend/docs/shop/launch-scope-decisions.md new file mode 100644 index 00000000..887a31b9 --- /dev/null +++ b/frontend/docs/shop/launch-scope-decisions.md @@ -0,0 +1,230 @@ +# Shop Launch Scope Decisions + +## Status + +Approved for launch planning. + +## Purpose + +This document fixes the business decisions that define launch scope for the Shop +module. These decisions exist to remove ambiguity before implementation work +continues. + +They are launch-policy decisions, not future-state product strategy. Anything +not explicitly enabled here is out of scope for launch. + +--- + +## 0.1 Inventory Policy + +### Decision: Product-Level Stock Only + +Launch uses **product-level stock only**. + +### Meaning + +- Stock is tracked at the **product level**. +- The shop does **not** support variant-level inventory at launch. +- Variant-specific stock reservation, decrement, restock, or reconciliation are + **out of scope**. +- If the UI shows options such as size/color, they must **not** imply separate + inventory unless that capability is implemented end-to-end. + +### Why (Inventory Complexity) + +Variant-level inventory introduces additional complexity in: + +- stock reservation, +- checkout validation, +- order item snapshotting, +- admin stock updates, +- oversell prevention, +- refund/restock behavior. + +That complexity is not required for launch and would expand scope without +improving launch safety. + +### Implementation Contract (Inventory) + +- Checkout and stock validation must use product-level inventory only. +- Admin inventory operations must update product-level stock only. +- Any fake or presentation-only variant options must not affect pricing, stock, + or fulfillment logic. + +### Out of Scope for Launch: Inventory + +- Variant SKU inventory +- Variant stock reservation +- Variant restock flows +- Variant-specific operational reporting + +--- + +## 0.2 Refund / Void Policy Matrix + +### Decision: Payment Reversal Policy + +Launch uses the following payment reversal policy: + +| Flow | Launch Policy | +| ------------------------------------------------------ | ------------------------------------------------- | +| Stripe paid order refund | Allowed | +| Stripe unpaid / incomplete payment cancellation | Allowed according to existing payment state rules | +| Monobank unpaid invoice void / cancellation | Allowed | +| Monobank paid refund | Disabled for launch | +| Return-based refunds | Disabled for launch | +| `paymentProvider='none'` for new orders | Forbidden | +| Legacy historical orders with `paymentProvider='none'` | Read-only / legacy only | + +### Meaning (Refund Policy) + +The launch refund model is intentionally narrow. Only reversal paths that are +already operationally safe and clearly supported are enabled. + +### Why (Payment Reversal Safety) + +The goal is to avoid partial or ambiguous payment reversal behavior in +production. Refund logic must be explicit per rail, not assumed to be symmetric +across providers. + +### Operational Contract (Refunds) + +- A paid Stripe order may be refunded through the approved Stripe refund flow. +- A Monobank payment that has not been captured / finalized may be canceled or + voided if the current rail logic supports that state. +- Paid Monobank refunds are not available to admins at launch. +- Returns are not an automatic refund source at launch; there is no + return-to-refund automation. +- New orders must never be created with `paymentProvider='none'`. + +### UI / Admin Contract + +- Admin UI must not expose unavailable refund actions. +- Unsupported actions must be either hidden or explicitly disabled. +- Public/legal/help content must not promise refund capabilities that are not + actually enabled. + +### Out of Scope for Launch: Refunds + +- Symmetric refund support across all PSPs +- Automated returns workflow +- Return approval -> refund orchestration +- Cross-provider unified refund console + +--- + +## 0.3 Guest Email Policy + +### Decision: Email Required for Guest Checkout + +Guest checkout requires **email as a mandatory field**. + +### Meaning (Guest Email) + +- A guest order cannot be created without a valid email address. +- Email is a required part of the checkout contract. +- Notifications for guest orders rely on the persisted checkout email. + +### Why (Guest Notifications) + +Guest notifications, confirmations, and recovery flows are not reliable if the +order has no email recipient. A guest checkout without email creates avoidable +operational gaps. + +### Implementation Contract (Guest Email) + +- Checkout validation must reject guest orders without email. +- Notification flows may assume a valid persisted email exists for guest orders. +- Signed-in users may still use their account email according to current account + rules. + +### Data Contract + +- Guest email must be stored with the order in the canonical + order/customer-contact record used by notification flows. +- The system must not rely only on browser return pages as proof of successful + order communication. + +### Out of Scope for Launch: Guest Email + +- Guest checkout without email +- Notification fallback based only on browser/session state +- Silent guest order creation with no contactable recipient + +--- + +## 0.4 Legal Merchant Identity Set + +### Decision: Complete Seller Identity Block + +Launch must publish a complete seller identity block. + +### Required Published Fields + +The public shop legal/contact area must include: + +- Merchant legal name +- Store / trading name, if different from legal name +- Support email +- Support phone +- Business or registered address +- Registration details required by the operating jurisdiction + +### Meaning (Legal Identity) + +The shop must identify the seller as a real merchant entity, not only as a brand +page with generic contact links. + +### Why (Customer Trust) + +Customers must be able to identify who is selling the goods, how to contact the +seller, and what legal/business identity stands behind the storefront. + +### Content Contract + +The seller identity block must be consistent across: + +- legal pages, +- footer or contact surfaces where applicable, +- checkout/help/customer-facing policy content. + +### Note + +The exact registration fields must match the merchant’s actual jurisdiction and +compliance requirements. Legal/business details must be verified before public +publication. + +### Out of Scope for Launch + +- Placeholder merchant identity text +- Brand-only contact presentation with no merchant details +- Publishing incomplete or unverified registration information + +--- + +## Launch Interpretation Rule + +These decisions define the launch contract. If implementation, UI, +documentation, or admin tooling conflicts with this document, this document wins +until explicitly revised. + +Any future expansion beyond this scope must be approved as a new decision and +implemented intentionally. + +--- + +## Summary of Approved Launch Decisions + +- Inventory: **product-level stock only** +- Refunds: **Stripe refund allowed; Monobank paid refund disabled; Monobank + unpaid void allowed; return-based refunds disabled** +- Guest checkout: **email required** +- Legal identity: **full merchant identity block required** + +--- + +## Change Control + +This document should be updated only when launch policy changes are explicitly +approved. Do not expand operational behavior implicitly in code without updating +this document. diff --git a/frontend/docs/shop/legal-merchant-identity-content.md b/frontend/docs/shop/legal-merchant-identity-content.md new file mode 100644 index 00000000..e804c8ba --- /dev/null +++ b/frontend/docs/shop/legal-merchant-identity-content.md @@ -0,0 +1,143 @@ +# Shop Legal Merchant Identity Content + +## Status + +Draft content package for public publication. + +## Purpose + +This document defines the minimum merchant identity content that must be +published for the Shop at launch. + +It is the source content package for: + +- seller information page/block, +- footer/legal links, +- customer-facing legal/contact surfaces. + +--- + +## Publishing Rule + +The shop must identify the seller as a real merchant entity. + +Brand-only presentation is not enough. + +Public shop surfaces must make it clear: + +- who sells the goods, +- how the customer contacts the seller, +- what merchant/legal identity stands behind the storefront. + +--- + +## Required Public Fields + +The following fields must be published at launch: + +- Merchant legal name +- Store / trading name, if different from legal name +- Support email +- Support phone +- Business or registered address +- Registration / business details required by the applicable jurisdiction + +--- + +## Canonical Public Content Block + +Use the following structure as the canonical seller block. + +### Seller Information + +**Merchant legal name:** `[INSERT LEGAL NAME]` +**Store / trading name:** `[INSERT STORE NAME IF DIFFERENT]` +**Support email:** `[INSERT SUPPORT EMAIL]` +**Support phone:** `[INSERT SUPPORT PHONE]` +**Business / registered address:** `[INSERT FULL ADDRESS]` +**Registration details:** +`[INSERT BUSINESS / TAX / REGISTRATION DETAILS REQUIRED FOR PUBLICATION]` + +--- + +## Short Public Version + +Use this shorter version only where space is limited, such as footer/legal +summaries. + +### Seller + +**Merchant:** `[INSERT LEGAL NAME OR STORE NAME]` +**Email:** `[INSERT SUPPORT EMAIL]` +**Phone:** `[INSERT SUPPORT PHONE]` +**Address:** `[INSERT SHORT ADDRESS OR FULL ADDRESS IF SPACE ALLOWS]` + +A link from this short version must lead to the full seller information surface. + +--- + +## Content Rules + +### Required + +- all published data must be real and verified +- the same merchant identity must be used consistently across legal/contact + surfaces +- seller information must be reachable from public navigation or footer/legal + area + +### Forbidden + +- placeholder merchant identity text in production +- incomplete seller identity block presented as final +- publishing unverified registration details +- replacing merchant identity with only brand/social/contact links + +--- + +## Placement Requirements + +This content must appear in at least one dedicated public seller-information +surface. + +It should also be reachable from: + +- footer legal links, +- legal/help area, +- other customer-facing shop policy surfaces where appropriate. + +--- + +## Relationship to Other Legal Pages + +This content package does not replace: + +- payment terms, +- delivery policy, +- returns policy, +- privacy policy, +- terms of service. + +It only defines the seller identity block required for merchant transparency. + +--- + +## Verification Note + +Before publication, confirm that the registration/business fields match the +actual jurisdiction and compliance obligations of the selling entity. + +Do not publish guessed or partial registration information. + +--- + +## Summary + +Launch requires a complete public seller identity block containing: + +- merchant legal name +- store/trading name if applicable +- support email +- support phone +- address +- required registration details diff --git a/frontend/docs/shop/payments-runbook.md b/frontend/docs/shop/payments-runbook.md new file mode 100644 index 00000000..1790262e --- /dev/null +++ b/frontend/docs/shop/payments-runbook.md @@ -0,0 +1,199 @@ +# Shop Payments Runbook + +## Status + +Approved for launch operations. + +## Purpose + +This runbook defines the launch-time payment reversal policy for the Shop +module. + +It is the canonical operational reference for: + +- engineering, +- admin operations, +- support, +- content/legal alignment. + +If admin UI, support instructions, or internal assumptions conflict with this +document, this document wins until explicitly revised. + +--- + +## Launch Payment Rails + +The launch payment rails are: + +- Stripe +- Monobank + +Legacy `paymentProvider='none'` is not a valid rail for new orders. + +--- + +## Canonical Launch Policy Matrix + +| Scenario | Stripe | Monobank | Returns | +| -------------------------------------- | ------------------------------------------------- | ------------------------------------------ | ------------------- | +| New paid order creation | Allowed | Allowed | Not applicable | +| New order via `paymentProvider='none'` | Forbidden | Forbidden | Not applicable | +| Unpaid payment cancellation / void | Allowed according to existing payment state rules | Allowed for unpaid invoice/admin-only path | Not applicable | +| Paid refund | Allowed | Disabled for launch | Disabled for launch | +| Return-driven refund automation | Disabled | Disabled | Disabled for launch | + +--- + +## Operational Rules + +### 1. Stripe + +Stripe is the only launch rail with paid refund support. + +Allowed: + +- normal Stripe checkout +- webhook-driven payment confirmation +- refund of eligible paid Stripe orders through the approved admin/operational + path + +Not allowed: + +- undocumented/manual refund flows outside the approved Stripe path +- assuming browser return page equals authoritative payment confirmation + +### 2. Monobank + +Monobank is supported for checkout, but with narrower reversal rules. + +Allowed: + +- normal Monobank checkout +- webhook-driven payment confirmation +- admin-only cancel/void path for unpaid invoice states when the rail logic + allows it + +Not allowed at launch: + +- paid Monobank refunds from admin +- presenting Monobank as having the same refund capability as Stripe + +### 3. Returns + +Returns are not connected to an automatic refund workflow at launch. + +Allowed: + +- documenting the returns policy for customer/legal clarity +- internal manual handling outside the productized shop refund flow, if + separately governed by business process + +Not allowed at launch: + +- automated return approval -> payment refund orchestration +- promising system-supported return refunds in public shop flows unless + separately implemented + +### 4. Legacy `paymentProvider='none'` + +Historical rows may exist. They are legacy/internal data only. + +Allowed: + +- reading historical records +- preserving historical compatibility + +Not allowed: + +- creating new customer orders through `paymentProvider='none'` +- exposing `none` as a selectable payment path +- using `none` as a fallback when configured rails are unavailable + +--- + +## Admin UI Rules + +The admin surface must follow the actual launch policy. + +Required behavior: + +- unsupported actions must not appear as available +- if shown for state explanation, unsupported actions must be explicitly + disabled +- labels/help text must not suggest unsupported refund capability + +Examples: + +- Stripe paid order: refund action may be available if all normal guardrails + pass +- Monobank paid order: refund action must be hidden or disabled +- Monobank unpaid invoice: cancel/void action may be available if state permits + +--- + +## Support / Operations Rules + +Support and operations must treat rail capability as provider-specific. + +Do not say: + +- “all paid orders can be refunded from admin” +- “Monobank works the same as Stripe for refunds” +- “return approval automatically refunds the order” + +Say instead: + +- Stripe paid refunds are supported +- Monobank paid refunds are not available in the launch admin flow +- unpaid Monobank invoice cancellation may be supported depending on + order/payment state +- return-based refund automation is not enabled at launch + +--- + +## Authoritative Payment Confirmation + +The browser return page is not the source of truth for payment success. + +Authoritative confirmation must come from: + +- persisted payment state, +- verified webhook/event processing, +- canonical order/payment state transitions. + +Public UX may show a return/success page, but that page does not override +provider-confirmed backend state. + +--- + +## Scope Boundaries + +### In Scope for Launch + +- Stripe checkout +- Monobank checkout +- Stripe refund support +- Monobank unpaid cancel/void +- explicit operational restrictions for unsupported refund paths + +### Out of Scope for Launch + +- Monobank paid refunds +- unified symmetric refund behavior across PSPs +- return-driven refund automation +- `paymentProvider='none'` as a new order rail + +--- + +## Summary + +Launch payment policy is intentionally narrow and explicit: + +- Stripe paid refunds: **enabled** +- Monobank paid refunds: **disabled** +- Monobank unpaid cancel/void: **enabled where current state allows** +- Returns-based refunds: **disabled for launch** +- `paymentProvider='none'` for new orders: **forbidden** + +This is a launch safety decision, not a statement that future support will +remain limited. From 8fc48fbba6a60a312fb91422c385af7aaf21c123 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 24 Mar 2026 10:23:44 -0700 Subject: [PATCH 2/5] (SP: 1)[SHOP] fail closed checkout on stale pricing with quote fingerprint validation --- .../app/[locale]/shop/cart/CartPageClient.tsx | 3 + frontend/app/api/shop/checkout/route.ts | 18 +- frontend/lib/cart.ts | 3 + frontend/lib/services/orders/checkout.ts | 37 ++ .../lib/services/products/cart/rehydrate.ts | 12 + frontend/lib/shop/checkout-pricing.ts | 42 +++ .../cart-rehydrate-variant-sanitize.test.ts | 2 + .../shop/checkout-currency-policy.test.ts | 26 +- .../checkout-price-change-fail-closed.test.ts | 320 ++++++++++++++++++ frontend/lib/validation/shop.ts | 7 + 10 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 frontend/lib/shop/checkout-pricing.ts create mode 100644 frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 6606fdb5..705c9c3c 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -1150,6 +1150,9 @@ export default function CartPage({ body: JSON.stringify({ paymentProvider: selectedProvider, paymentMethod: checkoutPaymentMethod, + ...(cart.summary.pricingFingerprint + ? { pricingFingerprint: cart.summary.pricingFingerprint } + : {}), ...(shippingPayloadResult?.ok ? { shipping: shippingPayloadResult.shipping, diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index f3e5f44c..a8b3654e 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -61,6 +61,7 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'INVALID_PAYLOAD', 'INVALID_VARIANT', 'INSUFFICIENT_STOCK', + 'CHECKOUT_PRICE_CHANGED', 'PRICE_CONFIG_ERROR', 'PAYMENT_ATTEMPTS_EXHAUSTED', 'MISSING_SHIPPING_ADDRESS', @@ -75,6 +76,7 @@ const DEFAULT_CHECKOUT_RATE_LIMIT_MAX = 10; const DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS = 300; const SHIPPING_ERROR_STATUS_MAP: Record = { + CHECKOUT_PRICE_CHANGED: 409, MISSING_SHIPPING_ADDRESS: 400, INVALID_SHIPPING_ADDRESS: 400, SHIPPING_METHOD_UNAVAILABLE: 422, @@ -1024,7 +1026,14 @@ export async function POST(request: NextRequest) { ); } - const { items, userId, shipping, country, legalConsent } = parsedPayload.data; + const { + items, + userId, + shipping, + country, + legalConsent, + pricingFingerprint, + } = parsedPayload.data; const itemCount = items.reduce((total, item) => total + item.quantity, 0); let currentUser: unknown = null; @@ -1149,6 +1158,8 @@ export async function POST(request: NextRequest) { country: country ?? null, shipping: shipping ?? null, legalConsent: legalConsent ?? null, + pricingFingerprint, + requirePricingFingerprint: true, paymentProvider: 'stripe', paymentMethod: selectedMethod, }); @@ -1173,6 +1184,8 @@ export async function POST(request: NextRequest) { country: country ?? null, shipping: shipping ?? null, legalConsent: legalConsent ?? null, + pricingFingerprint, + requirePricingFingerprint: true, paymentProvider: resolvedProvider, paymentMethod: selectedMethod, })); @@ -1588,7 +1601,8 @@ export async function POST(request: NextRequest) { return errorResponse( error.code, error.message || 'Invalid checkout payload', - customStatus ?? 400 + customStatus ?? 400, + error.details ); } diff --git a/frontend/lib/cart.ts b/frontend/lib/cart.ts index 7de09152..f7f166cb 100644 --- a/frontend/lib/cart.ts +++ b/frontend/lib/cart.ts @@ -29,6 +29,7 @@ export const emptyCart: Cart = { totalAmount: 0, itemCount: 0, currency: 'USD', + pricingFingerprint: undefined, }, }; @@ -174,6 +175,7 @@ export function computeSummaryFromItems( totalAmount: 0, itemCount: 0, currency: 'USD', + pricingFingerprint: undefined, }; } @@ -198,6 +200,7 @@ export function computeSummaryFromItems( totalAmount: fromCents(totalMinor), itemCount, currency, + pricingFingerprint: undefined, }; } diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index 656b30f1..c1020530 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -17,6 +17,7 @@ import { getShopLegalVersions } from '@/lib/env/shop-legal'; import { logError, logWarn } from '@/lib/logging'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { createCheckoutPricingFingerprint } from '@/lib/shop/checkout-pricing'; import { localeToCountry } from '@/lib/shop/locale'; import { calculateLineTotal, @@ -859,6 +860,8 @@ export async function createOrderWithItems({ country, shipping, legalConsent, + pricingFingerprint, + requirePricingFingerprint = false, paymentProvider: requestedProvider, paymentMethod: requestedMethod, }: { @@ -869,6 +872,8 @@ export async function createOrderWithItems({ country?: string | null; shipping?: CheckoutShippingInput | null; legalConsent?: CheckoutLegalConsentInput | null; + pricingFingerprint?: string | null; + requirePricingFingerprint?: boolean; paymentProvider?: PaymentProvider; paymentMethod?: PaymentMethod | null; }): Promise { @@ -1228,6 +1233,38 @@ export async function createOrderWithItems({ } const pricedItems = priceItems(normalizedItems, productMap, currency); + const authoritativePricingFingerprint = createCheckoutPricingFingerprint({ + currency, + items: pricedItems.map(item => ({ + productId: item.productId, + quantity: item.quantity, + unitPriceMinor: item.unitPriceCents, + selectedSize: item.selectedSize, + selectedColor: item.selectedColor, + })), + }); + + if (requirePricingFingerprint) { + const normalizedPricingFingerprint = pricingFingerprint?.trim() ?? ''; + + if ( + !normalizedPricingFingerprint || + normalizedPricingFingerprint !== authoritativePricingFingerprint + ) { + throw new InvalidPayloadError( + 'Cart pricing changed. Refresh your cart and try again.', + { + code: 'CHECKOUT_PRICE_CHANGED', + details: { + reason: normalizedPricingFingerprint + ? 'PRICING_FINGERPRINT_MISMATCH' + : 'PRICING_FINGERPRINT_MISSING', + }, + } + ); + } + } + const orderTotalCents = sumLineTotals(pricedItems.map(i => i.lineTotalCents)); let orderId: string; diff --git a/frontend/lib/services/products/cart/rehydrate.ts b/frontend/lib/services/products/cart/rehydrate.ts index e36e861b..b9e2cee9 100644 --- a/frontend/lib/services/products/cart/rehydrate.ts +++ b/frontend/lib/services/products/cart/rehydrate.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import { coercePriceFromDb } from '@/db/queries/shop/orders'; import { productPrices, products } from '@/db/schema'; import { logWarn } from '@/lib/logging'; +import { createCheckoutPricingFingerprint } from '@/lib/shop/checkout-pricing'; import { createCartItemKey } from '@/lib/shop/cart-item-key'; import { type CurrencyCode, isTwoDecimalCurrency } from '@/lib/shop/currency'; import { calculateLineTotal, fromCents, toCents } from '@/lib/shop/money'; @@ -296,6 +297,16 @@ export async function rehydrateCartItems( } const itemCount = rehydratedItems.reduce((total, i) => total + i.quantity, 0); + const pricingFingerprint = createCheckoutPricingFingerprint({ + currency, + items: rehydratedItems.map(item => ({ + productId: item.productId, + quantity: item.quantity, + unitPriceMinor: item.unitPriceMinor, + selectedSize: item.selectedSize, + selectedColor: item.selectedColor, + })), + }); return cartRehydrateResultSchema.parse({ items: rehydratedItems, @@ -305,6 +316,7 @@ export async function rehydrateCartItems( totalAmount: fromMinorUnits(totalMinor), itemCount, currency, + pricingFingerprint, }, }); } diff --git a/frontend/lib/shop/checkout-pricing.ts b/frontend/lib/shop/checkout-pricing.ts new file mode 100644 index 00000000..347eb0a6 --- /dev/null +++ b/frontend/lib/shop/checkout-pricing.ts @@ -0,0 +1,42 @@ +import crypto from 'node:crypto'; + +import type { CurrencyCode } from '@/lib/shop/currency'; + +type CheckoutPricingFingerprintItem = { + productId: string; + quantity: number; + unitPriceMinor: number; + selectedSize?: string | null; + selectedColor?: string | null; +}; + +function normalizeVariant(value: string | null | undefined): string { + return (value ?? '').trim(); +} + +export function createCheckoutPricingFingerprint(args: { + currency: CurrencyCode; + items: CheckoutPricingFingerprintItem[]; +}): string { + const normalizedItems = [...args.items] + .map(item => ({ + productId: item.productId, + quantity: item.quantity, + unitPriceMinor: item.unitPriceMinor, + selectedSize: normalizeVariant(item.selectedSize), + selectedColor: normalizeVariant(item.selectedColor), + })) + .sort((a, b) => { + const aKey = JSON.stringify(a); + const bKey = JSON.stringify(b); + return aKey.localeCompare(bKey); + }); + + const payload = JSON.stringify({ + v: 1, + currency: args.currency, + items: normalizedItems, + }); + + return crypto.createHash('sha256').update(payload).digest('hex'); +} diff --git a/frontend/lib/tests/shop/cart-rehydrate-variant-sanitize.test.ts b/frontend/lib/tests/shop/cart-rehydrate-variant-sanitize.test.ts index a3a356f3..105395d3 100644 --- a/frontend/lib/tests/shop/cart-rehydrate-variant-sanitize.test.ts +++ b/frontend/lib/tests/shop/cart-rehydrate-variant-sanitize.test.ts @@ -73,6 +73,7 @@ describe('cart rehydrate: variant sanitization', () => { expect(result.items[0]!.selectedSize).toBeUndefined(); expect(result.items[0]!.selectedColor).toBe('black'); expect(result.summary.totalAmountMinor).toBe(3000); + expect(result.summary.pricingFingerprint).toMatch(/^[a-f0-9]{64}$/); }); it('drops invalid selectedColor and merges lines after sanitization', async () => { @@ -98,5 +99,6 @@ describe('cart rehydrate: variant sanitization', () => { expect(result.items[0]!.selectedSize).toBe('S'); expect(result.items[0]!.selectedColor).toBeUndefined(); expect(result.summary.totalAmountMinor).toBe(3000); + expect(result.summary.pricingFingerprint).toMatch(/^[a-f0-9]{64}$/); }); }); diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index 06536548..b429c499 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -16,6 +16,7 @@ import { products, } from '@/db/schema'; import { resetEnvCache } from '@/lib/env'; +import { rehydrateCartItems } from '@/lib/services/products'; vi.mock('@/lib/auth', async () => { const actual = @@ -145,7 +146,7 @@ function makeTestClientIp(seed: string): string { return `${(digest[0] % 223) + 1}.${digest[1]}.${digest[2]}.${(digest[3] % 254) + 1}`; } -function makeCheckoutRequest( +async function makeCheckoutRequest( payload: unknown, opts: { idempotencyKey: string; acceptLanguage: string } ) { @@ -157,11 +158,26 @@ function makeCheckoutRequest( Origin: 'http://localhost:3000', }); + const body = + payload && typeof payload === 'object' && !Array.isArray(payload) + ? ({ ...(payload as Record) } as Record) + : {}; + const items = Array.isArray(body.items) ? body.items : []; + const currency = + opts.acceptLanguage.trim().toLowerCase().startsWith('uk') ? 'UAH' : 'USD'; + + if (items.length > 0) { + try { + const quote = await rehydrateCartItems(items as any, currency); + body.pricingFingerprint = quote.summary.pricingFingerprint; + } catch {} + } + return new NextRequest( new Request('http://localhost/api/shop/checkout', { method: 'POST', headers, - body: JSON.stringify(payload), + body: JSON.stringify(body), }) ); } @@ -236,7 +252,7 @@ describe('P0-CUR-3 checkout currency policy', () => { ], }); - const req = makeCheckoutRequest( + const req = await makeCheckoutRequest( { paymentProvider: 'stripe', paymentMethod: 'stripe_card', @@ -268,7 +284,7 @@ describe('P0-CUR-3 checkout currency policy', () => { ], }); - const req = makeCheckoutRequest( + const req = await makeCheckoutRequest( { paymentProvider: 'stripe', paymentMethod: 'stripe_card', @@ -297,7 +313,7 @@ describe('P0-CUR-3 checkout currency policy', () => { prices: [{ currency: 'USD', priceMinor: 6700, price: '67.00' }], // no UAH row }); - const req = makeCheckoutRequest( + const req = await makeCheckoutRequest( { paymentProvider: 'monobank', paymentMethod: 'monobank_invoice', diff --git a/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts b/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts new file mode 100644 index 00000000..7117946a --- /dev/null +++ b/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts @@ -0,0 +1,320 @@ +import crypto from 'node:crypto'; + +import { and, eq, inArray } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { + inventoryMoves, + orderItems, + orders, + productPrices, + products, +} from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { rehydrateCartItems } from '@/lib/services/products'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +vi.mock('@/lib/auth', async () => { + const actual = + await vi.importActual('@/lib/auth'); + return { + ...actual, + getCurrentUser: async () => null, + }; +}); + +vi.mock('@/lib/env/stripe', () => ({ + isPaymentsEnabled: () => true, +})); + +vi.mock('@/lib/services/orders/payment-attempts', async () => { + resetEnvCache(); + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' + ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn( + async (args: { orderId: string }) => ({ + paymentIntentId: `pi_test_${args.orderId.slice(0, 8)}`, + clientSecret: `cs_test_${args.orderId.slice(0, 8)}`, + attemptId: crypto.randomUUID(), + attemptNumber: 1, + }) + ), + }; +}); + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevStripePaymentsEnabled = process.env.STRIPE_PAYMENTS_ENABLED; +const __prevStripeSecret = process.env.STRIPE_SECRET_KEY; +const __prevStripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; +const __prevStripePublishableKey = + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; +const __prevAppOrigin = process.env.APP_ORIGIN; + +let POST: (req: NextRequest) => Promise; + +const createdProductIds: string[] = []; +const createdOrderIds: string[] = []; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_PAYMENTS_ENABLED = 'true'; + process.env.STRIPE_SECRET_KEY = 'sk_test_checkout_price_change'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_checkout_price_change'; + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = + 'pk_test_checkout_price_change'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + resetEnvCache(); +}); + +beforeAll(async () => { + const mod = await import('@/app/api/shop/checkout/route'); + POST = mod.POST; +}); + +afterAll(async () => { + if (createdOrderIds.length) { + await db + .delete(inventoryMoves) + .where(inArray(inventoryMoves.orderId, createdOrderIds)); + await db + .delete(orderItems) + .where(inArray(orderItems.orderId, createdOrderIds)); + await db.delete(orders).where(inArray(orders.id, createdOrderIds)); + } + + if (createdProductIds.length) { + await db + .delete(inventoryMoves) + .where(inArray(inventoryMoves.productId, createdProductIds)); + await db + .delete(orderItems) + .where(inArray(orderItems.productId, createdProductIds)); + await db + .delete(productPrices) + .where(inArray(productPrices.productId, createdProductIds)); + await db.delete(products).where(inArray(products.id, createdProductIds)); + } + + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevStripePaymentsEnabled === undefined) + delete process.env.STRIPE_PAYMENTS_ENABLED; + else process.env.STRIPE_PAYMENTS_ENABLED = __prevStripePaymentsEnabled; + + if (__prevStripeSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = __prevStripeSecret; + + if (__prevStripeWebhookSecret === undefined) + delete process.env.STRIPE_WEBHOOK_SECRET; + else process.env.STRIPE_WEBHOOK_SECRET = __prevStripeWebhookSecret; + + if (__prevStripePublishableKey === undefined) + delete process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + else + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = __prevStripePublishableKey; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + resetEnvCache(); +}); + +async function seedProduct(priceMinor: number): Promise { + const slug = `checkout-price-change-${crypto.randomUUID()}`; + + const [product] = await db + .insert(products) + .values({ + slug, + title: 'Checkout price change test product', + description: null, + imageUrl: 'https://example.com/checkout-price-change.png', + imagePublicId: null, + price: '9.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 10, + sku: null, + }) + .returning({ id: products.id }); + + if (!product) throw new Error('seedProduct: failed to insert product'); + + await db.insert(productPrices).values({ + productId: product.id, + currency: 'USD', + priceMinor, + originalPriceMinor: null, + price: (priceMinor / 100).toFixed(2), + originalPrice: null, + }); + + createdProductIds.push(product.id); + return product.id; +} + +function makeCheckoutRequest(args: { + idempotencyKey: string; + productId: string; + pricingFingerprint: string; +}) { + return new NextRequest( + new Request('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': args.idempotencyKey, + 'Accept-Language': 'en-US,en;q=0.9', + 'X-Forwarded-For': deriveTestIpFromIdemKey(args.idempotencyKey), + Origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + pricingFingerprint: args.pricingFingerprint, + items: [{ productId: args.productId, quantity: 1 }], + }), + }) + ); +} + +describe('checkout fail-closed for changed price mismatch', () => { + it('rejects checkout when pricing fingerprint is missing and creates no order', async () => { + const productId = await seedProduct(900); + const idempotencyKey = crypto.randomUUID(); + + const response = await POST( + new NextRequest( + new Request('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + 'Accept-Language': 'en-US,en;q=0.9', + 'X-Forwarded-For': deriveTestIpFromIdemKey(idempotencyKey), + Origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + items: [{ productId, quantity: 1 }], + }), + }) + ) + ); + + expect(response.status).toBe(409); + const json = await response.json(); + expect(json.code).toBe('CHECKOUT_PRICE_CHANGED'); + expect(json.message).toBe( + 'Cart pricing changed. Refresh your cart and try again.' + ); + expect(json.details?.reason).toBe('PRICING_FINGERPRINT_MISSING'); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + }); + + it('rejects a stale pricing fingerprint after price change and creates no order', async () => { + const productId = await seedProduct(900); + const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'USD'); + const pricingFingerprint = quote.summary.pricingFingerprint; + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + await db + .update(productPrices) + .set({ + priceMinor: 1200, + price: '12.00', + updatedAt: new Date(), + }) + .where( + and( + eq(productPrices.productId, productId), + eq(productPrices.currency, 'USD') + ) + ); + + const idempotencyKey = crypto.randomUUID(); + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId, + pricingFingerprint: pricingFingerprint!, + }) + ); + + expect(response.status).toBe(409); + const json = await response.json(); + expect(json.code).toBe('CHECKOUT_PRICE_CHANGED'); + expect(json.message).toBe( + 'Cart pricing changed. Refresh your cart and try again.' + ); + expect(json.details?.reason).toBe('PRICING_FINGERPRINT_MISMATCH'); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + }); + + it('accepts checkout when the authoritative pricing fingerprint is unchanged', async () => { + const productId = await seedProduct(900); + const quote = await rehydrateCartItems([{ productId, quantity: 1 }], 'USD'); + const pricingFingerprint = quote.summary.pricingFingerprint; + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + const idempotencyKey = crypto.randomUUID(); + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId, + pricingFingerprint: pricingFingerprint!, + }) + ); + + expect(response.status).toBe(201); + const json = await response.json(); + expect(json.success).toBe(true); + expect(json.order?.totalAmount).toBe(9); + + const orderId = + typeof json.order?.id === 'string' ? String(json.order.id) : null; + expect(orderId).toBeTruthy(); + + if (orderId) { + createdOrderIds.push(orderId); + } + }); +}); diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 5dd50ac6..67469414 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -405,6 +405,11 @@ export const checkoutLegalConsentSchema = z .strict(); const checkoutRequestedProviderSchema = z.enum(['stripe', 'monobank']); +const pricingFingerprintSchema = z + .string() + .trim() + .length(64) + .regex(/^[a-f0-9]{64}$/); export const checkoutPayloadSchema = z .object({ @@ -418,6 +423,7 @@ export const checkoutPayloadSchema = z .optional(), shipping: checkoutShippingSchema.optional(), legalConsent: checkoutLegalConsentSchema.optional(), + pricingFingerprint: pricingFingerprintSchema.optional(), paymentProvider: checkoutRequestedProviderSchema.optional(), paymentMethod: paymentMethodSchema.optional(), paymentCurrency: currencySchema.optional(), @@ -534,6 +540,7 @@ export const cartRehydrateResultSchema = z.object({ totalAmount: z.number().min(0), itemCount: z.number().int().min(0), currency: currencySchema, + pricingFingerprint: pricingFingerprintSchema.optional(), }), }); From 64c2919da3d293a278990a80ebcc13078b3654a9 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 24 Mar 2026 11:03:53 -0700 Subject: [PATCH 3/5] (SP: 2)[SHOP] make checkout totals include authoritative shipping fail-closed --- .../app/[locale]/shop/cart/CartPageClient.tsx | 104 +++- frontend/app/api/shop/checkout/route.ts | 9 + .../app/api/shop/shipping/methods/route.ts | 44 +- frontend/lib/services/orders/checkout.ts | 251 +++------ .../lib/services/products/cart/rehydrate.ts | 2 +- .../services/shop/shipping/checkout-quote.ts | 94 ++++ .../shop/checkout-currency-policy.test.ts | 5 +- ...ckout-shipping-authoritative-total.test.ts | 483 ++++++++++++++++++ .../shop/checkout-shipping-phase3.test.ts | 16 +- .../shop/shipping-methods-route-p2.test.ts | 55 ++ frontend/lib/validation/shop.ts | 1 + 11 files changed, 861 insertions(+), 203 deletions(-) create mode 100644 frontend/lib/services/shop/shipping/checkout-quote.ts create mode 100644 frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 705c9c3c..d9a0e139 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -63,6 +63,8 @@ type ShippingMethod = { provider: 'nova_poshta'; methodCode: CheckoutDeliveryMethodCode; title: string; + amountMinor: number; + quoteFingerprint: string; }; type ShippingCity = { @@ -161,6 +163,57 @@ function normalizeShippingCity(raw: unknown): ShippingCity | null { nameUa, }; } + +function readTrimmedNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeShippingMethod(raw: unknown): ShippingMethod | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + + const item = raw as Record; + if (item.provider !== 'nova_poshta') { + return null; + } + + const methodCode = readTrimmedNonEmptyString(item.methodCode); + const title = readTrimmedNonEmptyString(item.title); + const quoteFingerprint = readTrimmedNonEmptyString(item.quoteFingerprint); + const amountMinor = item.amountMinor; + + if (!methodCode || !isValidDeliveryMethodCode(methodCode)) { + return null; + } + + if (!title) { + return null; + } + + if ( + typeof amountMinor !== 'number' || + !Number.isInteger(amountMinor) || + amountMinor < 0 + ) { + return null; + } + + if (!quoteFingerprint || !/^[a-f0-9]{64}$/.test(quoteFingerprint)) { + return null; + } + + return { + provider: 'nova_poshta', + methodCode, + title, + amountMinor, + quoteFingerprint, + }; +} + function normalizeShippingWarehouse(raw: unknown): ShippingWarehouse | null { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return null; @@ -454,6 +507,13 @@ export default function CartPage({ shippingReasonCode === 'COUNTRY_NOT_SUPPORTED' || shippingReasonCode === 'CURRENCY_NOT_SUPPORTED' || shippingReasonCode === 'INTERNAL_ERROR'; + const selectedShippingQuote = + shippingMethods.find( + method => method.methodCode === selectedShippingMethod + ) ?? null; + const checkoutSummaryShippingMinor = selectedShippingQuote?.amountMinor ?? 0; + const checkoutSummaryTotalMinor = + cart.summary.totalAmountMinor + checkoutSummaryShippingMinor; const isWarehouseSelectionMethod = isWarehouseMethod(selectedShippingMethod); @@ -636,30 +696,14 @@ export default function CartPage({ const methods: ShippingMethod[] = []; for (const item of methodsRaw) { - if (!item || typeof item !== 'object' || Array.isArray(item)) { - hardBlock(); - return; - } + const method = normalizeShippingMethod(item); - const m = item as Record; - - const providerOk = m.provider === 'nova_poshta'; - const methodCode = - typeof m.methodCode === 'string' ? m.methodCode.trim() : ''; - const methodCodeOk = isValidDeliveryMethodCode(methodCode); - const titleOk = - typeof m.title === 'string' && m.title.trim().length > 0; - - if (!providerOk || !methodCodeOk || !titleOk) { + if (!method) { hardBlock(); return; } - methods.push({ - provider: 'nova_poshta', - methodCode, - title: String(m.title), - }); + methods.push(method); } if (available === false && reasonCode == null) { @@ -1153,6 +1197,12 @@ export default function CartPage({ ...(cart.summary.pricingFingerprint ? { pricingFingerprint: cart.summary.pricingFingerprint } : {}), + ...(selectedShippingQuote?.quoteFingerprint + ? { + shippingQuoteFingerprint: + selectedShippingQuote.quoteFingerprint, + } + : {}), ...(shippingPayloadResult?.ok ? { shipping: shippingPayloadResult.shipping, @@ -2241,9 +2291,15 @@ export default function CartPage({ - {t('summary.shippingInformationalOnly')} + {selectedShippingQuote + ? formatMoney( + checkoutSummaryShippingMinor, + cart.summary.currency, + locale + ) + : t('summary.shippingCalc')} @@ -2259,7 +2315,7 @@ export default function CartPage({ className="text-foreground text-2xl font-bold" > {formatMoney( - cart.summary.totalAmountMinor, + checkoutSummaryTotalMinor, cart.summary.currency, locale )} @@ -2312,10 +2368,6 @@ export default function CartPage({ -

- {t('summary.shippingPayOnDeliveryNote')} -

- {recoveryHref && !checkoutError ? (
{recoveryIsExternal ? ( diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index a8b3654e..9bf0fcfc 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -62,12 +62,14 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'INVALID_VARIANT', 'INSUFFICIENT_STOCK', 'CHECKOUT_PRICE_CHANGED', + 'CHECKOUT_SHIPPING_CHANGED', 'PRICE_CONFIG_ERROR', 'PAYMENT_ATTEMPTS_EXHAUSTED', 'MISSING_SHIPPING_ADDRESS', 'INVALID_SHIPPING_ADDRESS', 'SHIPPING_METHOD_UNAVAILABLE', 'SHIPPING_CURRENCY_UNSUPPORTED', + 'SHIPPING_AMOUNT_UNAVAILABLE', 'TERMS_NOT_ACCEPTED', 'PRIVACY_NOT_ACCEPTED', ]); @@ -77,10 +79,12 @@ const DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS = 300; const SHIPPING_ERROR_STATUS_MAP: Record = { CHECKOUT_PRICE_CHANGED: 409, + CHECKOUT_SHIPPING_CHANGED: 409, MISSING_SHIPPING_ADDRESS: 400, INVALID_SHIPPING_ADDRESS: 400, SHIPPING_METHOD_UNAVAILABLE: 422, SHIPPING_CURRENCY_UNSUPPORTED: 422, + SHIPPING_AMOUNT_UNAVAILABLE: 422, }; const STATUS_TOKEN_SCOPES_STATUS_ONLY: readonly StatusTokenScope[] = [ @@ -1033,6 +1037,7 @@ export async function POST(request: NextRequest) { country, legalConsent, pricingFingerprint, + shippingQuoteFingerprint, } = parsedPayload.data; const itemCount = items.reduce((total, item) => total + item.quantity, 0); @@ -1159,7 +1164,9 @@ export async function POST(request: NextRequest) { shipping: shipping ?? null, legalConsent: legalConsent ?? null, pricingFingerprint, + shippingQuoteFingerprint, requirePricingFingerprint: true, + requireShippingQuoteFingerprint: true, paymentProvider: 'stripe', paymentMethod: selectedMethod, }); @@ -1185,7 +1192,9 @@ export async function POST(request: NextRequest) { shipping: shipping ?? null, legalConsent: legalConsent ?? null, pricingFingerprint, + shippingQuoteFingerprint, requirePricingFingerprint: true, + requireShippingQuoteFingerprint: true, paymentProvider: resolvedProvider, paymentMethod: selectedMethod, })); diff --git a/frontend/app/api/shop/shipping/methods/route.ts b/frontend/app/api/shop/shipping/methods/route.ts index 23dfa333..6f86dfa4 100644 --- a/frontend/app/api/shop/shipping/methods/route.ts +++ b/frontend/app/api/shop/shipping/methods/route.ts @@ -11,6 +11,10 @@ import { rateLimitResponse, } from '@/lib/security/rate-limit'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; +import { + isCheckoutShippingQuoteCurrency, + resolveCheckoutShippingQuote, +} from '@/lib/services/shop/shipping/checkout-quote'; import { sanitizeShippingErrorForLog, sanitizeShippingLogMeta, @@ -27,6 +31,8 @@ type ShippingMethod = { provider: 'nova_poshta'; methodCode: 'NP_WAREHOUSE' | 'NP_LOCKER' | 'NP_COURIER'; title: string; + amountMinor: number; + quoteFingerprint: string; requiredFields: Array< | 'cityRef' | 'warehouseRef' @@ -58,12 +64,27 @@ function parseQuery(request: NextRequest) { return shippingMethodsQuerySchema.safeParse(raw); } -function getMethods(): ShippingMethod[] { +function getMethods(currency: 'UAH'): ShippingMethod[] { + const warehouseQuote = resolveCheckoutShippingQuote({ + methodCode: 'NP_WAREHOUSE', + currency, + }); + const lockerQuote = resolveCheckoutShippingQuote({ + methodCode: 'NP_LOCKER', + currency, + }); + const courierQuote = resolveCheckoutShippingQuote({ + methodCode: 'NP_COURIER', + currency, + }); + return [ { provider: 'nova_poshta', methodCode: 'NP_WAREHOUSE', title: 'Nova Poshta warehouse', + amountMinor: warehouseQuote.amountMinor, + quoteFingerprint: warehouseQuote.quoteFingerprint, requiredFields: [ 'cityRef', 'warehouseRef', @@ -75,6 +96,8 @@ function getMethods(): ShippingMethod[] { provider: 'nova_poshta', methodCode: 'NP_LOCKER', title: 'Nova Poshta parcel locker', + amountMinor: lockerQuote.amountMinor, + quoteFingerprint: lockerQuote.quoteFingerprint, requiredFields: [ 'cityRef', 'warehouseRef', @@ -86,6 +109,8 @@ function getMethods(): ShippingMethod[] { provider: 'nova_poshta', methodCode: 'NP_COURIER', title: 'Nova Poshta courier', + amountMinor: courierQuote.amountMinor, + quoteFingerprint: courierQuote.quoteFingerprint, requiredFields: [ 'cityRef', 'addressLine1', @@ -168,6 +193,21 @@ export async function GET(request: NextRequest) { ); } + if (!isCheckoutShippingQuoteCurrency(availability.normalized.currency)) { + return cachedJson( + { + success: true, + available: false, + reasonCode: 'CURRENCY_NOT_SUPPORTED', + locale: availability.normalized.locale, + country: availability.normalized.country, + currency: availability.normalized.currency, + methods: [], + }, + requestId + ); + } + return cachedJson( { success: true, @@ -176,7 +216,7 @@ export async function GET(request: NextRequest) { locale: availability.normalized.locale, country: availability.normalized.country, currency: availability.normalized.currency, - methods: getMethods(), + methods: getMethods(availability.normalized.currency), }, requestId ); diff --git a/frontend/lib/services/orders/checkout.ts b/frontend/lib/services/orders/checkout.ts index c1020530..e78f1912 100644 --- a/frontend/lib/services/orders/checkout.ts +++ b/frontend/lib/services/orders/checkout.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, ne, sql } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '@/db'; import { coercePriceFromDb } from '@/db/queries/shop/orders'; @@ -16,8 +16,14 @@ import { getShopShippingFlags } from '@/lib/env/nova-poshta'; import { getShopLegalVersions } from '@/lib/env/shop-legal'; import { logError, logWarn } from '@/lib/logging'; import { resolveShippingAvailability } from '@/lib/services/shop/shipping/availability'; -import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; +import { + type CheckoutShippingQuote, + CheckoutShippingQuoteConfigError, + isCheckoutShippingQuoteCurrency, + resolveCheckoutShippingQuote, +} from '@/lib/services/shop/shipping/checkout-quote'; import { createCheckoutPricingFingerprint } from '@/lib/shop/checkout-pricing'; +import { resolveCurrencyFromLocale } from '@/lib/shop/currency'; import { localeToCountry } from '@/lib/shop/locale'; import { calculateLineTotal, @@ -65,172 +71,6 @@ import { import { guardedPaymentStatusUpdate } from './payment-state'; import { restockOrder } from './restock'; import { getOrderById, getOrderByIdempotencyKey } from './summary'; - -async function reconcileNoPaymentOrder( - orderId: string -): Promise { - const [row] = await db - .select({ - id: orders.id, - paymentStatus: orders.paymentStatus, - paymentProvider: orders.paymentProvider, - paymentIntentId: orders.paymentIntentId, - inventoryStatus: orders.inventoryStatus, - stockRestored: orders.stockRestored, - restockedAt: orders.restockedAt, - failureCode: orders.failureCode, - failureMessage: orders.failureMessage, - }) - .from(orders) - .where(eq(orders.id, orderId)) - .limit(1); - - if (!row) throw new OrderNotFoundError('Order not found'); - - const provider = resolvePaymentProvider({ - paymentProvider: row.paymentProvider, - paymentIntentId: row.paymentIntentId, - paymentStatus: row.paymentStatus as PaymentStatus, - }); - - if (provider !== 'none') return getOrderById(orderId); - - if (row.paymentIntentId) { - throw new OrderStateInvalidError( - `Order ${orderId} is inconsistent: paymentProvider=none but paymentIntentId is set`, - { orderId } - ); - } - - if (row.inventoryStatus === 'reserved') { - return getOrderById(orderId); - } - - if (row.inventoryStatus === 'release_pending') { - try { - await restockOrder(orderId, { - reason: 'failed', - workerId: 'reconcileNoPaymentOrder', - }); - } catch (restockErr) { - logError( - `[reconcileNoPaymentOrder] restock failed orderId=${orderId}`, - restockErr - ); - } - - throw new InsufficientStockError( - row.failureMessage ?? 'Order cannot be completed (release pending).' - ); - } - if ( - row.inventoryStatus === 'released' || - row.stockRestored || - row.restockedAt !== null - ) { - throw new InsufficientStockError( - 'Order cannot be completed (stock restored).' - ); - } - - const items = await db - .select({ - productId: orderItems.productId, - quantity: orderItems.quantity, - }) - .from(orderItems) - .where(eq(orderItems.orderId, orderId)); - - if (!items.length) { - throw new InvalidPayloadError('Order has no items.'); - } - - const now = new Date(); - await db - .update(orders) - .set({ inventoryStatus: 'reserving', updatedAt: now }) - .where( - and( - eq(orders.id, orderId), - ne(orders.inventoryStatus, 'reserved'), - ne(orders.inventoryStatus, 'released') - ) - ); - - const itemsToReserve = aggregateReserveByProductId(items); - - try { - for (const item of itemsToReserve) { - const res = await applyReserveMove( - orderId, - item.productId, - item.quantity - ); - if (!res.ok) { - throw new InsufficientStockError( - `Insufficient stock for product ${item.productId}` - ); - } - } - - await db - .update(orders) - .set({ - status: 'PAID', - inventoryStatus: 'reserved', - failureCode: null, - failureMessage: null, - updatedAt: new Date(), - }) - .where(eq(orders.id, orderId)); - - const payRes = await guardedPaymentStatusUpdate({ - orderId, - paymentProvider: 'none', - to: 'paid', - source: 'checkout', - }); - - if (!payRes.applied && payRes.reason !== 'ALREADY_IN_STATE') { - throw new OrderStateInvalidError( - 'Order paymentStatus transition blocked after reservation.', - { orderId, details: { reason: payRes.reason, from: payRes.from } } - ); - } - - return getOrderById(orderId); - } catch (e) { - const failAt = new Date(); - const isOos = e instanceof InsufficientStockError; - - await db - .update(orders) - .set({ - status: 'INVENTORY_FAILED', - inventoryStatus: 'release_pending', - failureCode: isOos ? 'OUT_OF_STOCK' : 'INTERNAL_ERROR', - failureMessage: isOos - ? e.message - : 'Checkout failed after reservation attempt.', - updatedAt: failAt, - }) - .where(eq(orders.id, orderId)); - - try { - await restockOrder(orderId, { - reason: 'failed', - workerId: 'reconcileNoPaymentOrder', - }); - } catch (restockErr) { - logError( - `[reconcileNoPaymentOrder] restock failed orderId=${orderId}`, - restockErr - ); - } - - throw e; - } -} export async function findExistingCheckoutOrderByIdempotencyKey( idempotencyKey: string ): Promise { @@ -408,6 +248,8 @@ async function prepareCheckoutShipping(args: { locale: string | null | undefined; country: string | null | undefined; currency: Currency; + shippingQuoteFingerprint?: string | null; + requireShippingQuoteFingerprint?: boolean; }): Promise { const flags = getShopShippingFlags(); const shippingFeatureEnabled = flags.shippingEnabled && flags.npEnabled; @@ -547,9 +389,63 @@ async function prepareCheckoutShipping(args: { }); } + if (!isCheckoutShippingQuoteCurrency(args.currency)) { + throw new InvalidPayloadError( + 'Shipping is available only for UAH currency.', + { + code: 'SHIPPING_CURRENCY_UNSUPPORTED', + } + ); + } + + let authoritativeQuote: CheckoutShippingQuote; + try { + authoritativeQuote = resolveCheckoutShippingQuote({ + methodCode, + currency: args.currency, + }); + } catch (error) { + if (error instanceof CheckoutShippingQuoteConfigError) { + throw new InvalidPayloadError( + 'Shipping amount is unavailable. Refresh your cart and try again.', + { + code: 'SHIPPING_AMOUNT_UNAVAILABLE', + } + ); + } + throw error; + } + + if (args.requireShippingQuoteFingerprint) { + const normalizedShippingQuoteFingerprint = + args.shippingQuoteFingerprint?.trim() ?? ''; + + if ( + !normalizedShippingQuoteFingerprint || + normalizedShippingQuoteFingerprint !== authoritativeQuote.quoteFingerprint + ) { + throw new InvalidPayloadError( + 'Shipping amount changed. Refresh your cart and try again.', + { + code: 'CHECKOUT_SHIPPING_CHANGED', + details: { + reason: normalizedShippingQuoteFingerprint + ? 'SHIPPING_QUOTE_FINGERPRINT_MISMATCH' + : 'SHIPPING_QUOTE_FINGERPRINT_MISSING', + }, + } + ); + } + } + const snapshot: Record = { provider: 'nova_poshta', methodCode, + quote: { + currency: authoritativeQuote.currency, + amountMinor: authoritativeQuote.amountMinor, + quoteFingerprint: authoritativeQuote.quoteFingerprint, + }, selection: { cityRef, cityNameUa: city.nameUa, @@ -583,7 +479,7 @@ async function prepareCheckoutShipping(args: { shippingPayer: 'customer', shippingProvider: 'nova_poshta', shippingMethodCode: methodCode, - shippingAmountMinor: null, + shippingAmountMinor: authoritativeQuote.amountMinor, shippingStatus: 'pending', }, snapshot, @@ -862,6 +758,8 @@ export async function createOrderWithItems({ legalConsent, pricingFingerprint, requirePricingFingerprint = false, + shippingQuoteFingerprint, + requireShippingQuoteFingerprint = false, paymentProvider: requestedProvider, paymentMethod: requestedMethod, }: { @@ -874,6 +772,8 @@ export async function createOrderWithItems({ legalConsent?: CheckoutLegalConsentInput | null; pricingFingerprint?: string | null; requirePricingFingerprint?: boolean; + shippingQuoteFingerprint?: string | null; + requireShippingQuoteFingerprint?: boolean; paymentProvider?: PaymentProvider; paymentMethod?: PaymentMethod | null; }): Promise { @@ -913,6 +813,8 @@ export async function createOrderWithItems({ locale, country: country ?? null, currency, + shippingQuoteFingerprint, + requireShippingQuoteFingerprint, }); const preparedLegalConsent = resolveCheckoutLegalConsent({ legalConsent: legalConsent ?? null, @@ -1265,7 +1167,15 @@ export async function createOrderWithItems({ } } - const orderTotalCents = sumLineTotals(pricedItems.map(i => i.lineTotalCents)); + const itemsSubtotalCents = sumLineTotals( + pricedItems.map(i => i.lineTotalCents) + ); + const shippingAmountCents = + preparedShipping.orderSummary.shippingAmountMinor ?? 0; + const orderTotalCents = sumLineTotals([ + itemsSubtotalCents, + shippingAmountCents, + ]); let orderId: string; try { @@ -1276,6 +1186,7 @@ export async function createOrderWithItems({ totalAmount: toDbMoney(orderTotalCents), currency, + itemsSubtotalMinor: itemsSubtotalCents, paymentStatus: initialPaymentStatus, paymentProvider, paymentIntentId: null, diff --git a/frontend/lib/services/products/cart/rehydrate.ts b/frontend/lib/services/products/cart/rehydrate.ts index b9e2cee9..b153bee2 100644 --- a/frontend/lib/services/products/cart/rehydrate.ts +++ b/frontend/lib/services/products/cart/rehydrate.ts @@ -4,8 +4,8 @@ import { db } from '@/db'; import { coercePriceFromDb } from '@/db/queries/shop/orders'; import { productPrices, products } from '@/db/schema'; import { logWarn } from '@/lib/logging'; -import { createCheckoutPricingFingerprint } from '@/lib/shop/checkout-pricing'; import { createCartItemKey } from '@/lib/shop/cart-item-key'; +import { createCheckoutPricingFingerprint } from '@/lib/shop/checkout-pricing'; import { type CurrencyCode, isTwoDecimalCurrency } from '@/lib/shop/currency'; import { calculateLineTotal, fromCents, toCents } from '@/lib/shop/money'; import type { diff --git a/frontend/lib/services/shop/shipping/checkout-quote.ts b/frontend/lib/services/shop/shipping/checkout-quote.ts new file mode 100644 index 00000000..14559d06 --- /dev/null +++ b/frontend/lib/services/shop/shipping/checkout-quote.ts @@ -0,0 +1,94 @@ +import 'server-only'; + +import crypto from 'node:crypto'; + +import type { CurrencyCode } from '@/lib/shop/currency'; + +import type { CheckoutDeliveryMethodCode } from './checkout-payload'; + +const SHIPPING_QUOTE_VERSION = 1; +export const CHECKOUT_SHIPPING_QUOTE_CURRENCY = 'UAH' as const; +export type CheckoutShippingQuoteCurrency = + typeof CHECKOUT_SHIPPING_QUOTE_CURRENCY; + +const SHIPPING_AMOUNT_ENV_BY_METHOD: Record< + CheckoutDeliveryMethodCode, + string +> = { + NP_WAREHOUSE: 'SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', + NP_LOCKER: 'SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', + NP_COURIER: 'SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', +}; + +export class CheckoutShippingQuoteConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'CheckoutShippingQuoteConfigError'; + } +} + +export type CheckoutShippingQuote = { + methodCode: CheckoutDeliveryMethodCode; + currency: CheckoutShippingQuoteCurrency; + amountMinor: number; + quoteFingerprint: string; +}; + +export function isCheckoutShippingQuoteCurrency( + currency: CurrencyCode +): currency is CheckoutShippingQuoteCurrency { + return currency === CHECKOUT_SHIPPING_QUOTE_CURRENCY; +} + +function readNonNegativeIntEnv(name: string): number | null { + const raw = process.env[name]; + if (typeof raw !== 'string') return null; + + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isSafeInteger(parsed) || parsed < 0) return null; + + return parsed; +} + +export function createCheckoutShippingQuoteFingerprint(args: { + methodCode: CheckoutDeliveryMethodCode; + currency: CheckoutShippingQuoteCurrency; + amountMinor: number; +}): string { + const payload = JSON.stringify({ + v: SHIPPING_QUOTE_VERSION, + methodCode: args.methodCode, + currency: args.currency, + amountMinor: args.amountMinor, + }); + + return crypto.createHash('sha256').update(payload).digest('hex'); +} + +export function resolveCheckoutShippingQuote(args: { + methodCode: CheckoutDeliveryMethodCode; + currency: CheckoutShippingQuoteCurrency; +}): CheckoutShippingQuote { + const envName = SHIPPING_AMOUNT_ENV_BY_METHOD[args.methodCode]; + const amountMinor = readNonNegativeIntEnv(envName); + + if (amountMinor === null) { + throw new CheckoutShippingQuoteConfigError( + `Missing or invalid ${envName} shipping amount.` + ); + } + + return { + methodCode: args.methodCode, + currency: CHECKOUT_SHIPPING_QUOTE_CURRENCY, + amountMinor, + quoteFingerprint: createCheckoutShippingQuoteFingerprint({ + methodCode: args.methodCode, + currency: CHECKOUT_SHIPPING_QUOTE_CURRENCY, + amountMinor, + }), + }; +} diff --git a/frontend/lib/tests/shop/checkout-currency-policy.test.ts b/frontend/lib/tests/shop/checkout-currency-policy.test.ts index b429c499..049c19a3 100644 --- a/frontend/lib/tests/shop/checkout-currency-policy.test.ts +++ b/frontend/lib/tests/shop/checkout-currency-policy.test.ts @@ -163,8 +163,9 @@ async function makeCheckoutRequest( ? ({ ...(payload as Record) } as Record) : {}; const items = Array.isArray(body.items) ? body.items : []; - const currency = - opts.acceptLanguage.trim().toLowerCase().startsWith('uk') ? 'UAH' : 'USD'; + const currency = opts.acceptLanguage.trim().toLowerCase().startsWith('uk') + ? 'UAH' + : 'USD'; if (items.length > 0) { try { diff --git a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts new file mode 100644 index 00000000..da4d4718 --- /dev/null +++ b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts @@ -0,0 +1,483 @@ +import crypto from 'node:crypto'; + +import { eq, inArray } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { db } from '@/db'; +import { + inventoryMoves, + npCities, + npWarehouses, + orderItems, + orders, + productPrices, + products, +} from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { rehydrateCartItems } from '@/lib/services/products'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +const enforceRateLimitMock = vi.fn(); + +vi.mock('@/lib/security/rate-limit', () => ({ + getRateLimitSubject: vi.fn(() => 'checkout_shipping_authoritative_subject'), + enforceRateLimit: (...args: any[]) => enforceRateLimitMock(...args), + rateLimitResponse: ({ retryAfterSeconds }: { retryAfterSeconds: number }) => { + const res = NextResponse.json( + { + success: false, + code: 'RATE_LIMITED', + retryAfterSeconds, + }, + { status: 429 } + ); + res.headers.set('Retry-After', String(retryAfterSeconds)); + res.headers.set('Cache-Control', 'no-store'); + return res; + }, +})); + +vi.mock('@/lib/auth', async () => { + const actual = + await vi.importActual('@/lib/auth'); + return { + ...actual, + getCurrentUser: async () => null, + }; +}); + +vi.mock('@/lib/env/stripe', () => ({ + isPaymentsEnabled: () => true, +})); + +vi.mock('@/lib/services/orders/payment-attempts', async () => { + resetEnvCache(); + const actual = await vi.importActual( + '@/lib/services/orders/payment-attempts' + ); + return { + ...actual, + ensureStripePaymentIntentForOrder: vi.fn( + async (args: { orderId: string }) => ({ + paymentIntentId: `pi_test_${args.orderId.slice(0, 8)}`, + clientSecret: `cs_test_${args.orderId.slice(0, 8)}`, + attemptId: crypto.randomUUID(), + attemptNumber: 1, + }) + ), + }; +}); + +let POST: (req: NextRequest) => Promise; +let GET_METHODS: (req: NextRequest) => Promise; + +const createdProductIds: string[] = []; +const createdOrderIds: string[] = []; +const createdCityRefs: string[] = []; +const createdWarehouseRefs: string[] = []; + +type SeedData = { + productId: string; + cityRef: string; + warehouseRef: string; +}; + +beforeAll(async () => { + const checkoutRoute = await import('@/app/api/shop/checkout/route'); + POST = checkoutRoute.POST; + + const shippingMethodsRoute = + await import('@/app/api/shop/shipping/methods/route'); + GET_METHODS = shippingMethodsRoute.GET; +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + enforceRateLimitMock.mockResolvedValue({ ok: true, remaining: 100 }); + + vi.stubEnv('PAYMENTS_ENABLED', 'true'); + vi.stubEnv('STRIPE_PAYMENTS_ENABLED', 'true'); + vi.stubEnv('STRIPE_SECRET_KEY', 'sk_test_checkout_shipping_total'); + vi.stubEnv('STRIPE_WEBHOOK_SECRET', 'whsec_test_checkout_shipping_total'); + vi.stubEnv( + 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', + 'pk_test_checkout_shipping_total' + ); + vi.stubEnv('APP_ORIGIN', 'http://localhost:3000'); + vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_SYNC_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '500'); + vi.stubEnv('SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', '400'); + vi.stubEnv('SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', '700'); + resetEnvCache(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + resetEnvCache(); +}); + +afterAll(async () => { + if (createdOrderIds.length) { + await db + .delete(inventoryMoves) + .where(inArray(inventoryMoves.orderId, createdOrderIds)); + await db + .delete(orderItems) + .where(inArray(orderItems.orderId, createdOrderIds)); + await db.delete(orders).where(inArray(orders.id, createdOrderIds)); + } + + if (createdWarehouseRefs.length) { + await db + .delete(npWarehouses) + .where(inArray(npWarehouses.ref, createdWarehouseRefs)); + } + + if (createdCityRefs.length) { + await db.delete(npCities).where(inArray(npCities.ref, createdCityRefs)); + } + + if (createdProductIds.length) { + await db + .delete(inventoryMoves) + .where(inArray(inventoryMoves.productId, createdProductIds)); + await db + .delete(orderItems) + .where(inArray(orderItems.productId, createdProductIds)); + await db + .delete(productPrices) + .where(inArray(productPrices.productId, createdProductIds)); + await db.delete(products).where(inArray(products.id, createdProductIds)); + } +}); + +async function seedShippingCheckoutData(): Promise { + const productId = crypto.randomUUID(); + const cityRef = crypto.randomUUID(); + const warehouseRef = crypto.randomUUID(); + + await db.insert(products).values({ + id: productId, + slug: `checkout-shipping-total-${productId.slice(0, 8)}`, + title: 'Checkout Shipping Total Test Product', + description: null, + imageUrl: 'https://example.com/checkout-shipping-total.png', + imagePublicId: null, + price: '10.00', + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 25, + sku: null, + } as any); + + await db.insert(productPrices).values({ + id: crypto.randomUUID(), + productId, + currency: 'UAH', + priceMinor: 4000, + originalPriceMinor: null, + price: '40.00', + originalPrice: null, + } as any); + + await db.insert(npCities).values({ + ref: cityRef, + nameUa: 'Kyiv', + nameRu: 'Kiev', + area: 'Kyivska', + region: 'Kyiv', + settlementType: 'City', + isActive: true, + } as any); + + await db.insert(npWarehouses).values({ + ref: warehouseRef, + cityRef, + settlementRef: cityRef, + number: '1', + type: 'Branch', + name: 'Warehouse 1', + address: 'Address 1', + isPostMachine: false, + isActive: true, + } as any); + + createdProductIds.push(productId); + createdCityRefs.push(cityRef); + createdWarehouseRefs.push(warehouseRef); + + return { + productId, + cityRef, + warehouseRef, + }; +} + +async function fetchWarehouseMethodQuote() { + const response = await GET_METHODS( + new NextRequest( + 'http://localhost/api/shop/shipping/methods?locale=uk¤cy=UAH&country=UA' + ) + ); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.available).toBe(true); + + const warehouseMethod = Array.isArray(json.methods) + ? json.methods.find((method: any) => method?.methodCode === 'NP_WAREHOUSE') + : null; + + expect(warehouseMethod).toBeTruthy(); + expect(warehouseMethod.amountMinor).toBe(500); + expect(warehouseMethod.quoteFingerprint).toMatch(/^[a-f0-9]{64}$/); + + return warehouseMethod as { + amountMinor: number; + quoteFingerprint: string; + }; +} + +function makeCheckoutRequest(args: { + idempotencyKey: string; + productId: string; + pricingFingerprint: string; + cityRef: string; + warehouseRef: string; + shippingQuoteFingerprint?: string; + extraBody?: Record; +}) { + return new NextRequest( + new Request('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': args.idempotencyKey, + 'Accept-Language': 'uk-UA,uk;q=0.9', + 'X-Forwarded-For': deriveTestIpFromIdemKey(args.idempotencyKey), + Origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + paymentProvider: 'stripe', + paymentMethod: 'stripe_card', + pricingFingerprint: args.pricingFingerprint, + ...(args.shippingQuoteFingerprint + ? { shippingQuoteFingerprint: args.shippingQuoteFingerprint } + : {}), + shipping: { + provider: 'nova_poshta', + methodCode: 'NP_WAREHOUSE', + selection: { + cityRef: args.cityRef, + warehouseRef: args.warehouseRef, + }, + recipient: { + fullName: 'Alice', + phone: '+380501112233', + }, + }, + items: [{ productId: args.productId, quantity: 1 }], + ...(args.extraBody ?? {}), + }), + }) + ); +} + +describe('checkout authoritative shipping totals', () => { + it('includes authoritative shipping in the final persisted order total', async () => { + const seed = await seedShippingCheckoutData(); + const quote = await rehydrateCartItems( + [{ productId: seed.productId, quantity: 1 }], + 'UAH' + ); + const warehouseMethod = await fetchWarehouseMethodQuote(); + const pricingFingerprint = quote.summary.pricingFingerprint; + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + const expectedTotalMinor = + quote.summary.totalAmountMinor + warehouseMethod.amountMinor; + const idempotencyKey = crypto.randomUUID(); + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId: seed.productId, + pricingFingerprint: pricingFingerprint!, + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + shippingQuoteFingerprint: warehouseMethod.quoteFingerprint, + }) + ); + + expect(response.status).toBe(201); + const json = await response.json(); + expect(json.success).toBe(true); + expect(json.order.totalAmount).toBe(expectedTotalMinor / 100); + + const [orderRow] = await db + .select({ + id: orders.id, + totalAmountMinor: orders.totalAmountMinor, + itemsSubtotalMinor: orders.itemsSubtotalMinor, + shippingAmountMinor: orders.shippingAmountMinor, + }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toMatchObject({ + totalAmountMinor: expectedTotalMinor, + itemsSubtotalMinor: quote.summary.totalAmountMinor, + shippingAmountMinor: warehouseMethod.amountMinor, + }); + + if (orderRow?.id) { + createdOrderIds.push(orderRow.id); + } + }); + + it('fails closed when shipping quote fingerprint is missing and creates no order', async () => { + const seed = await seedShippingCheckoutData(); + const quote = await rehydrateCartItems( + [{ productId: seed.productId, quantity: 1 }], + 'UAH' + ); + const pricingFingerprint = quote.summary.pricingFingerprint; + const idempotencyKey = crypto.randomUUID(); + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId: seed.productId, + pricingFingerprint: pricingFingerprint!, + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + }) + ); + + expect(response.status).toBe(409); + const json = await response.json(); + expect(json.code).toBe('CHECKOUT_SHIPPING_CHANGED'); + expect(json.message).toBe( + 'Shipping amount changed. Refresh your cart and try again.' + ); + expect(json.details?.reason).toBe('SHIPPING_QUOTE_FINGERPRINT_MISSING'); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + }); + + it('fails closed when the authoritative shipping amount changes before submit', async () => { + const seed = await seedShippingCheckoutData(); + const quote = await rehydrateCartItems( + [{ productId: seed.productId, quantity: 1 }], + 'UAH' + ); + const warehouseMethod = await fetchWarehouseMethodQuote(); + const pricingFingerprint = quote.summary.pricingFingerprint; + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '650'); + resetEnvCache(); + + const idempotencyKey = crypto.randomUUID(); + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId: seed.productId, + pricingFingerprint: pricingFingerprint!, + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + shippingQuoteFingerprint: warehouseMethod.quoteFingerprint, + }) + ); + + expect(response.status).toBe(409); + const json = await response.json(); + expect(json.code).toBe('CHECKOUT_SHIPPING_CHANGED'); + expect(json.message).toBe( + 'Shipping amount changed. Refresh your cart and try again.' + ); + expect(json.details?.reason).toBe('SHIPPING_QUOTE_FINGERPRINT_MISMATCH'); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + }); + + it('rejects client-supplied payable totals and creates no order', async () => { + const seed = await seedShippingCheckoutData(); + const quote = await rehydrateCartItems( + [{ productId: seed.productId, quantity: 1 }], + 'UAH' + ); + const warehouseMethod = await fetchWarehouseMethodQuote(); + const pricingFingerprint = quote.summary.pricingFingerprint; + const idempotencyKey = crypto.randomUUID(); + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId: seed.productId, + pricingFingerprint: pricingFingerprint!, + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + shippingQuoteFingerprint: warehouseMethod.quoteFingerprint, + extraBody: { + shippingAmountMinor: 1, + totalAmountMinor: 1, + }, + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('INVALID_PAYLOAD'); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + }); +}); diff --git a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts index 695ffd9f..5184082e 100644 --- a/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts +++ b/frontend/lib/tests/shop/checkout-shipping-phase3.test.ts @@ -125,6 +125,9 @@ describe('checkout shipping phase 3', () => { vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); vi.stubEnv('SHOP_SHIPPING_SYNC_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '500'); + vi.stubEnv('SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', '400'); + vi.stubEnv('SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', '700'); resetEnvCache(); }); @@ -237,7 +240,9 @@ describe('checkout shipping phase 3', () => { .where(eq(orders.idempotencyKey, idem)); expect(rows.length).toBe(0); } finally { - await db.delete(npWarehouses).where(eq(npWarehouses.ref, otherWarehouseRef)); + await db + .delete(npWarehouses) + .where(eq(npWarehouses.ref, otherWarehouseRef)); await db.delete(npCities).where(eq(npCities.ref, otherCityRef)); await cleanupSeedData(seed, createdOrderIds); } @@ -358,6 +363,8 @@ describe('checkout shipping phase 3', () => { const [orderRow] = await db .select({ + totalAmountMinor: orders.totalAmountMinor, + itemsSubtotalMinor: orders.itemsSubtotalMinor, shippingRequired: orders.shippingRequired, shippingPayer: orders.shippingPayer, shippingProvider: orders.shippingProvider, @@ -370,12 +377,14 @@ describe('checkout shipping phase 3', () => { .limit(1); expect(orderRow).toMatchObject({ + totalAmountMinor: 4500, + itemsSubtotalMinor: 4000, shippingRequired: true, shippingPayer: 'customer', shippingProvider: 'nova_poshta', shippingMethodCode: 'NP_WAREHOUSE', shippingStatus: 'pending', - shippingAmountMinor: null, + shippingAmountMinor: 500, }); const [shippingRow] = await db @@ -391,6 +400,9 @@ describe('checkout shipping phase 3', () => { expect( (shippingRow?.shippingAddress as any)?.selection?.warehouseRef ).toBe(seed.warehouseRefA); + expect((shippingRow?.shippingAddress as any)?.quote?.amountMinor).toBe( + 500 + ); expect((shippingRow?.shippingAddress as any)?.recipient?.fullName).toBe( 'Alice' ); diff --git a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts index 08f388a3..590cce91 100644 --- a/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts +++ b/frontend/lib/tests/shop/shipping-methods-route-p2.test.ts @@ -76,4 +76,59 @@ describe('shop shipping methods route (phase 2)', () => { methods: [], }); }); + + it('returns 200 + available=false when checkout currency is unsupported', async () => { + vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '500'); + vi.stubEnv('SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', '400'); + vi.stubEnv('SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', '700'); + + const req = new NextRequest( + 'http://localhost/api/shop/shipping/methods?locale=en&country=UA¤cy=USD' + ); + const res = await GET(req); + const json: any = await res.json(); + + expect(res.status).toBe(200); + expect(json).toMatchObject({ + success: true, + available: false, + reasonCode: 'CURRENCY_NOT_SUPPORTED', + currency: 'USD', + methods: [], + }); + }); + + it('returns authoritative shipping amounts and quote fingerprints when shipping is available', async () => { + vi.stubEnv('SHOP_SHIPPING_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_ENABLED', 'true'); + vi.stubEnv('SHOP_SHIPPING_NP_WAREHOUSE_AMOUNT_MINOR', '500'); + vi.stubEnv('SHOP_SHIPPING_NP_LOCKER_AMOUNT_MINOR', '400'); + vi.stubEnv('SHOP_SHIPPING_NP_COURIER_AMOUNT_MINOR', '700'); + + const req = new NextRequest( + 'http://localhost/api/shop/shipping/methods?locale=uk¤cy=UAH&country=UA' + ); + const res = await GET(req); + const json: any = await res.json(); + + expect(res.status).toBe(200); + expect(json).toMatchObject({ + success: true, + available: true, + reasonCode: 'OK', + currency: 'UAH', + }); + expect(Array.isArray(json.methods)).toBe(true); + expect(json.methods).toHaveLength(3); + + for (const method of json.methods) { + expect(method.provider).toBe('nova_poshta'); + expect(method.methodCode).toMatch(/^NP_(WAREHOUSE|LOCKER|COURIER)$/); + expect(Number.isInteger(method.amountMinor)).toBe(true); + expect(method.amountMinor).toBeGreaterThanOrEqual(0); + expect(method.quoteFingerprint).toMatch(/^[a-f0-9]{64}$/); + } + }); }); diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 67469414..f8cb9735 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -424,6 +424,7 @@ export const checkoutPayloadSchema = z shipping: checkoutShippingSchema.optional(), legalConsent: checkoutLegalConsentSchema.optional(), pricingFingerprint: pricingFingerprintSchema.optional(), + shippingQuoteFingerprint: pricingFingerprintSchema.optional(), paymentProvider: checkoutRequestedProviderSchema.optional(), paymentMethod: paymentMethodSchema.optional(), paymentCurrency: currencySchema.optional(), From 5422f340923950f3979ef942ac7a1ff5e1270afd Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 24 Mar 2026 11:12:45 -0700 Subject: [PATCH 4/5] (SP: 1)[SHOP] enforce explicit no-discount checkout contract for launch --- frontend/app/api/shop/checkout/route.ts | 40 +++++++++++++++++ ...ckout-shipping-authoritative-total.test.ts | 45 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 9bf0fcfc..b27a3491 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -59,6 +59,7 @@ type CheckoutRequestedProvider = 'stripe' | 'monobank'; const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'IDEMPOTENCY_CONFLICT', 'INVALID_PAYLOAD', + 'DISCOUNTS_NOT_SUPPORTED', 'INVALID_VARIANT', 'INSUFFICIENT_STOCK', 'CHECKOUT_PRICE_CHANGED', @@ -94,6 +95,15 @@ const STATUS_TOKEN_SCOPES_PAYMENT_INIT: readonly StatusTokenScope[] = [ 'status_lite', 'order_payment_init', ]; +const UNSUPPORTED_DISCOUNT_FIELDS = new Set([ + 'discountCode', + 'couponCode', + 'promoCode', + 'discountAmount', + 'discountAmountMinor', + 'totalDiscountAmount', + 'totalDiscountMinor', +]); function resolveCheckoutTokenScopes(args: { paymentProvider: PaymentProvider; @@ -342,6 +352,18 @@ function errorResponse( return res; } +function collectUnsupportedDiscountFields( + value: unknown +): string[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return []; + } + + return Object.keys(value).filter(field => + UNSUPPORTED_DISCOUNT_FIELDS.has(field) + ); +} + function getIdempotencyKey(request: NextRequest) { const headerKey = request.headers.get('Idempotency-Key'); if (headerKey === null || headerKey === undefined) return null; @@ -998,6 +1020,24 @@ export async function POST(request: NextRequest) { }; } + const unsupportedDiscountFields = + collectUnsupportedDiscountFields(payloadForValidation); + + if (unsupportedDiscountFields.length > 0) { + logWarn('checkout_discount_not_supported', { + ...meta, + code: 'DISCOUNTS_NOT_SUPPORTED', + fields: unsupportedDiscountFields, + }); + + return errorResponse( + 'DISCOUNTS_NOT_SUPPORTED', + 'Discounts are not available at checkout.', + 400, + { fields: unsupportedDiscountFields } + ); + } + const parsedPayload = checkoutPayloadSchema.safeParse(payloadForValidation); if (!parsedPayload.success) { diff --git a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts index da4d4718..9b305218 100644 --- a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts +++ b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts @@ -480,4 +480,49 @@ describe('checkout authoritative shipping totals', () => { expect(orderRow).toBeFalsy(); }); + + it('rejects client-supplied discount input under the launch no-discount contract', async () => { + const seed = await seedShippingCheckoutData(); + const quote = await rehydrateCartItems( + [{ productId: seed.productId, quantity: 1 }], + 'UAH' + ); + const warehouseMethod = await fetchWarehouseMethodQuote(); + const pricingFingerprint = quote.summary.pricingFingerprint; + const idempotencyKey = crypto.randomUUID(); + + expect(typeof pricingFingerprint).toBe('string'); + expect(pricingFingerprint).toHaveLength(64); + + const response = await POST( + makeCheckoutRequest({ + idempotencyKey, + productId: seed.productId, + pricingFingerprint: pricingFingerprint!, + cityRef: seed.cityRef, + warehouseRef: seed.warehouseRef, + shippingQuoteFingerprint: warehouseMethod.quoteFingerprint, + extraBody: { + discountCode: 'SPRING10', + discountAmountMinor: 1000, + }, + }) + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe('DISCOUNTS_NOT_SUPPORTED'); + expect(json.message).toBe('Discounts are not available at checkout.'); + expect(json.details?.fields).toEqual( + expect.arrayContaining(['discountCode', 'discountAmountMinor']) + ); + + const [orderRow] = await db + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.idempotencyKey, idempotencyKey)) + .limit(1); + + expect(orderRow).toBeFalsy(); + }); }); From de48708c46c4833e0623952e3c973c9c8c2b314e Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Tue, 24 Mar 2026 11:36:27 -0700 Subject: [PATCH 5/5] (SP: 1)[SHOP] remove unsafe dead cart helpers and clean review nits --- .../shop/checkout-notifications-contract.md | 2 +- frontend/lib/cart.ts | 48 ------------------- .../services/shop/shipping/checkout-quote.ts | 2 +- .../checkout-price-change-fail-closed.test.ts | 8 ++-- ...ckout-shipping-authoritative-total.test.ts | 31 +++++++----- 5 files changed, 24 insertions(+), 67 deletions(-) diff --git a/frontend/docs/shop/checkout-notifications-contract.md b/frontend/docs/shop/checkout-notifications-contract.md index e62c1be3..b8e1a26c 100644 --- a/frontend/docs/shop/checkout-notifications-contract.md +++ b/frontend/docs/shop/checkout-notifications-contract.md @@ -26,7 +26,7 @@ This is a launch rule, not a UI preference. Notification flows depend on a reliable recipient. If a guest order is created without email, the notification pipeline can fail or dead-letter because there -is no guaranteed recipient identity. :contentReference[oaicite:2]{index=2} +is no guaranteed recipient identity. The system must not rely only on: diff --git a/frontend/lib/cart.ts b/frontend/lib/cart.ts index f7f166cb..1997f81a 100644 --- a/frontend/lib/cart.ts +++ b/frontend/lib/cart.ts @@ -2,14 +2,12 @@ import { z } from 'zod'; import { logWarn } from '@/lib/logging'; import { createCartItemKey } from '@/lib/shop/cart-item-key'; -import { fromCents } from '@/lib/shop/money'; import { type CartClientItem as ValidationCartClientItem, cartClientItemSchema, type CartRehydrateItem, type CartRehydrateResult, cartRehydrateResultSchema, - type CartRemovedItem, MAX_QUANTITY_PER_LINE, } from '@/lib/validation/shop'; @@ -166,44 +164,6 @@ function normalizeItemsForStorage( })); } -export function computeSummaryFromItems( - items: CartRehydrateItem[] -): CartSummary { - if (!items.length) { - return { - totalAmountMinor: 0, - totalAmount: 0, - itemCount: 0, - currency: 'USD', - pricingFingerprint: undefined, - }; - } - - const currency = (items[0]?.currency ?? 'USD') as CartSummary['currency']; - - let totalMinor = 0; - let itemCount = 0; - - for (const item of items) { - if (item.currency !== currency) { - throw new Error( - `Cart contains mixed currencies (${currency} and ${item.currency}). Clear cart and try again.` - ); - } - - totalMinor += item.lineTotalMinor; - itemCount += item.quantity; - } - - return { - totalAmountMinor: totalMinor, - totalAmount: fromCents(totalMinor), - itemCount, - currency, - pricingFingerprint: undefined, - }; -} - function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -307,14 +267,6 @@ export async function rehydrateCart( return parsed; } -export function buildCartFromItems( - items: CartRehydrateItem[], - removed: CartRemovedItem[] = [] -): Cart { - const summary = computeSummaryFromItems(items); - return { items, removed, summary }; -} - export function clearStoredCart(ownerId?: string | null): void { if (typeof window === 'undefined') return; diff --git a/frontend/lib/services/shop/shipping/checkout-quote.ts b/frontend/lib/services/shop/shipping/checkout-quote.ts index 14559d06..57f282a7 100644 --- a/frontend/lib/services/shop/shipping/checkout-quote.ts +++ b/frontend/lib/services/shop/shipping/checkout-quote.ts @@ -48,7 +48,7 @@ function readNonNegativeIntEnv(name: string): number | null { if (!/^\d+$/.test(trimmed)) return null; const parsed = Number.parseInt(trimmed, 10); - if (!Number.isSafeInteger(parsed) || parsed < 0) return null; + if (!Number.isSafeInteger(parsed)) return null; return parsed; } diff --git a/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts b/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts index 7117946a..1629328f 100644 --- a/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts +++ b/frontend/lib/tests/shop/checkout-price-change-fail-closed.test.ts @@ -133,6 +133,7 @@ afterAll(async () => { async function seedProduct(priceMinor: number): Promise { const slug = `checkout-price-change-${crypto.randomUUID()}`; + const price = (priceMinor / 100).toFixed(2); const [product] = await db .insert(products) @@ -142,7 +143,7 @@ async function seedProduct(priceMinor: number): Promise { description: null, imageUrl: 'https://example.com/checkout-price-change.png', imagePublicId: null, - price: '9.00', + price, originalPrice: null, currency: 'USD', category: null, @@ -164,7 +165,7 @@ async function seedProduct(priceMinor: number): Promise { currency: 'USD', priceMinor, originalPriceMinor: null, - price: (priceMinor / 100).toFixed(2), + price, originalPrice: null, }); @@ -309,8 +310,7 @@ describe('checkout fail-closed for changed price mismatch', () => { expect(json.success).toBe(true); expect(json.order?.totalAmount).toBe(9); - const orderId = - typeof json.order?.id === 'string' ? String(json.order.id) : null; + const orderId = typeof json.order?.id === 'string' ? json.order.id : null; expect(orderId).toBeTruthy(); if (orderId) { diff --git a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts index 9b305218..aec9a08a 100644 --- a/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts +++ b/frontend/lib/tests/shop/checkout-shipping-authoritative-total.test.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; -import { eq, inArray } from 'drizzle-orm'; +import { eq, inArray, type InferInsertModel } from 'drizzle-orm'; import { NextRequest, NextResponse } from 'next/server'; import { afterAll, @@ -91,6 +91,10 @@ type SeedData = { cityRef: string; warehouseRef: string; }; +type ProductInsert = InferInsertModel; +type ProductPriceInsert = InferInsertModel; +type NovaPoshtaCityInsert = InferInsertModel; +type NovaPoshtaWarehouseInsert = InferInsertModel; beforeAll(async () => { const checkoutRoute = await import('@/app/api/shop/checkout/route'); @@ -168,8 +172,7 @@ async function seedShippingCheckoutData(): Promise { const productId = crypto.randomUUID(); const cityRef = crypto.randomUUID(); const warehouseRef = crypto.randomUUID(); - - await db.insert(products).values({ + const productRow: ProductInsert = { id: productId, slug: `checkout-shipping-total-${productId.slice(0, 8)}`, title: 'Checkout Shipping Total Test Product', @@ -188,9 +191,8 @@ async function seedShippingCheckoutData(): Promise { isFeatured: false, stock: 25, sku: null, - } as any); - - await db.insert(productPrices).values({ + }; + const productPriceRow: ProductPriceInsert = { id: crypto.randomUUID(), productId, currency: 'UAH', @@ -198,9 +200,8 @@ async function seedShippingCheckoutData(): Promise { originalPriceMinor: null, price: '40.00', originalPrice: null, - } as any); - - await db.insert(npCities).values({ + }; + const cityRow: NovaPoshtaCityInsert = { ref: cityRef, nameUa: 'Kyiv', nameRu: 'Kiev', @@ -208,9 +209,8 @@ async function seedShippingCheckoutData(): Promise { region: 'Kyiv', settlementType: 'City', isActive: true, - } as any); - - await db.insert(npWarehouses).values({ + }; + const warehouseRow: NovaPoshtaWarehouseInsert = { ref: warehouseRef, cityRef, settlementRef: cityRef, @@ -220,7 +220,12 @@ async function seedShippingCheckoutData(): Promise { address: 'Address 1', isPostMachine: false, isActive: true, - } as any); + }; + + await db.insert(products).values(productRow); + await db.insert(productPrices).values(productPriceRow); + await db.insert(npCities).values(cityRow); + await db.insert(npWarehouses).values(warehouseRow); createdProductIds.push(productId); createdCityRefs.push(cityRef);