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
This commit is contained in:
Jeppe B
2026-07-27 19:09:37 +02:00
committed by GitHub
parent d3e4798b11
commit 30d860fdef
5 changed files with 88 additions and 310 deletions
@@ -3,14 +3,12 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\email;
use classes\response; use classes\response;
use classes\router; use classes\router;
use classes\stripe; use classes\stripe;
use objects\departments_o; use objects\departments_o;
use objects\logs_o; use objects\logs_o;
use objects\orders_o; use objects\orders_o;
use objects\stripe_module_customers_o;
use traits\route_t; use traits\route_t;
class moduleStripeRoute 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 () { $this->post('/modules/stripe/invoice', function () {
global $response; global $response;
self::requirePermission('modules_stripe_invoice_send'); self::requirePermission('modules_stripe_invoice_send');
$user = (new authentication())->get_user(); $response->error([
if ($user) { 'message' => 'Direct Stripe payment links by email are no longer available. Use card payment instead.',
self::requireParameters(['email', 'order_id']); 'code' => 'stripe_email_payment_disabled',
self::requireType(self::fromRequest('email'), 'string'); ], 410);
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);
}
}, },
[ [
'modules_stripe_invoice_send' => 'Send invoice' 'modules_stripe_invoice_send' => 'Manage legacy Stripe invoices'
] ]
); );
+40 -17
View File
@@ -644,6 +644,13 @@ class ordersRoute
$this->isStripePaymentIntentReusable($storedPaymentIntent) $this->isStripePaymentIntentReusable($storedPaymentIntent)
&& $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage ?? 0) && $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'] . ')'); (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, [ $response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [
'reused' => true, 'reused' => true,
@@ -696,6 +703,9 @@ class ordersRoute
try { try {
$paymentIntent = $stripe->payment_intents->get($paymentIntent->id); $paymentIntent = $stripe->payment_intents->get($paymentIntent->id);
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
if (strtolower((string)($paymentIntent->status ?? '')) === 'requires_capture') {
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
}
} catch (\Stripe\Exception\InvalidRequestException) { } catch (\Stripe\Exception\InvalidRequestException) {
// Keep the created intent payload if Stripe retrieve is temporarily unavailable. // 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.', 'message' => 'No active payment intent for this order.',
])); ]));
} }
if ($status === 'requires_capture') {
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
}
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
$response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents)); $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents));
@@ -841,6 +854,7 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $response->error('Order not found', 400);
} }
self::requireDepartmentAccess((int)$order->department_id->value());
$stripePaymentIntents = new stripe_payment_intents_o(); $stripePaymentIntents = new stripe_payment_intents_o();
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
@@ -871,23 +885,7 @@ class ordersRoute
$response->error('Payment intent is not ready to capture.', 409); $response->error('Payment intent is not ready to capture.', 409);
} }
try { $paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
$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);
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CONFIRM_PAYMENT_INTENT', 'Successfully confirmed a payment intent (ID: ' . $data['id'] . ')'); (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)); $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents));
@@ -994,6 +992,31 @@ class ordersRoute
], true); ], 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 private function buildStripePaymentIntentResponse(?object $paymentIntent, ?stripe_payment_intents_o $storedIntent, array $extra = []): array
{ {
$paymentIntentPayload = null; $paymentIntentPayload = null;
+6 -213
View File
@@ -2,19 +2,15 @@
declare(strict_types=1); declare(strict_types=1);
use classes\email;
use classes\stripe;
use classes\stripe_fake_http_client; use classes\stripe_fake_http_client;
putenv('STRIPE_FAKE_MODE=1'); putenv('STRIPE_FAKE_MODE=1');
putenv('STRIPE_FAKE_STORE_PATH=' . sys_get_temp_dir() . '/truckwash-stripe-api-tests.json'); putenv('STRIPE_FAKE_STORE_PATH=' . sys_get_temp_dir() . '/truckwash-stripe-api-tests.json');
putenv('EMAIL_FAKE_MODE=1');
usesApiSuite(); usesApiSuite();
beforeEach(function (): void { beforeEach(function (): void {
stripe_fake_http_client::resetStore(); stripe_fake_http_client::resetStore();
email::resetFakeDeliveries();
if (api_tests_enabled()) { if (api_tests_enabled()) {
api_test_runtime()->db()->query("DELETE FROM stripe_module_orders WHERE invoice_id LIKE 'in_fake_%'"); 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'); ->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([ $department = api_fixtures()->createDepartment([
'name' => 'Stripe Email Payments Department', 'name' => 'Stripe Email Payments Department',
]); ]);
@@ -71,222 +67,19 @@ it('sends a Stripe invoice by email and persists the hosted invoice association'
], $session['headers']); ], $session['headers']);
$response $response
->assertStatus(200) ->assertStatus(410)
->assertEnvelope() ->assertEnvelope()
->assertSuccess(true); ->assertSuccess(false)
->assertMessage('Direct Stripe payment links by email are no longer available. Use card payment instead.');
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'] ?? ''));
$stored = api_test_runtime()->queryOne( $stored = api_test_runtime()->queryOne(
'SELECT invoice_id, customer_id, url FROM stripe_module_orders WHERE id = ' . (int)$order['id'] '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()) expect($response->data())
->toBeArray() ->toBeArray()
->toHaveKey('code', 'stripe_invoice_exists'); ->toHaveKey('code', 'stripe_email_payment_disabled')
}); ->and($stored)->toBeNull();
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();
}); });
it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void { it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void {
@@ -1,7 +1,7 @@
<?php <?php
it('wires mobile stripe payment intent routes to normalized lifecycle handling', function (): void { it('wires mobile stripe payment intent routes to normalized lifecycle handling', function (): void {
$routeFile = app_path('routes/ordersRoute.php'); $routeFile = dirname(__DIR__, 3) . '/routes/ordersRoute.php';
$content = file_get_contents($routeFile); $content = file_get_contents($routeFile);
expect($content)->not->toBeFalse(); expect($content)->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 has already been captured.");
expect($stripeSection)->toContain("Payment intent was cancelled. Start the payment again."); expect($stripeSection)->toContain("Payment intent was cancelled. Start the payment again.");
expect($stripeSection)->toContain("Payment intent is not ready to capture."); 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($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 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('!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount');
expect($content)->toContain("\$storedTaxPercentage = \$metadata['tax_percentage'] ?? null;"); expect($content)->toContain("\$storedTaxPercentage = \$metadata['tax_percentage'] ?? null;");
}); });
@@ -0,0 +1,31 @@
<?php
it('keeps direct Stripe payment-link creation retired while preserving legacy cleanup', function (): void {
$routeFile = dirname(__DIR__, 3) . '/routes/moduleStripeRoute.php';
$content = file_get_contents($routeFile);
expect($content)->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()');
});