## 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>
195 lines
12 KiB
PHP
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');
|
|
}
|
|
});
|