From 9ec8499d552b92a08d522f37e0771b4185225754 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 1 Jun 2026 23:54:04 +0200 Subject: [PATCH] Validate Stripe payment intent amount before reuse --- services/nginx/app/routes/ordersRoute.php | 40 ++++++++++++++++--- ...StripePaymentIntentLifecycleWiringTest.php | 8 ++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index b13d75de..d8681fd2 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -629,6 +629,7 @@ class ordersRoute $stripe = new stripe(); $stripePaymentIntents = new stripe_payment_intents_o(); + $expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage ?? 0); if ($stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); @@ -641,7 +642,10 @@ class ordersRoute $stripePaymentIntents->tax_percentage->set($tax_percentage); } - if ($this->isStripePaymentIntentReusable($storedPaymentIntent)) { + if ( + $this->isStripePaymentIntentReusable($storedPaymentIntent) + && $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage ?? 0) + ) { (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused Stripe payment intent for order (ID: ' . $data['id'] . ')'); $response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [ 'reused' => true, @@ -655,10 +659,7 @@ class ordersRoute } $paymentIntent = $stripe->payment_intents->create( - (int)round($this->addTaxNetAmount( - (float)$order->getNetAmount() * 100, - $tax_percentage ?? 0 - )), + $expectedPaymentIntentAmount, [ 'description' => 'Order ID: ' . $order->id, 'metadata' => [ @@ -952,6 +953,35 @@ class ordersRoute return $net_amount + ($net_amount * ($tax_percentage / 100)); } + private function getStripePaymentIntentAmountForOrder(orders_o $order, ?int $tax_percentage): int + { + return (int)round($this->addTaxNetAmount( + (float)$order->getNetAmount() * 100, + $tax_percentage ?? 0 + )); + } + + private function doesStripePaymentIntentMatchOrder(object $paymentIntent, int $expectedAmount, ?int $tax_percentage): bool + { + if (!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount) { + return false; + } + + $metadata = $paymentIntent->metadata ?? null; + $storedTaxPercentage = null; + if (is_array($metadata)) { + $storedTaxPercentage = $metadata['tax_percentage'] ?? null; + } elseif (is_object($metadata)) { + $storedTaxPercentage = $metadata->tax_percentage ?? null; + } + + if ($storedTaxPercentage === null || !is_numeric($storedTaxPercentage)) { + return ($tax_percentage ?? 0) === 0; + } + + return (int)$storedTaxPercentage === ($tax_percentage ?? 0); + } + private function isStripePaymentIntentReusable(object $paymentIntent): bool { $status = strtolower((string)($paymentIntent->status ?? '')); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php index 12282476..f8cda7d8 100644 --- a/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php +++ b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php @@ -14,7 +14,11 @@ it('wires mobile stripe payment intent routes to normalized lifecycle handling', $stripeSection = substr($content, (int)$start, (int)$end - (int)$start); expect($stripeSection)->toContain('buildStripePaymentIntentResponse('); + expect($stripeSection)->toContain('$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage ?? 0);'); expect($stripeSection)->toContain('isStripePaymentIntentReusable($storedPaymentIntent)'); + expect($stripeSection)->toContain('doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage ?? 0)'); + expect($stripeSection)->toContain('$stripe->payment_intents->create( + $expectedPaymentIntentAmount,'); expect($stripeSection)->toContain("'reused' => true"); expect($stripeSection)->toContain("'message' => 'No active payment intent for this order.'"); expect($stripeSection)->toContain("'message' => 'Payment intent cleared successfully.'"); @@ -24,4 +28,8 @@ it('wires mobile stripe payment intent routes to normalized lifecycle handling', expect($stripeSection)->toContain("Payment intent is not ready to capture."); expect($stripeSection)->toContain("paidWithStripe(\$paymentIntent->id);"); expect($stripeSection)->not->toContain("Order does not have a payment intent"); + + expect($content)->toContain('private function doesStripePaymentIntentMatchOrder(object $paymentIntent, int $expectedAmount, ?int $tax_percentage): bool'); + expect($content)->toContain('!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount'); + expect($content)->toContain("\$storedTaxPercentage = \$metadata['tax_percentage'] ?? null;"); });