Files
api/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php
T
Jeppe BandJeppe Bundgaard 42ddce84bc Serialize VAT collection mutations with payment operations (#326)
## Summary

- Makes Stripe Terminal card payment intents always use 25% moms in the
API, independent of any client-supplied `tax_percentage`.
- Updates amount calculation, metadata persistence, stored-intent reuse
matching, the authoritative OpenAPI contracts, and operation-specific
Writerside outputs.
- Prevents double charging and false order closure across stale,
concurrently succeeded, partially recorded, or mismatched intents.
- Serializes payment create/capture/closure with order-item changes and
every order-to-invoice-collection reassignment through shared database
locks.
- Converts expected lock contention and reconciliation cases into
deliberate 409 responses.

## Exact-head evidence

Current head: `3a0f70d315a94d2efe586a2188d2c54f8ff11cd4`

- PHP syntax passed for all changed runtime files.
- Focused Orders suite: **42 tests / 293 assertions passed**.
- `git diff --check` passed.
- Fresh exact-head Tests and Qodana are running.
- Every Codex finding has a concrete reply; a fresh exact-head review is
requested below.

## Safety behavior

- Caller-controlled VAT is absent from request contracts; fixed 25% moms
is server-owned.
- A succeeded payment is preserved, requires the full expected
`amount_received`, and cannot close a changed/mismatched or
already-claimed collection.
- A compatible partially recorded Stripe closure is completed
idempotently; conflicting partial state fails closed for manual
reconciliation.
- Every cancellation/delete caller honors a concurrent-success result
and never falsely reports a completed payment as cleared.
- Price changes and invoice-collection reassignment share the payment
lock through validation, capture, post-capture reload, and closure.
- Reader changes are persisted only for reusable matching intents, so
stale intent cancellation targets the original terminal.
- Accepted legacy succeeded intents normalize stored tax to 25% before
response construction.

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-28 22:00:59 +02:00

195 lines
12 KiB
PHP

<?php
it('wires mobile stripe payment intent routes to normalized lifecycle handling', function (): void {
$routeFile = dirname(__DIR__, 3) . '/routes/ordersRoute.php';
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
$start = strpos($content, "\$this->post('/orders/module/stripe/payment_intent'");
$end = strpos($content, "\$this->post('/orders/module/stripe/debug/simulate_payment'");
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
$stripeSection = substr($content, (int)$start, (int)$end - (int)$start);
expect($stripeSection)->toContain('buildStripePaymentIntentResponse(');
expect($stripeSection)->toContain('$tax_percentage = self::CARD_PAYMENT_TAX_PERCENTAGE;');
expect($stripeSection)->toContain('$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage);');
expect($stripeSection)->toContain('isStripePaymentIntentReusable($storedPaymentIntent)');
expect($stripeSection)->toContain('doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage)');
expect($stripeSection)->toContain("\$storedPaymentIntentStatus = strtolower((string)(\$storedPaymentIntent->status ?? ''));");
expect($stripeSection)->toContain("if (\$storedPaymentIntentStatus === 'succeeded')");
expect($stripeSection)->toContain("'code' => 'stripe_payment_reconciliation_conflict'");
expect($stripeSection)->toContain('$this->recordSucceededStripePayment($order, $storedPaymentIntent);');
expect($stripeSection)->toContain("'already_succeeded' => true");
expect($stripeSection)->toContain('if (!$stripePaymentIntents->delete())');
expect($stripeSection)->toContain('$stripePaymentIntents->tax_percentage->set($tax_percentage);');
expect($stripeSection)->toContain('[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);');
expect(substr_count($stripeSection, 'if (!$stripePaymentIntents->delete())'))->toBeGreaterThanOrEqual(3);
$succeededPosition = strpos($stripeSection, "if (\$storedPaymentIntentStatus === 'succeeded')");
$matchPosition = strpos(
$stripeSection,
'doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage)'
);
$deletePosition = strpos($stripeSection, 'if (!$stripePaymentIntents->delete())');
$readerUpdatePosition = strpos($stripeSection, '$stripePaymentIntents->setReaderId($readerId);');
expect($succeededPosition)->not->toBeFalse();
expect($matchPosition)->not->toBeFalse();
expect($deletePosition)->not->toBeFalse();
expect($readerUpdatePosition)->not->toBeFalse();
expect($succeededPosition)->toBeLessThan($matchPosition);
expect($readerUpdatePosition)->toBeGreaterThan($matchPosition);
expect($deletePosition)->toBeGreaterThan($matchPosition);
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.'");
expect($stripeSection)->toContain("Stored payment intent is stale. Start the payment again.");
expect($stripeSection)->toContain("'already_succeeded' => true");
expect($stripeSection)->toContain("Payment intent was cancelled. Start the payment again.");
expect($stripeSection)->toContain("Payment intent is not ready to capture.");
expect($stripeSection)->toContain('captureApprovedStripePaymentIntent(');
expect(substr_count($stripeSection, 'captureApprovedStripePaymentIntent('))->toBeGreaterThanOrEqual(4);
expect($stripeSection)->not->toContain("Order does not have a payment intent");
expect($content)->toContain('private const CARD_PAYMENT_TAX_PERCENTAGE = 25;');
expect($content)->toContain('private function doesStripePaymentIntentMatchOrder(object $paymentIntent, int $expectedAmount, ?int $tax_percentage): bool');
expect($content)->toContain('private function captureApprovedStripePaymentIntent(');
expect($content)->toContain('order_payment_lock $orderPaymentLock');
expect($content)->toContain('private function acquireStripePaymentLocks(orders_o $order): array');
expect($content)->toContain('order_payment_lock::tryAcquireOrderMutation((int)$order->id)');
expect($content)->toContain("'code' => 'stripe_payment_collection_missing'");
expect($content)->toContain("'code' => 'order_payment_locked'");
expect($content)->toContain("'code' => 'stripe_payment_intent_contract_mismatch'");
expect($content)->toContain("'code' => 'stripe_payment_reconciliation_conflict'");
expect($content)->toContain('$expectedAmount = $this->getStripePaymentIntentAmountForOrder(');
$captureHelperStart = strpos($content, 'private function captureApprovedStripePaymentIntent(');
$captureHelperEnd = strpos($content, 'private function buildStripePaymentIntentResponse(', (int)$captureHelperStart);
expect($captureHelperStart)->not->toBeFalse();
expect($captureHelperEnd)->not->toBeFalse();
$captureHelper = substr($content, (int)$captureHelperStart, (int)$captureHelperEnd - (int)$captureHelperStart);
$contractValidationPosition = strpos($captureHelper, 'doesStripePaymentIntentMatchOrder(');
$stripeCapturePosition = strpos($captureHelper, '$stripe->payment_intents->capture(');
expect($contractValidationPosition)->not->toBeFalse();
expect($stripeCapturePosition)->not->toBeFalse();
expect($contractValidationPosition)->toBeLessThan($stripeCapturePosition);
$collectionValidationPosition = strpos($captureHelper, '$this->requireStripePaymentCollectionAvailable($order);');
expect($collectionValidationPosition)->not->toBeFalse();
expect($collectionValidationPosition)->toBeLessThan($stripeCapturePosition);
expect($content)->toContain('private function recordSucceededStripePayment(orders_o $order, object $paymentIntent): void');
expect($content)->toContain('$isExactRecordedPayment = (');
expect($content)->toContain('$isCompatiblePartialPayment = (');
expect($content)->toContain('if ($isCompatiblePartialPayment)');
expect($content)->toContain('$orderCollection->paidWithStripe((string)$paymentIntent->id);');
expect($content)->toContain('!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount');
expect($content)->toContain('!isset($paymentIntent->amount_received) || (int)$paymentIntent->amount_received !== $expectedAmount');
expect($captureHelper)->toContain('$expectedAmountAfterCapture = $this->getStripePaymentIntentAmountForOrder(');
expect($content)->toContain("\$storedTaxPercentage = \$metadata['tax_percentage'] ?? null;");
expect($content)->toContain('return (int)$storedTaxPercentage === ($tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE);');
$updateOrderStart = strpos($content, '#[NoReturn] private function updateOrder(): void');
expect($updateOrderStart)->not->toBeFalse();
$updateOrderSection = substr($content, (int)$updateOrderStart);
expect($updateOrderSection)->toContain(
'$orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id);'
);
});
it('serializes order price mutations with card payment capture', function (): void {
$routeFile = dirname(__DIR__, 3) . '/routes/orderItemsRoute.php';
$lockFile = dirname(__DIR__, 3) . '/classes/order_payment_lock.php';
$routeContent = file_get_contents($routeFile);
$lockContent = file_get_contents($lockFile);
expect($routeContent)->not->toBeFalse()
->and($lockContent)->not->toBeFalse();
expect($routeContent)->toContain('use classes\order_payment_lock;');
expect(substr_count($routeContent, '$this->acquireOrderPaymentLock('))->toBe(3);
expect($routeContent)->toContain("'code' => 'order_payment_locked'");
expect($lockContent)->toContain('public static function tryAcquire(int $orderId): ?self');
expect($lockContent)->toContain('public static function tryAcquireInvoiceCollection(int $invoiceCollectionId): ?self');
expect($lockContent)->toContain('catch (UnexpectedValueException)');
expect($lockContent)->toContain("SELECT GET_LOCK(?, ?) AS acquired");
expect($lockContent)->toContain("SELECT RELEASE_LOCK(?)");
expect($lockContent)->toContain('public function __destruct()');
$ordersObjectFile = dirname(__DIR__, 3) . '/objects/orders_o.php';
$orderItemsObjectFile = dirname(__DIR__, 3) . '/objects/order_items_o.php';
$collectionsObjectFile = dirname(__DIR__, 3) . '/objects/collected_order_invoices_o.php';
$bulkActionFile = dirname(__DIR__, 3) . '/classes/invoice_collection_bulk_action_service.php';
$orderInvoicesRouteFile = dirname(__DIR__, 3) . '/routes/orderInvoicesRoute.php';
$ordersObjectContent = file_get_contents($ordersObjectFile);
$orderItemsObjectContent = file_get_contents($orderItemsObjectFile);
$collectionsObjectContent = file_get_contents($collectionsObjectFile);
$bulkActionContent = file_get_contents($bulkActionFile);
$orderInvoicesRouteContent = file_get_contents($orderInvoicesRouteFile);
expect($ordersObjectContent)->not->toBeFalse()
->and($orderItemsObjectContent)->not->toBeFalse()
->and($collectionsObjectContent)->not->toBeFalse()
->and($bulkActionContent)->not->toBeFalse()
->and($orderInvoicesRouteContent)->not->toBeFalse();
$assignmentStart = strpos($ordersObjectContent, 'public function assignToInvoiceCollection(');
expect($assignmentStart)->not->toBeFalse();
$assignmentSection = substr($ordersObjectContent, (int)$assignmentStart, 2400);
expect($assignmentSection)->toContain('order_payment_lock::tryAcquireReassignment(');
expect($assignmentSection)->toContain("'code' => 'order_payment_locked'");
expect($lockContent)->toContain('public static function tryAcquireOrderMutations(array $orderIds): ?self');
expect($lockContent)->toContain('public static function tryAcquireReassignment(');
expect($lockContent)->toContain('public static function tryAcquireInvoiceCollections(');
expect($lockContent)->toContain('public static function tryAcquireInvoiceCollectionWithOrders(');
expect($orderItemsObjectContent)->toContain(
'order_payment_lock::tryAcquireOrderMutations($orderIds)'
);
expect($collectionsObjectContent)->toContain(
'order_payment_lock::tryAcquireInvoiceCollectionWithOrders((int)$this->id)'
);
expect($bulkActionContent)->toContain(
'order_payment_lock::tryAcquireInvoiceCollections('
);
expect($orderInvoicesRouteContent)->not->toContain(
'$order->customer_id->set((int)$new_invoice_collection->customer_number->value());'
);
expect($collectionsObjectContent)->toContain(
'$order_object->assignToInvoiceCollection((int)$tmp->id);'
);
});
it('documents fixed card payment moms in the OpenAPI request contract', function (): void {
$openApiFiles = array_values(array_filter([
dirname(__DIR__, 6) . '/openapi.yaml',
dirname(__DIR__, 3) . '/openapi.yaml',
dirname(__DIR__, 6) . '/documentation/generated/openapi.json',
], static fn (string $path): bool => file_exists($path)));
expect($openApiFiles)->not->toBeEmpty();
foreach ($openApiFiles as $openApiFile) {
$content = file_get_contents($openApiFile);
expect($content)->not->toBeFalse();
$isJson = str_ends_with($openApiFile, '.json');
$start = strpos(
$content,
$isJson
? '"/orders/module/stripe/payment_intent":'
: '/orders/module/stripe/payment_intent:'
);
$end = strpos(
$content,
$isJson
? '"/orders/module/stripe/payment_intent/capture":'
: ' /orders/module/stripe/payment_intent/capture:',
(int)$start
);
expect($start)->not->toBeFalse();
expect($end)->not->toBeFalse();
$paymentIntentSection = substr($content, (int)$start, (int)$end - (int)$start);
expect($paymentIntentSection)->toContain('fixed 25% moms');
expect($paymentIntentSection)->not->toContain('tax_percentage');
}
});