Skip to content

Cherry/be implement checkout flow#24

Open
CherryCIC wants to merge 3 commits into
mainfrom
cherry/BEImplementCheckoutFlow
Open

Cherry/be implement checkout flow#24
CherryCIC wants to merge 3 commits into
mainfrom
cherry/BEImplementCheckoutFlow

Conversation

@CherryCIC
Copy link
Copy Markdown
Contributor

@CherryCIC CherryCIC commented May 7, 2026

Summary

Describe the change in plain language.

Checklist

  • I ran npm run build
  • I ran npm test -- --runInBand
  • I updated Swagger comments if an API contract changed
  • I updated .env.example if config changed
  • I documented mock versus live behaviour if shipping or payment logic changed
  • I linked any relevant design, flow, or contract context for user-facing changes
  • I noted any follow-up work or known gaps

Testing

List the checks you ran and the result.

Risk

Call out anything reviewers should watch closely, especially around auth, Stripe, Sendcloud, and order creation.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced Stripe PaymentSheet integration for seamless order creation with payment validation
    • Improved pickup point delivery options with better shipment tracking
    • Added payment status tracking for orders with Stripe PaymentIntent verification
  • Bug Fixes

    • Fixed webhook signature verification for payment and shipping notifications
    • Improved error handling and validation for payment processing
  • Improvements

    • Idempotent order creation prevents duplicate orders from payment retries
    • Enhanced shipping provider integration with better carrier support

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements a comprehensive Stripe PaymentSheet-based checkout system featuring idempotent order creation keyed by paymentIntentId, server-side payment and shipping validation, flexible shipping provider abstraction supporting both live Sendcloud and mock implementations, and webhook-driven shipment status tracking with timestamp-based deduplication.

Changes

Stripe PaymentSheet Checkout with Idempotent Orders and Shipping Provider Abstraction

