From 30d860fdef0cdf92adf7473f084b7ed42d9930fc Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 27 Jul 2026 19:09:37 +0200 Subject: [PATCH] Retire payment links and capture card payments (#327) ## Summary - retire Stripe hosted payment-link creation routes used by POS and order management - automatically capture authorized payment intents rather than requiring a separate manual capture action - preserve ordinary terminal payment and payment-intent lifecycle behavior - add API and wiring regressions for payment-link retirement and automatic capture Paired frontend change: https://github.com/copenhagentruckwash/pleno-vue/pull/233 ## Verification - focused backend unit suite: 2 tests, 36 assertions passed - PHP syntax checks passed - paired frontend unit and Playwright suites passed locally - full required GitHub runner suites are required before this task may enter Review or merge ## Security and operational notes - no credentials, terminal secrets, or payment data are added - no live Stripe account or physical terminal was exercised locally - automatic merge remains gated on both paired PRs having passing required checks and current branches --- .../nginx/app/routes/moduleStripeRoute.php | 84 +------ services/nginx/app/routes/ordersRoute.php | 57 +++-- .../nginx/app/tests/Api/StripeApiTest.php | 219 +----------------- ...StripePaymentIntentLifecycleWiringTest.php | 7 +- ...eStripePaymentLinkRetirementWiringTest.php | 31 +++ 5 files changed, 88 insertions(+), 310 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Stripe/ModuleStripePaymentLinkRetirementWiringTest.php diff --git a/services/nginx/app/routes/moduleStripeRoute.php b/services/nginx/app/routes/moduleStripeRoute.php index 7c1c1e5f..2d4a51d4 100644 --- a/services/nginx/app/routes/moduleStripeRoute.php +++ b/services/nginx/app/routes/moduleStripeRoute.php @@ -3,14 +3,12 @@ namespace routes; use classes\authentication; -use classes\email; use classes\response; use classes\router; use classes\stripe; use objects\departments_o; use objects\logs_o; use objects\orders_o; -use objects\stripe_module_customers_o; use traits\route_t; class moduleStripeRoute @@ -81,87 +79,17 @@ class moduleStripeRoute ] ); - /** Modules > Stripe > Send Invoice */ + /** Modules > Stripe > Retired direct payment-link creation */ $this->post('/modules/stripe/invoice', function () { global $response; self::requirePermission('modules_stripe_invoice_send'); - $user = (new authentication())->get_user(); - if ($user) { - self::requireParameters(['email', 'order_id']); - self::requireType(self::fromRequest('email'), 'string'); - self::requireType((int)self::fromRequest('order_id'), self::TYPE_INT()); - self::requireMinLength('email', 4); - self::requireMaxLength('email', 255); - self::requireMinLength('order_id', 1); - self::requireMaxLength('order_id', 255); - // Check if the email is valid - if (!filter_var(self::fromRequest('email'), FILTER_VALIDATE_EMAIL)) { - $response->error('Invalid email', 400); - } - // Check if the order exists - $order = (new orders_o())->select((int)self::fromRequest('order_id')); - $order->requireSelected(); - if ($order->stripe_module_orders->exists()) { - $existingInvoice = []; - $shouldBlockInvoiceCreation = true; - try { - $existingInvoice = $order->stripe_module_orders->asArray(); - $retrievedInvoice = $order->stripe_module_orders->retrievePaymentLink(); - $existingStatus = (string)($retrievedInvoice->status ?? ''); - $isTerminalInvoiceState = (bool)($retrievedInvoice->paid ?? false) === true - || in_array($existingStatus, ['paid', 'void', 'uncollectible', 'deleted'], true); - $shouldBlockInvoiceCreation = !$isTerminalInvoiceState; - } catch (\Throwable) { - $shouldBlockInvoiceCreation = false; - } - - if ($shouldBlockInvoiceCreation) { - $response->error([ - 'message' => 'A Stripe payment link is already active for this order.', - 'code' => 'stripe_invoice_exists', - 'stripeModuleOrders' => $existingInvoice, - ], 409); - } - - $order->clearStripeInvoicing(); - } - // Log the action - (new logs_o())->add('modules_stripe', 'global', 1, $user->id, 'MODULES_STRIPE', 'User sent an invoice'); - // Create a customer account, if it doesn't exist - $hasCustomerAccount = (new stripe_module_customers_o())->doesCustomerHaveAccount((string)self::fromRequest('email')); - if (!$hasCustomerAccount) { - $customer = (new stripe())->customers->create((string)self::fromRequest('email')); - (new stripe_module_customers_o())->add((string)self::fromRequest('email'), $customer->id); - } else { - // Get the customer account by email - $customer = (new stripe())->customers->retrieve( - // Get the customer ID - (new stripe_module_customers_o()) - ->getCustomerId( - (string)self::fromRequest('email') - ) - ); - } - // Create an invoice - $invoice = (new stripe())->invoice->generate((int)self::fromRequest('order_id'), $customer->id); - // Set the order stripe invoice details - $order->setStripeInvoicing($invoice->id, $customer->id, $invoice->hosted_invoice_url); - $email = new email(); - $email->sendStripeInvoiceEmail( - (string)self::fromRequest('email'), - null, - $invoice->hosted_invoice_url, - (int)self::fromRequest('order_id') - ); - // Return the result - $response->success((object)$invoice); - } else { - (new logs_o())->add('modules_stripe', 'global', 0, 0, 'MODULES_STRIPE', 'User tried to send an invoice without a valid session'); - $response->error('Invalid session', 400); - } + $response->error([ + 'message' => 'Direct Stripe payment links by email are no longer available. Use card payment instead.', + 'code' => 'stripe_email_payment_disabled', + ], 410); }, [ - 'modules_stripe_invoice_send' => 'Send invoice' + 'modules_stripe_invoice_send' => 'Manage legacy Stripe invoices' ] ); diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index bfef0a70..7fa61878 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -644,6 +644,13 @@ class ordersRoute $this->isStripePaymentIntentReusable($storedPaymentIntent) && $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage ?? 0) ) { + if (strtolower((string)($storedPaymentIntent->status ?? '')) === 'requires_capture') { + $storedPaymentIntent = $this->captureApprovedStripePaymentIntent( + $order, + $stripePaymentIntents, + $stripe + ); + } (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, @@ -696,6 +703,9 @@ class ordersRoute try { $paymentIntent = $stripe->payment_intents->get($paymentIntent->id); $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); + if (strtolower((string)($paymentIntent->status ?? '')) === 'requires_capture') { + $paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe); + } } catch (\Stripe\Exception\InvalidRequestException) { // Keep the created intent payload if Stripe retrieve is temporarily unavailable. } @@ -760,6 +770,9 @@ class ordersRoute 'message' => 'No active payment intent for this order.', ])); } + if ($status === 'requires_capture') { + $paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe); + } $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents)); @@ -841,6 +854,7 @@ class ordersRoute if (!$order->exists()) { $response->error('Order not found', 400); } + self::requireDepartmentAccess((int)$order->department_id->value()); $stripePaymentIntents = new stripe_payment_intents_o(); if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { @@ -871,23 +885,7 @@ class ordersRoute $response->error('Payment intent is not ready to capture.', 409); } - try { - $paymentIntent = $stripe->payment_intents->capture( - $stripePaymentIntents->payment_intent_id->value(), - [] - ); - } catch (\Stripe\Exception\InvalidRequestException) { - $stripePaymentIntents->deletePermanently(); - $response->error('Stored payment intent is stale. Start the payment again.', 409); - } - - $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); - if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') { - $response->error('Payment intent is not ready to capture.', 409); - } - - $order_collection = $order->getOrderCollection(); - $order_collection->paidWithStripe($paymentIntent->id); + $paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe); (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CONFIRM_PAYMENT_INTENT', 'Successfully confirmed a payment intent (ID: ' . $data['id'] . ')'); $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents)); @@ -994,6 +992,31 @@ class ordersRoute ], true); } + private function captureApprovedStripePaymentIntent(orders_o $order, stripe_payment_intents_o $stripePaymentIntents, stripe $stripe): object + { + global $response; + + try { + $paymentIntent = $stripe->payment_intents->capture( + $stripePaymentIntents->payment_intent_id->value(), + [] + ); + } catch (\Stripe\Exception\InvalidRequestException) { + $stripePaymentIntents->deletePermanently(); + $response->error('Stored payment intent is stale. Start the payment again.', 409); + } + + $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); + if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') { + $response->error('Payment intent is not ready to capture.', 409); + } + + $order_collection = $order->getOrderCollection(); + $order_collection->paidWithStripe($paymentIntent->id); + + return $paymentIntent; + } + private function buildStripePaymentIntentResponse(?object $paymentIntent, ?stripe_payment_intents_o $storedIntent, array $extra = []): array { $paymentIntentPayload = null; diff --git a/services/nginx/app/tests/Api/StripeApiTest.php b/services/nginx/app/tests/Api/StripeApiTest.php index 735f307c..264cacea 100644 --- a/services/nginx/app/tests/Api/StripeApiTest.php +++ b/services/nginx/app/tests/Api/StripeApiTest.php @@ -2,19 +2,15 @@ declare(strict_types=1); -use classes\email; -use classes\stripe; use classes\stripe_fake_http_client; putenv('STRIPE_FAKE_MODE=1'); putenv('STRIPE_FAKE_STORE_PATH=' . sys_get_temp_dir() . '/truckwash-stripe-api-tests.json'); -putenv('EMAIL_FAKE_MODE=1'); usesApiSuite(); beforeEach(function (): void { stripe_fake_http_client::resetStore(); - email::resetFakeDeliveries(); if (api_tests_enabled()) { api_test_runtime()->db()->query("DELETE FROM stripe_module_orders WHERE invoice_id LIKE 'in_fake_%'"); @@ -47,7 +43,7 @@ it('returns a setup required error when department terminal readers are requeste ->toHaveKey('code', 'stripe_terminal_setup_required'); }); -it('sends a Stripe invoice by email and persists the hosted invoice association', function (): void { +it('rejects creating a Stripe hosted invoice by email because direct payment links are retired', function (): void { $department = api_fixtures()->createDepartment([ 'name' => 'Stripe Email Payments Department', ]); @@ -71,222 +67,19 @@ it('sends a Stripe invoice by email and persists the hosted invoice association' ], $session['headers']); $response - ->assertStatus(200) + ->assertStatus(410) ->assertEnvelope() - ->assertSuccess(true); - - expect($response->data()) - ->toBeArray() - ->toHaveKey('id') - ->toHaveKey('hosted_invoice_url') - ->toHaveKey('metadata'); - - expect($response->data()['metadata'] ?? []) - ->toMatchArray([ - 'order_id' => (string)$order['id'], - 'customer_id' => (string)$customer['customer_number'], - 'department_id' => (string)$department['id'], - 'reference' => 'STRIPE-EMAIL-SEND', - 'reg_1' => 'EMAIL01', - ]) - ->and(($response->data()['metadata']['stripe_customer_id'] ?? null)) - ->toBe((string)($response->data()['customer'] ?? '')); - - $retrievedInvoice = (new stripe())->invoice->retrieve((string)$response->data()['id']); - $retrievedMetadata = json_decode(json_encode($retrievedInvoice->metadata), true); - if (!is_array($retrievedMetadata)) { - $retrievedMetadata = []; - } - - expect($retrievedMetadata) - ->toMatchArray([ - 'order_id' => (string)$order['id'], - 'customer_id' => (string)$customer['customer_number'], - 'department_id' => (string)$department['id'], - 'reference' => 'STRIPE-EMAIL-SEND', - 'reg_1' => 'EMAIL01', - ]) - ->and(($retrievedMetadata['stripe_customer_id'] ?? null)) - ->toBe((string)($response->data()['customer'] ?? '')); + ->assertSuccess(false) + ->assertMessage('Direct Stripe payment links by email are no longer available. Use card payment instead.'); $stored = api_test_runtime()->queryOne( 'SELECT invoice_id, customer_id, url FROM stripe_module_orders WHERE id = ' . (int)$order['id'] ); - expect($stored) - ->not->toBeNull() - ->and($stored['invoice_id'] ?? null)->toBe((string)$response->data()['id']) - ->and($stored['customer_id'] ?? null)->not->toBe('') - ->and($stored['url'] ?? null)->toBe((string)$response->data()['hosted_invoice_url']); -}); - -it('returns a conflict when a Stripe hosted invoice is already active for the order', function (): void { - $department = api_fixtures()->createDepartment([ - 'name' => 'Stripe Email Guard Department', - ]); - $customer = api_fixtures()->createUser([ - 'display_name' => 'Stripe Duplicate Guard Customer', - ]); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'reference' => 'STRIPE-EMAIL-GUARD', - 'reg_1' => 'EMAIL02', - ]); - $session = api_fixtures()->createUserSession([ - 'modules_stripe_invoice_send', - ]); - $emailAddress = sprintf('stripe-duplicate-%d@example.com', (int)$order['id']); - - api_client()->post('/modules/stripe/invoice', [ - 'email' => $emailAddress, - 'order_id' => $order['id'], - ], $session['headers'])->assertStatus(200); - - $response = api_client()->post('/modules/stripe/invoice', [ - 'email' => $emailAddress, - 'order_id' => $order['id'], - ], $session['headers']); - - $response - ->assertStatus(409) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('A Stripe payment link is already active for this order.'); - expect($response->data()) ->toBeArray() - ->toHaveKey('code', 'stripe_invoice_exists'); -}); - -it('allows sending a new Stripe hosted invoice when the existing association is already terminal', function (): void { - $department = api_fixtures()->createDepartment([ - 'name' => 'Stripe Email Terminal Guard Department', - ]); - $customer = api_fixtures()->createUser([ - 'display_name' => 'Stripe Terminal Guard Customer', - ]); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'reference' => 'STRIPE-EMAIL-TERMINAL', - 'reg_1' => 'EMAIL05', - ]); - $session = api_fixtures()->createUserSession([ - 'modules_stripe_invoice_send', - ]); - $emailAddress = sprintf('stripe-terminal-%d@example.com', (int)$order['id']); - - $firstResponse = api_client()->post('/modules/stripe/invoice', [ - 'email' => $emailAddress, - 'order_id' => $order['id'], - ], $session['headers']); - - $firstInvoiceId = (string)($firstResponse->data()['id'] ?? ''); - stripe_fake_http_client::setInvoiceState($firstInvoiceId, [ - 'status' => 'void', - 'paid' => false, - ]); - - $secondResponse = api_client()->post('/modules/stripe/invoice', [ - 'email' => $emailAddress, - 'order_id' => $order['id'], - ], $session['headers']); - - $secondResponse - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(true); - - expect((string)($secondResponse->data()['id'] ?? '')) - ->not->toBe('') - ->not->toBe($firstInvoiceId); -}); - -it('voids an unpaid Stripe hosted invoice and clears the local order association', function (): void { - $department = api_fixtures()->createDepartment([ - 'name' => 'Stripe Email Cancel Department', - ]); - $customer = api_fixtures()->createUser([ - 'display_name' => 'Stripe Cancel Customer', - ]); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'reference' => 'STRIPE-EMAIL-CANCEL', - 'reg_1' => 'EMAIL03', - ]); - $session = api_fixtures()->createUserSession([ - 'modules_stripe_invoice_send', - ]); - $emailAddress = sprintf('stripe-cancel-%d@example.com', (int)$order['id']); - - $sendResponse = api_client()->post('/modules/stripe/invoice', [ - 'email' => $emailAddress, - 'order_id' => $order['id'], - ], $session['headers']); - $invoiceId = (string)($sendResponse->data()['id'] ?? ''); - - $response = api_client()->delete('/modules/stripe/invoice', [ - 'order_id' => $order['id'], - ], $session['headers']); - - $response - ->assertStatus(200) - ->assertEnvelope() - ->assertSuccess(true); - - expect(api_test_runtime()->queryOne( - 'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id'] - ))->toBeNull(); - - expect((new stripe())->invoice->retrieve($invoiceId)->status)->toBe('void'); -}); - -it('refuses to cancel a paid Stripe hosted invoice', function (): void { - $department = api_fixtures()->createDepartment([ - 'name' => 'Stripe Email Paid Department', - ]); - $customer = api_fixtures()->createUser([ - 'display_name' => 'Stripe Paid Customer', - ]); - $order = api_fixtures()->createOrder([ - 'customer_id' => $customer['customer_number'], - 'department_id' => $department['id'], - 'reference' => 'STRIPE-EMAIL-PAID', - 'reg_1' => 'EMAIL04', - ]); - $session = api_fixtures()->createUserSession([ - 'modules_stripe_invoice_send', - ]); - $emailAddress = sprintf('stripe-paid-%d@example.com', (int)$order['id']); - - $sendResponse = api_client()->post('/modules/stripe/invoice', [ - 'email' => $emailAddress, - 'order_id' => $order['id'], - ], $session['headers']); - $invoiceId = (string)($sendResponse->data()['id'] ?? ''); - - stripe_fake_http_client::setInvoiceState($invoiceId, [ - 'status' => 'paid', - 'paid' => true, - 'amount_due' => 0, - 'amount_paid' => 1000, - ]); - - $response = api_client()->delete('/modules/stripe/invoice', [ - 'order_id' => $order['id'], - ], $session['headers']); - - $response - ->assertStatus(409) - ->assertEnvelope() - ->assertSuccess(false) - ->assertMessage('A paid Stripe payment link cannot be cancelled.'); - - expect(api_test_runtime()->queryOne( - 'SELECT invoice_id FROM stripe_module_orders WHERE id = ' . (int)$order['id'] - ))->not->toBeNull(); + ->toHaveKey('code', 'stripe_email_payment_disabled') + ->and($stored)->toBeNull(); }); it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void { diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php index f8cda7d8..06387bc8 100644 --- a/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php +++ b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php @@ -1,7 +1,7 @@ not->toBeFalse(); @@ -26,10 +26,13 @@ it('wires mobile stripe payment intent routes to normalized lifecycle handling', expect($stripeSection)->toContain("Payment intent has already been captured."); expect($stripeSection)->toContain("Payment intent was cancelled. Start the payment again."); expect($stripeSection)->toContain("Payment intent is not ready to capture."); - expect($stripeSection)->toContain("paidWithStripe(\$paymentIntent->id);"); + expect($stripeSection)->toContain("captureApprovedStripePaymentIntent(\$order, \$stripePaymentIntents, \$stripe)"); + expect(substr_count($stripeSection, 'captureApprovedStripePaymentIntent('))->toBeGreaterThanOrEqual(4); 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('private function captureApprovedStripePaymentIntent(orders_o $order, stripe_payment_intents_o $stripePaymentIntents, stripe $stripe): object'); + expect($content)->toContain("paidWithStripe(\$paymentIntent->id);"); expect($content)->toContain('!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount'); expect($content)->toContain("\$storedTaxPercentage = \$metadata['tax_percentage'] ?? null;"); }); diff --git a/services/nginx/app/tests/Unit/Stripe/ModuleStripePaymentLinkRetirementWiringTest.php b/services/nginx/app/tests/Unit/Stripe/ModuleStripePaymentLinkRetirementWiringTest.php new file mode 100644 index 00000000..242d754f --- /dev/null +++ b/services/nginx/app/tests/Unit/Stripe/ModuleStripePaymentLinkRetirementWiringTest.php @@ -0,0 +1,31 @@ +not->toBeFalse(); + + $postStart = strpos($content, "\$this->post('/modules/stripe/invoice'"); + $deleteStart = strpos($content, "\$this->delete('/modules/stripe/invoice'"); + + expect($postStart)->not->toBeFalse() + ->and($deleteStart)->not->toBeFalse() + ->and($deleteStart)->toBeGreaterThan($postStart); + + $retiredCreationRoute = substr($content, $postStart, $deleteStart - $postStart); + + expect($retiredCreationRoute) + ->toContain("'code' => 'stripe_email_payment_disabled'") + ->toContain('], 410);') + ->not->toContain('invoice->generate(') + ->not->toContain('sendStripeInvoiceEmail(') + ->not->toContain('setStripeInvoicing('); + + $legacyCleanupRoute = substr($content, $deleteStart); + + expect($legacyCleanupRoute) + ->toContain('retrievePaymentLink()') + ->toContain('invoice->void(') + ->toContain('clearStripeInvoicing()'); +});