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:
@@ -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'
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+5
-2
@@ -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()');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user