Layer / File(s) Summary
Environment Variables & Configuration Foundation
.env.example, src/shared/config/stripeConfig.ts, src/shared/config/sendcloudConfig.ts, src/shared/config/firebaseConfig.ts, src/test/setupEnv.ts
Environment template adds STRIPE_PUBLISHABLE_KEY; Stripe config validates both secret and publishable keys, pins API version 2025-08-27.basil, adds maxNetworkRetries and typescript options, includes retrievePaymentIntent helper, and warns when webhook secret is missing; Sendcloud config introduces SendcloudMode type and resolveSendcloudMode() helper for mock/live selection; Firebase config adds test-mode initialization branch; setupEnv.ts populates test environment with mock credentials.
Core Data Shapes & Contracts
src/modules/order/model/Order.ts, src/modules/shipping/models/Shipment.ts, src/modules/shipping/services/ShippingProvider.ts
Order interface extended with paymentIntentId, paymentStatus, deliveryType, productSnapshot, shipping option metadata (shippingOptionId, shippingOptionName, shippingOptionPrice, shippingCarrier, shippingWeight), nested pickupPoint object, and shipmentStatus field; Shipment model adds optional lastWebhookTimestamp and extends parcel with optional shipment.id and to_service_point fields; ShippingProvider interface defines contract for parcel creation (including service-point variant), retrieval, method lookup with optional servicePointId filter, cancellation, label URLs, pickup-point lookup, and webhook signature verification.
Stripe Payment Intent System
src/modules/payment/PaymentRepository.ts, src/modules/payment/services/PaymentService.ts, src/modules/payment/controllers/paymentController.ts, src/modules/payment/routes/paymentRoutes.ts
PaymentRepository validates pence-based integer amounts, creates Stripe PaymentIntents with optional Firebase UID metadata, returns paymentIntentId/amount/currency; adds getPaymentIntent() and getCustomerEmail() methods; PaymentService delegates to repository with Firebase UID lookup; controller validates uid presence and amount as positive pence integer, returns detailed PaymentIntent response, handles webhook events (succeeded, payment_failed, canceled, processing) and updates order payment status; routes document expanded request/response schema and remove express.raw() middleware from webhook route (raw body captured via app.ts).
Shipping Provider Abstraction
src/modules/shipping/services/ShippingProvider.ts, src/modules/shipping/services/ShippingProviderFactory.ts, src/modules/shipping/services/SendcloudService.ts, src/modules/shipping/services/MockShippingProvider.ts
ShippingProvider interface defines contract; factory pattern selects SendcloudService or MockShippingProvider based on sendcloudConfig.mode; SendcloudService implements interface with typed ShippingParcelRequest, service-point parcel creation (createParcelToServicePoint), optional servicePointId filtering for shipping methods, and HMAC-SHA256 webhook signature verification using crypto.timingSafeEqual; MockShippingProvider provides deterministic in-memory implementation with mock shipping methods, parcel ID hashing, and webhook signature verification.
Order Repository & Shipment Persistence
src/modules/order/repositories/OrderRepository.ts, src/modules/shipping/repositories/ShipmentRepository.ts
OrderRepository supports conditional Firestore payload with payment/shipping/product fields; createOrderIdempotently uses paymentIntentId as idempotency key via transactions; buildOrderPayload conditionally includes optional fields; mapToOrder populates expanded Order shape with currency, payment details, shipping option metadata, pickup point, tracking, and shipment fields; updateOrderTracking now also sets shipmentStatus: 'announced' and supports shipmentId; updatePaymentStatusByPaymentIntentId handles pre-order payment status updates via stripePaymentIntents collection; ShipmentRepository accepts optional createdAt/updatedAt from caller, normalizes lastWebhookTimestamp into Date (handling Firestore Timestamp objects).
Order Validation & Shipping Constraints
src/modules/order/validators/orderValidator.ts
orderSchema now requires paymentIntentId and productId; enforces deliveryType consistency with deliveryMethod; requires shippingOptionId matching specific values per delivery type (mvp-home-delivery or mvp-pickup-point-delivery); conditionally validates address structure and required fields for home delivery; conditionally validates pickupPoint or (pickupPointId + courier) presence for pickup-point delivery; adds country constraints (GB) for address and pickup-point validation.
Order Controller & Checkout Routes
src/modules/order/controllers/orderController.ts, src/modules/order/routes/orderRoutes.ts
createOrder handler validates Stripe PaymentIntent status (succeeded), verifies amount matches product price + shipping option price, checks customer email and user metadata, creates shipment via provider for home delivery using ShippingParcelRequest, sets pending status for pickup-point, returns idempotent flag and shipment details; createShipToHomeParcel refactored to use provider abstraction and return shipment id/status; routes document expanded PaymentSheet-based request schema with required payment/delivery/product fields and 200 response including payment/shipment identifiers plus idempotent boolean.
Shipping Controller & Webhook Routes
src/modules/shipping/controllers/shippingController.ts, src/modules/shipping/routes/shippingRoutes.ts
createShipment uses provider abstraction; handleSendcloudWebhook verifies raw body signature, deduplicates via lastWebhookTimestamp comparison, maps status via mapShipmentStatus, updates shipment with status/tracking/timestamp; getShippingMethods passes through optional servicePointId to provider; routes document servicePointId query parameter and required shippingMethodId for pickup-point orders.
Request Parameter Type Safety
src/modules/categories/controllers/categoryController.ts, src/modules/charities/controllers/charityController.ts, src/modules/products/controllers/productController.ts, src/modules/products/validators/productIdValidator.ts
Controllers and validators refactored across 13 locations to extract route parameters via explicit as string casting (const id = req.params.id as string) instead of destructuring for consistent TypeScript typing.
Raw Request Body Capture
src/app.ts
Express JSON middleware configured with custom verify callback to capture raw request body as Buffer on request object for /api/payment/webhook and /api/shipping/webhook endpoints, enabling signature verification.
Test Infrastructure & Comprehensive Coverage
jest.config.js, src/test/setupEnv.ts, src/modules/payment/tests/paymentController.test.ts, src/modules/order/tests/orderController.test.ts, src/modules/order/tests/orderValidator.test.ts, src/modules/order/tests/exportRoutes.test.ts, src/modules/products/tests/productRoutes.test.ts, src/modules/shipping/tests/mockShippingProvider.test.ts, src/modules/shipping/tests/sendcloudService.test.ts, src/modules/shipping/tests/shippingWebhook.test.ts
Jest config adds roots, modulePathIgnorePatterns, and setupFiles pointing to setupEnv.ts; payment controller tests verify webhook signature verification and PaymentIntent creation with pence amounts; order controller tests cover idempotent creation, payment validation, shipment creation for home delivery, and failure handling; order validator tests validate required fields and delivery-type/shippingOptionId pairing; shipping tests verify webhook deduplication, provider abstraction, signature verification, and mock provider determinism; export routes test uses PassThrough stream for write stream mocking.

Possibly Related Issues

  • Implement Checkout Flow MVP#397: Directly implements backend checkout endpoint refactoring with idempotent PaymentIntent-keyed order creation, expanded request schema supporting paymentIntentId and deliveryType, and comprehensive payment/shipment verification required by frontend PaymentSheet integration.

Poem

🐰 A payment waltz now takes the stage,

With Stripe's intent on every page.

Idempotent magic, safe and sound,

Provider abstractions all around! 📦

Mock and real play side by side,

Tests ensure the whole checkout ride! ✨

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description is the unchecked template with no concrete details filled in; all checklist items are unchecked, Testing and Risk sections are empty headings only. Complete the description by filling in the Summary section with a plain-language explanation of changes, checking relevant checklist items, documenting test results, and calling out review focus areas for auth, Stripe, Sendcloud, and order creation logic.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title references the author's branch name rather than clearly describing the primary change; it is vague and does not convey the main implementation focus. Revise the title to clearly summarize the main change, e.g., 'Implement checkout flow with Stripe payment and order creation' or 'Add idempotent order creation with Stripe PaymentIntent and shipping integration'.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cherry/BEImplementCheckoutFlow
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch cherry/BEImplementCheckoutFlow

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3c07faf53

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/app.ts
Comment on lines +20 to +21
requestUrl === '/api/payment/webhook' ||
requestUrl === '/api/shipping/webhook'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match webhook paths without exact-string equality

The raw body is only captured when originalUrl is exactly /api/payment/webhook or /api/shipping/webhook, so valid webhook requests to equivalent route variants (for example a configured trailing slash like /api/payment/webhook/, or any query-string form) skip rawBody capture and then fail signature verification in the webhook controllers. This can silently break Stripe/Sendcloud event processing in production depending on endpoint configuration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
.env.example (1)

18-27: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

SENDCLOUD_WEBHOOK_SECRET and SENDCLOUD_MODE are missing from the Sendcloud section.

Both variables are consumed by sendcloudConfig.ts in this PR (webhookSecret sourced from SENDCLOUD_WEBHOOK_SECRET, mode controlled by SENDCLOUD_MODE) but are not documented here. Developers following this template will not know to configure them, making production webhook verification silently broken.

📝 Proposed addition
 SENDCLOUD_PUBLIC_KEY=your_sendcloud_public_key
 SENDCLOUD_SECRET_KEY=your_sendcloud_secret_key
 SENDCLOUD_API_URL=https://panel.sendcloud.sc/api/v2
+# Separate signing secret configured on the Sendcloud webhook integration (Dashboard → Settings → Integrations → Webhook)
+SENDCLOUD_WEBHOOK_SECRET=your_sendcloud_webhook_secret
+# 'mock' (default for non-production) or 'live' – controls which ShippingProvider is used
+SENDCLOUD_MODE=mock
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 18 - 27, The .env.example is missing
SENDCLOUD_WEBHOOK_SECRET and SENDCLOUD_MODE which sendcloudConfig.ts expects
(webhookSecret sourced from SENDCLOUD_WEBHOOK_SECRET and mode from
SENDCLOUD_MODE); update the Sendcloud section of .env.example to add
SENDCLOUD_WEBHOOK_SECRET and SENDCLOUD_MODE entries with brief guidance (e.g.,
that the webhook secret is server-only and mode should be "live" or "test") so
developers know to configure them and webhook verification will work as
intended.
src/modules/shipping/controllers/shippingController.ts (1)

54-55: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Parse and validate weight and shippingMethodId before calling the provider.

req.body is untyped here, so "1000", "12", or a negative number can flow straight into the Sendcloud payload. That turns client input errors into provider failures/500s, and createParcelToServicePoint() is typed to receive a numeric shipping-method ID. Reject non-positive integers locally before building parcelData.

Suggested fix
-    const { orderId, weight, shippingMethodId } = req.body;
+    const { orderId, weight, shippingMethodId } = req.body;
+    const parsedWeight = weight === undefined ? 1000 : Number(weight);
+    const parsedShippingMethodId =
+      shippingMethodId === undefined ? undefined : Number(shippingMethodId);

     if (!orderId) {
       ResponseHandler.badRequest(res, 'Invalid request', 'orderId is required');
       return;
     }
+
+    if (!Number.isInteger(parsedWeight) || parsedWeight <= 0) {
+      ResponseHandler.badRequest(
+        res,
+        'Invalid weight',
+        'weight must be a positive integer',
+      );
+      return;
+    }
+
+    if (
+      parsedShippingMethodId !== undefined &&
+      (!Number.isInteger(parsedShippingMethodId) ||
+        parsedShippingMethodId <= 0)
+    ) {
+      ResponseHandler.badRequest(
+        res,
+        'Invalid shipping method',
+        'shippingMethodId must be a positive integer',
+      );
+      return;
+    }
@@
-      weight: weight || 1000, // Default 1kg if not provided
+      weight: parsedWeight, // Default 1kg if not provided
@@
-      parcelData.shipment = { id: shippingMethodId };
+      parcelData.shipment = { id: parsedShippingMethodId };
       parcelData.to_service_point = order.pickupPointId;
       sendcloudParcel = await shippingProvider.createParcelToServicePoint(
         parcelData,
         order.pickupPointId,
-        shippingMethodId,
+        parsedShippingMethodId,
       );
     } else {
-      if (shippingMethodId) {
-        parcelData.shipment = { id: shippingMethodId };
+      if (parsedShippingMethodId !== undefined) {
+        parcelData.shipment = { id: parsedShippingMethodId };
       }

Also applies to: 107-108, 122-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/shipping/controllers/shippingController.ts` around lines 54 - 55,
The controller currently pulls orderId, weight, and shippingMethodId raw from
req.body and passes them to createParcelToServicePoint/parcelData unvalidated;
parse weight and shippingMethodId into numbers (e.g., Number()/parseInt), ensure
shippingMethodId is a positive integer (Number.isInteger and >0) and weight is a
positive number (>0), and if validation fails return a 400 with a clear error
message rather than calling the provider; update every place that constructs
parcelData or calls createParcelToServicePoint (including the other occurrences
mentioned) to use the validated/converted numeric values.
🧹 Nitpick comments (1)
src/modules/payment/PaymentRepository.ts (1)

64-67: 💤 Low value

Add error handling for deleted customer scenarios.

StripeService.getCustomerByID explicitly throws an error when the customer has been deleted (see stripeConfig.ts lines 30-31). Since the call in orderController.ts:376 is not wrapped in error handling, an unhandled exception would disrupt the checkout flow. Consider wrapping this method in a try-catch to gracefully handle the edge case.

Suggested defensive handling
  async getCustomerEmail(customerId: string): Promise<string | undefined> {
-    const customer = await StripeService.getCustomerByID(customerId);
-    return customer.email || undefined;
+    try {
+      const customer = await StripeService.getCustomerByID(customerId);
+      return customer?.email || undefined;
+    } catch {
+      return undefined;
+    }
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/payment/PaymentRepository.ts` around lines 64 - 67,
getCustomerEmail currently calls StripeService.getCustomerByID without handling
the case where that service throws for a deleted customer; wrap the call to
StripeService.getCustomerByID inside getCustomerEmail in a try-catch, catch the
specific deleted-customer error (or inspect error.message/ty pe) and return
undefined (and optionally log a debug/warn) so callers like orderController can
continue the checkout flow instead of bubbling an unhandled exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app.ts`:
- Around line 19-24: The match on requestUrl (derived from req.originalUrl) is
too strict and misses webhook calls with query strings; update the middleware
that sets (req as any).rawBody to compare only the pathname or use startsWith on
requestUrl (e.g., check requestUrl.startsWith('/api/payment/webhook') or parse
the URL and use its pathname) so requests like /api/payment/webhook?foo=bar
still get rawBody set for signature verification.

In `@src/modules/order/controllers/orderController.ts`:
- Around line 205-213: The controller currently only reads pickupPoint from
req.body so valid requests that supply pickupPointId and courier (but omit
pickupPoint) lose those values; update the destructuring at the top of the
handler in orderController.ts to also extract pickupPointId and courier from
req.body, and when constructing the order payload and shipping data (the object
passed toward createShipment()) prefer the explicit pickupPointId and courier
values if present (fall back to pickupPoint or shipping option carrier only when
those explicit fields are missing) so createShipment() receives a persisted
pickupPointId and correct courier.

In `@src/modules/shipping/controllers/shippingController.ts`:
- Around line 435-458: The current guard only ignores updates when both incoming
and existing timestamps parse and incoming is older; add a check so if
incomingTimestampMs is null but existingTimestampMs is not null, treat the
webhook as stale and return 200 {received:true, ignored:true} without calling
shipmentRepo.updateShipment. Update the logic around parseWebhookTimestamp
(incomingTimestampMs / existingTimestampMs) before mapping status and before
calling shipmentRepo.updateShipment (and keep the lastWebhookTimestamp update
behavior unchanged when incomingTimestampMs is present) so that null incoming
timestamps cannot overwrite a stored newer event; reference
parseWebhookTimestamp, incomingTimestampMs, existingTimestampMs,
mapShipmentStatus, and shipmentRepo.updateShipment to locate the change.

In `@src/modules/shipping/tests/mockShippingProvider.test.ts`:
- Around line 37-48: The test for provider.verifyWebhookSignature relies on
process.env.SENDCLOUD_WEBHOOK_SECRET which may be undefined in CI; make the test
deterministic by setting a known secret before computing the expected signature
and restoring it after (or by mocking process.env.SENDCLOUD_WEBHOOK_SECRET).
Specifically, in the test setup for mockShippingProvider.test.ts set
process.env.SENDCLOUD_WEBHOOK_SECRET = 'test-secret' (or mock the env) prior to
calling crypto.createHmac and provider.verifyWebhookSignature, compute the
signature with that known secret, and ensure any global side-effects are cleaned
up after the test.

In `@src/shared/config/sendcloudConfig.ts`:
- Around line 20-34: The webhookSecret fallback is incorrectly using
SENDCLOUD_SECRET_KEY which will break webhook HMAC verification; update the
sendcloudConfig.webhookSecret to only use process.env.SENDCLOUD_WEBHOOK_SECRET
(no fallback to SENDCLOUD_SECRET_KEY) and make missing webhookSecret in live
mode emit a clear warning (or fail-fast) alongside the existing credential check
(use the sendcloudConfig.webhookSecret and sendcloudConfig.mode checks where
resolveSendcloudMode() is used) so verifyWebhookSignature receives the correct
key in production.

---

Outside diff comments:
In @.env.example:
- Around line 18-27: The .env.example is missing SENDCLOUD_WEBHOOK_SECRET and
SENDCLOUD_MODE which sendcloudConfig.ts expects (webhookSecret sourced from
SENDCLOUD_WEBHOOK_SECRET and mode from SENDCLOUD_MODE); update the Sendcloud
section of .env.example to add SENDCLOUD_WEBHOOK_SECRET and SENDCLOUD_MODE
entries with brief guidance (e.g., that the webhook secret is server-only and
mode should be "live" or "test") so developers know to configure them and
webhook verification will work as intended.

In `@src/modules/shipping/controllers/shippingController.ts`:
- Around line 54-55: The controller currently pulls orderId, weight, and
shippingMethodId raw from req.body and passes them to
createParcelToServicePoint/parcelData unvalidated; parse weight and
shippingMethodId into numbers (e.g., Number()/parseInt), ensure shippingMethodId
is a positive integer (Number.isInteger and >0) and weight is a positive number
(>0), and if validation fails return a 400 with a clear error message rather
than calling the provider; update every place that constructs parcelData or
calls createParcelToServicePoint (including the other occurrences mentioned) to
use the validated/converted numeric values.

---

Nitpick comments:
In `@src/modules/payment/PaymentRepository.ts`:
- Around line 64-67: getCustomerEmail currently calls
StripeService.getCustomerByID without handling the case where that service
throws for a deleted customer; wrap the call to StripeService.getCustomerByID
inside getCustomerEmail in a try-catch, catch the specific deleted-customer
error (or inspect error.message/ty pe) and return undefined (and optionally log
a debug/warn) so callers like orderController can continue the checkout flow
instead of bubbling an unhandled exception.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ea44758a-aa04-42e2-a282-0a6d9cf68b96

📥 Commits

Reviewing files that changed from the base of the PR and between f0f0e79 and f3c07fa.

📒 Files selected for processing (36)
  • .env.example
  • jest.config.js
  • src/app.ts
  • src/modules/categories/controllers/categoryController.ts
  • src/modules/charities/controllers/charityController.ts
  • src/modules/order/controllers/orderController.ts
  • src/modules/order/model/Order.ts
  • src/modules/order/repositories/OrderRepository.ts
  • src/modules/order/routes/orderRoutes.ts
  • src/modules/order/tests/exportRoutes.test.ts
  • src/modules/order/tests/orderController.test.ts
  • src/modules/order/tests/orderValidator.test.ts
  • src/modules/order/validators/orderValidator.ts
  • src/modules/payment/PaymentRepository.ts
  • src/modules/payment/controllers/paymentController.ts
  • src/modules/payment/routes/paymentRoutes.ts
  • src/modules/payment/services/PaymentService.ts
  • src/modules/payment/tests/paymentController.test.ts
  • src/modules/products/controllers/productController.ts
  • src/modules/products/tests/productRoutes.test.ts
  • src/modules/products/validators/productIdValidator.ts
  • src/modules/shipping/controllers/shippingController.ts
  • src/modules/shipping/models/Shipment.ts
  • src/modules/shipping/repositories/ShipmentRepository.ts
  • src/modules/shipping/routes/shippingRoutes.ts
  • src/modules/shipping/services/MockShippingProvider.ts
  • src/modules/shipping/services/SendcloudService.ts
  • src/modules/shipping/services/ShippingProvider.ts
  • src/modules/shipping/services/ShippingProviderFactory.ts
  • src/modules/shipping/tests/mockShippingProvider.test.ts
  • src/modules/shipping/tests/sendcloudService.test.ts
  • src/modules/shipping/tests/shippingWebhook.test.ts
  • src/shared/config/firebaseConfig.ts
  • src/shared/config/sendcloudConfig.ts
  • src/shared/config/stripeConfig.ts
  • src/test/setupEnv.ts

Comment thread src/app.ts
Comment on lines +19 to +24
if (
requestUrl === '/api/payment/webhook' ||
requestUrl === '/api/shipping/webhook'
) {
(req as any).rawBody = Buffer.from(buffer);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Exact === match on originalUrl will miss webhook requests with a query string.

req.originalUrl includes the query string, so a URL like /api/payment/webhook?foo=bar won't match and req.rawBody won't be set, silently breaking signature verification. Use startsWith or parse only the pathname component.

🛡️ Proposed fix
-      if (
-        requestUrl === '/api/payment/webhook' ||
-        requestUrl === '/api/shipping/webhook'
-      ) {
+      const pathname = requestUrl.split('?')[0];
+      if (
+        pathname === '/api/payment/webhook' ||
+        pathname === '/api/shipping/webhook'
+      ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
requestUrl === '/api/payment/webhook' ||
requestUrl === '/api/shipping/webhook'
) {
(req as any).rawBody = Buffer.from(buffer);
}
const pathname = requestUrl.split('?')[0];
if (
pathname === '/api/payment/webhook' ||
pathname === '/api/shipping/webhook'
) {
(req as any).rawBody = Buffer.from(buffer);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app.ts` around lines 19 - 24, The match on requestUrl (derived from
req.originalUrl) is too strict and misses webhook calls with query strings;
update the middleware that sets (req as any).rawBody to compare only the
pathname or use startsWith on requestUrl (e.g., check
requestUrl.startsWith('/api/payment/webhook') or parse the URL and use its
pathname) so requests like /api/payment/webhook?foo=bar still get rawBody set
for signature verification.

Comment on lines 205 to 213
const {
amount,
paymentIntentId,
productId,
productName,
shipping,
deliveryMethod,
courier,
pickupPointId,
shippingOptionId,
shippingWeight,
pickupPoint,
} = req.body;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist the fallback pickupPointId/courier fields for pickup-point orders.

The validator accepts pickup-point checkouts with pickupPointId + courier even when pickupPoint is omitted, but this controller only reads pickupPoint. In that valid request shape, the saved order loses pickupPointId, and courier falls back to the hard-coded shipping option carrier. The downstream impact is that createShipment() later rejects the order as missing pickupPointId.

Suggested fix
     const {
       amount,
       paymentIntentId,
       productId,
       shipping,
       shippingOptionId,
       shippingWeight,
       pickupPoint,
+      pickupPointId,
+      courier,
     } = req.body;
@@
     const orderPickupPoint = buildPickupPoint(pickupPoint);
+    const resolvedPickupPointId = orderPickupPoint?.id ?? pickupPointId;
+    const resolvedCourier =
+      orderPickupPoint?.carrier ?? courier ?? shippingOption.carrier;
     const resolvedShippingWeight =
       typeof shippingWeight === 'number' ? shippingWeight : 500;
@@
-        courier: orderPickupPoint?.carrier || shippingOption.carrier,
-        pickupPointId: orderPickupPoint?.id,
+        courier: resolvedCourier,
+        pickupPointId: resolvedPickupPointId,
         pickupPoint: orderPickupPoint,

Also applies to: 404-429

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/order/controllers/orderController.ts` around lines 205 - 213, The
controller currently only reads pickupPoint from req.body so valid requests that
supply pickupPointId and courier (but omit pickupPoint) lose those values;
update the destructuring at the top of the handler in orderController.ts to also
extract pickupPointId and courier from req.body, and when constructing the order
payload and shipping data (the object passed toward createShipment()) prefer the
explicit pickupPointId and courier values if present (fall back to pickupPoint
or shipping option carrier only when those explicit fields are missing) so
createShipment() receives a persisted pickupPointId and correct courier.

Comment on lines 76 to +91
const { amount } = req.body;

if (!Number.isInteger(amount) || amount <= 0) {
ResponseHandler.badRequest(
res,
'Invalid payment amount',
'amount must be a positive integer in pence',
);
return;
}

// Use service to handle Stripe logic and reuse existing customers when possible
const paymentService = new PaymentService();
const responseData = await paymentService.createPaymentIntentForUserByUid(firebaseUid, amount);
const responseData = await paymentService.createPaymentIntentForUserByUid(
firebaseUid,
amount,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Price the PaymentIntent from server-side checkout inputs, not the raw amount.

Any authenticated client can currently create a successful PaymentIntent for an arbitrary positive integer. createOrder() then rejects mismatched totals only after the charge has already succeeded, which leaves paid PaymentIntents with no order and forces manual recovery/refunds. This endpoint needs the same product/shipping inputs and pricing validation as order creation, or it should create intents from a server-stored quote instead of trusting amount.

Comment on lines +435 to +458
const incomingTimestampMs = parseWebhookTimestamp(timestamp);
const existingTimestampMs = parseWebhookTimestamp(
shipment.lastWebhookTimestamp,
);

if (
incomingTimestampMs !== null &&
existingTimestampMs !== null &&
incomingTimestampMs <= existingTimestampMs
) {
res.status(200).json({ received: true, ignored: true });
return;
}

const status = mapShipmentStatus(parcel.status?.message);

await shipmentRepo.updateShipment(shipment.id, {
status,
trackingNumber: parcel.tracking_number,
trackingUrl: parcel.tracking_url,
lastWebhookTimestamp:
incomingTimestampMs !== null
? new Date(incomingTimestampMs)
: shipment.lastWebhookTimestamp,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ignore webhook updates whose timestamp cannot be ordered against a newer stored event.

The stale-event guard only runs when timestamp parses successfully. If an older Sendcloud webhook arrives without a parsable timestamp after a newer one has already been stored, this path still overwrites the shipment status and keeps the old lastWebhookTimestamp. That reintroduces out-of-order status regression.

Suggested fix
         const incomingTimestampMs = parseWebhookTimestamp(timestamp);
         const existingTimestampMs = parseWebhookTimestamp(
           shipment.lastWebhookTimestamp,
         );
+
+        if (existingTimestampMs !== null && incomingTimestampMs === null) {
+          res.status(200).json({ received: true, ignored: true });
+          return;
+        }

         if (
           incomingTimestampMs !== null &&
           existingTimestampMs !== null &&
           incomingTimestampMs <= existingTimestampMs
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const incomingTimestampMs = parseWebhookTimestamp(timestamp);
const existingTimestampMs = parseWebhookTimestamp(
shipment.lastWebhookTimestamp,
);
if (
incomingTimestampMs !== null &&
existingTimestampMs !== null &&
incomingTimestampMs <= existingTimestampMs
) {
res.status(200).json({ received: true, ignored: true });
return;
}
const status = mapShipmentStatus(parcel.status?.message);
await shipmentRepo.updateShipment(shipment.id, {
status,
trackingNumber: parcel.tracking_number,
trackingUrl: parcel.tracking_url,
lastWebhookTimestamp:
incomingTimestampMs !== null
? new Date(incomingTimestampMs)
: shipment.lastWebhookTimestamp,
const incomingTimestampMs = parseWebhookTimestamp(timestamp);
const existingTimestampMs = parseWebhookTimestamp(
shipment.lastWebhookTimestamp,
);
if (existingTimestampMs !== null && incomingTimestampMs === null) {
res.status(200).json({ received: true, ignored: true });
return;
}
if (
incomingTimestampMs !== null &&
existingTimestampMs !== null &&
incomingTimestampMs <= existingTimestampMs
) {
res.status(200).json({ received: true, ignored: true });
return;
}
const status = mapShipmentStatus(parcel.status?.message);
await shipmentRepo.updateShipment(shipment.id, {
status,
trackingNumber: parcel.tracking_number,
trackingUrl: parcel.tracking_url,
lastWebhookTimestamp:
incomingTimestampMs !== null
? new Date(incomingTimestampMs)
: shipment.lastWebhookTimestamp,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/shipping/controllers/shippingController.ts` around lines 435 -
458, The current guard only ignores updates when both incoming and existing
timestamps parse and incoming is older; add a check so if incomingTimestampMs is
null but existingTimestampMs is not null, treat the webhook as stale and return
200 {received:true, ignored:true} without calling shipmentRepo.updateShipment.
Update the logic around parseWebhookTimestamp (incomingTimestampMs /
existingTimestampMs) before mapping status and before calling
shipmentRepo.updateShipment (and keep the lastWebhookTimestamp update behavior
unchanged when incomingTimestampMs is present) so that null incoming timestamps
cannot overwrite a stored newer event; reference parseWebhookTimestamp,
incomingTimestampMs, existingTimestampMs, mapShipmentStatus, and
shipmentRepo.updateShipment to locate the change.

Comment on lines +37 to +48
it('verifies webhook signatures against the configured secret', () => {
const rawBody = JSON.stringify({ action: 'parcel_status_changed' });
const signature = crypto
.createHmac('sha256', process.env.SENDCLOUD_WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');

expect(provider.verifyWebhookSignature(rawBody, signature)).toBe(true);
expect(provider.verifyWebhookSignature(rawBody, 'bad-signature')).toBe(
false,
);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test relies on undefined environment variable.

The test computes the expected signature using process.env.SENDCLOUD_WEBHOOK_SECRET!, but if this variable isn't set in the test environment, createHmac receives undefined and the signature comparison becomes meaningless or fails unexpectedly. Consider setting a known test secret in the test setup or mocking process.env to ensure deterministic behavior.

Proposed fix
 describe('MockShippingProvider', () => {
   const provider = new MockShippingProvider();
+  const TEST_WEBHOOK_SECRET = 'test-webhook-secret';
+
+  beforeAll(() => {
+    process.env.SENDCLOUD_WEBHOOK_SECRET = TEST_WEBHOOK_SECRET;
+  });

   // ... other tests ...

   it('verifies webhook signatures against the configured secret', () => {
     const rawBody = JSON.stringify({ action: 'parcel_status_changed' });
     const signature = crypto
-      .createHmac('sha256', process.env.SENDCLOUD_WEBHOOK_SECRET!)
+      .createHmac('sha256', TEST_WEBHOOK_SECRET)
       .update(rawBody)
       .digest('hex');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/shipping/tests/mockShippingProvider.test.ts` around lines 37 -
48, The test for provider.verifyWebhookSignature relies on
process.env.SENDCLOUD_WEBHOOK_SECRET which may be undefined in CI; make the test
deterministic by setting a known secret before computing the expected signature
and restoring it after (or by mocking process.env.SENDCLOUD_WEBHOOK_SECRET).
Specifically, in the test setup for mockShippingProvider.test.ts set
process.env.SENDCLOUD_WEBHOOK_SECRET = 'test-secret' (or mock the env) prior to
calling crypto.createHmac and provider.verifyWebhookSignature, compute the
signature with that known secret, and ensure any global side-effects are cleaned
up after the test.

Comment on lines +20 to +34
webhookSecret:
process.env.SENDCLOUD_WEBHOOK_SECRET ||
process.env.SENDCLOUD_SECRET_KEY ||
'',
apiUrl: process.env.SENDCLOUD_API_URL || 'https://panel.sendcloud.sc/api/v2',
mode: resolveSendcloudMode(),
};

// Validate configuration
if (!sendcloudConfig.publicKey || !sendcloudConfig.secretKey) {
if (
sendcloudConfig.mode === 'live' &&
(!sendcloudConfig.publicKey || !sendcloudConfig.secretKey)
) {
console.warn('⚠️ Sendcloud credentials are not configured. Please set SENDCLOUD_PUBLIC_KEY and SENDCLOUD_SECRET_KEY in your .env file.');
} No newline at end of file
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Incorrect SENDCLOUD_SECRET_KEY fallback for webhookSecret will silently break webhook verification in production.

Sendcloud's webhook secret is "a password used to sign all Sendcloud webhook requests" — it is a separate, dashboard-configured credential that is distinct from the API auth pair (SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY). Using SENDCLOUD_SECRET_KEY as a fallback will cause verifyWebhookSignature to compute HMACs with the wrong key, which means every live Sendcloud webhook will fail signature verification. There is also no warning emitted in live mode when webhookSecret is unconfigured, so this misconfiguration will be invisible at startup.

🐛 Proposed fix
  webhookSecret:
    process.env.SENDCLOUD_WEBHOOK_SECRET ||
-   process.env.SENDCLOUD_SECRET_KEY ||
    '',
  ...

// Validate configuration
 if (
   sendcloudConfig.mode === 'live' &&
   (!sendcloudConfig.publicKey || !sendcloudConfig.secretKey)
 ) {
   console.warn('⚠️  Sendcloud credentials are not configured. Please set SENDCLOUD_PUBLIC_KEY and SENDCLOUD_SECRET_KEY in your .env file.');
 }
+
+if (sendcloudConfig.mode === 'live' && !sendcloudConfig.webhookSecret) {
+  console.warn('⚠️  SENDCLOUD_WEBHOOK_SECRET is not set. Webhook signature verification will reject all incoming Sendcloud webhooks in live mode.');
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/config/sendcloudConfig.ts` around lines 20 - 34, The webhookSecret
fallback is incorrectly using SENDCLOUD_SECRET_KEY which will break webhook HMAC
verification; update the sendcloudConfig.webhookSecret to only use
process.env.SENDCLOUD_WEBHOOK_SECRET (no fallback to SENDCLOUD_SECRET_KEY) and
make missing webhookSecret in live mode emit a clear warning (or fail-fast)
alongside the existing credential check (use the sendcloudConfig.webhookSecret
and sendcloudConfig.mode checks where resolveSendcloudMode() is used) so
verifyWebhookSignature receives the correct key in production.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant