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>
This commit is contained in:
Jeppe B
2026-07-28 22:00:59 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent da0113e3ed
commit 42ddce84bc
16 changed files with 929 additions and 88 deletions
@@ -82,6 +82,18 @@ class invoice_collection_bulk_action_service
if (!empty($freshPreview['blockers'])) {
throw new Exception('Action cannot be applied while blockers are present.');
}
$lockedCollectionIds = [
...$invoiceCollectionIds,
(int)($options['target_invoice_collection_id'] ?? 0),
];
$paymentMutationLock = order_payment_lock::tryAcquireInvoiceCollections(
$lockedCollectionIds
);
if ($paymentMutationLock === null) {
throw new Exception(
'An invoice collection is currently being changed or paid. Try again.'
);
}
$db->conn()->begin_transaction();
try {
@@ -0,0 +1,241 @@
<?php
namespace classes;
use RuntimeException;
use UnexpectedValueException;
final class order_payment_lock
{
private const LOCK_TIMEOUT_SECONDS = 10;
private const ORDER_RESOURCE = 'order-payment-v1';
private const INVOICE_COLLECTION_RESOURCE = 'invoice-collection-payment-v1';
/** @var list<string> */
private array $lockNames = [];
public static function tryAcquire(int $orderId): ?self
{
return self::tryAcquireResource(self::ORDER_RESOURCE, $orderId);
}
public static function tryAcquireInvoiceCollection(int $invoiceCollectionId): ?self
{
return self::tryAcquireResource(self::INVOICE_COLLECTION_RESOURCE, $invoiceCollectionId);
}
public static function tryAcquireOrderMutation(int $orderId): ?self
{
return self::tryAcquireOrderMutations([$orderId]);
}
/**
* @param list<int> $orderIds
*/
public static function tryAcquireOrderMutations(array $orderIds): ?self
{
$ids = array_values(array_unique(array_filter(
array_map('intval', $orderIds),
static fn(int $id): bool => $id > 0
)));
sort($ids, SORT_NUMERIC);
$collectionByOrder = [];
foreach ($ids as $id) {
$collectionByOrder[$id] = self::invoiceCollectionIdForOrder($id);
}
$collectionIds = array_values(array_unique(array_filter(
$collectionByOrder,
static fn(int $id): bool => $id > 0
)));
sort($collectionIds, SORT_NUMERIC);
$resources = array_map(
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
$collectionIds
);
array_push(
$resources,
...array_map(
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
$ids
)
);
$lock = self::tryAcquireNames($resources);
if ($lock !== null) {
foreach ($collectionByOrder as $id => $invoiceCollectionId) {
if (self::invoiceCollectionIdForOrder($id) !== $invoiceCollectionId) {
$lock->release();
return null;
}
}
}
return $lock;
}
public static function tryAcquireReassignment(int $orderId, int $targetInvoiceCollectionId): ?self
{
$sourceInvoiceCollectionId = self::invoiceCollectionIdForOrder($orderId);
$collectionIds = array_values(array_unique(array_filter([
$sourceInvoiceCollectionId,
$targetInvoiceCollectionId,
], static fn(int $id): bool => $id > 0)));
sort($collectionIds, SORT_NUMERIC);
$resources = array_map(
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
$collectionIds
);
$resources[] = self::resourceName(self::ORDER_RESOURCE, $orderId);
$lock = self::tryAcquireNames($resources);
if ($lock !== null && self::invoiceCollectionIdForOrder($orderId) !== $sourceInvoiceCollectionId) {
$lock->release();
return null;
}
return $lock;
}
/**
* @param list<int> $invoiceCollectionIds
*/
public static function tryAcquireInvoiceCollections(array $invoiceCollectionIds): ?self
{
$ids = array_values(array_unique(array_filter(
array_map('intval', $invoiceCollectionIds),
static fn(int $id): bool => $id > 0
)));
sort($ids, SORT_NUMERIC);
return self::tryAcquireNames(array_map(
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
$ids
));
}
public static function tryAcquireInvoiceCollectionWithOrders(int $invoiceCollectionId): ?self
{
$lock = self::tryAcquireInvoiceCollection($invoiceCollectionId);
if ($lock === null) {
return null;
}
try {
$lock->acquireNames(array_map(
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
self::orderIdsForInvoiceCollection($invoiceCollectionId)
));
return $lock;
} catch (UnexpectedValueException) {
$lock->release();
return null;
}
}
private static function tryAcquireResource(string $resource, int $resourceId): ?self
{
return self::tryAcquireNames([self::resourceName($resource, $resourceId)]);
}
/**
* @param list<string> $lockNames
*/
private static function tryAcquireNames(array $lockNames): ?self
{
try {
return new self($lockNames);
} catch (UnexpectedValueException) {
return null;
}
}
private static function resourceName(string $resource, int $resourceId): string
{
if ($resourceId <= 0) {
throw new RuntimeException('A valid resource ID is required for the payment lock.');
}
return $resource . ':' . $resourceId;
}
private static function invoiceCollectionIdForOrder(int $orderId): int
{
global $db;
if ($orderId <= 0) {
throw new RuntimeException('A valid order ID is required for the payment lock.');
}
$result = $db->query(
'SELECT `invoice_collection_id` FROM `orders` WHERE `id` = ' . $orderId . ' LIMIT 1'
);
$row = $result ? $result->fetch_assoc() : null;
return (int)($row['invoice_collection_id'] ?? 0);
}
/**
* @return list<int>
*/
private static function orderIdsForInvoiceCollection(int $invoiceCollectionId): array
{
global $db;
$result = $db->query(
'SELECT `id` FROM `orders` WHERE `invoice_collection_id` = '
. $invoiceCollectionId . ' ORDER BY `id` ASC'
);
$ids = [];
while ($result && ($row = $result->fetch_assoc())) {
$ids[] = (int)$row['id'];
}
return $ids;
}
/**
* @param list<string> $lockNames
*/
private function __construct(array $lockNames)
{
$this->acquireNames($lockNames);
}
/**
* @param list<string> $lockNames
*/
private function acquireNames(array $lockNames): void
{
global $db;
foreach ($lockNames as $lockName) {
if (in_array($lockName, $this->lockNames, true)) {
continue;
}
$statement = $db->prepare('SELECT GET_LOCK(?, ?) AS acquired');
if ($statement === false) {
$this->release();
throw new RuntimeException('Unable to prepare the order payment lock.');
}
$timeout = self::LOCK_TIMEOUT_SECONDS;
$statement->bind_param('si', $lockName, $timeout);
$statement->execute();
$result = $statement->get_result()->fetch_assoc();
$statement->close();
if ((int)($result['acquired'] ?? 0) !== 1) {
$this->release();
throw new UnexpectedValueException(
'The order or invoice collection is currently being changed or paid. Try again.'
);
}
$this->lockNames[] = $lockName;
}
}
public function release(): void
{
global $db;
foreach (array_reverse($this->lockNames) as $lockName) {
$statement = $db->prepare('SELECT RELEASE_LOCK(?)');
if ($statement !== false) {
$statement->bind_param('s', $lockName);
$statement->execute();
$statement->close();
}
}
$this->lockNames = [];
}
public function __destruct()
{
$this->release();
}
}
@@ -161,9 +161,18 @@ trait selfserve_lane_invoice_t
// If the time exceeds the included minutes, reduce the quantity by the included minutes, if not delete the order item
if ($this->billable_minutes_order_item->quantity->value() > $this->machine_wash_minutes_included) {
$this->billable_minutes_order_item->quantity->set($this->billable_minutes_order_item->quantity->value() - $this->machine_wash_minutes_included);
(new order_items_o())->updateOrderItem(
(int)$this->billable_minutes_order_item->id,
(int)$this->billable_minutes_order_item->price->value(),
(string)$this->billable_minutes_order_item->notes->value(),
(string)$this->billable_minutes_order_item->reference->value(),
(int)$this->billable_minutes_order_item->quantity->value()
- $this->machine_wash_minutes_included
);
} else {
$this->billable_minutes_order_item->delete();
(new order_items_o())->removeOrderItem(
(int)$this->billable_minutes_order_item->id
);
}
return true;
@@ -5,6 +5,7 @@ namespace objects;
use classes\db;
use classes\economic;
use classes\object_property;
use classes\order_payment_lock;
use classes\stripe;
use config\economic_admin_fee_monthly_c;
use config\economic_admin_fee_order_c;
@@ -419,6 +420,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Require the customer number to be set
if (empty($this->customer_number->value())) {
throw new Exception('Customer number is not set');
@@ -681,6 +683,7 @@ class collected_order_invoices_o extends db
global $db;
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
self::requireValidCustomer((string)$target_customer_number);
if ($target_customer_number <= 0) {
@@ -1084,6 +1087,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Require the invoice collection to be open
self::requireOpen();
// Close the invoice collection
@@ -1139,6 +1143,7 @@ class collected_order_invoices_o extends db
public function split(): void
{
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Determine the processor type
switch ($this->processor->value()) {
case null:
@@ -1182,7 +1187,7 @@ class collected_order_invoices_o extends db
$tmp->closed_at->set($closed_at);
}
// Set the order to the new invoice collection
$order_object->invoice_collection_id->set($tmp->id);
$order_object->assignToInvoiceCollection((int)$tmp->id);
}
// Invalidate the cache for the invoice collection
$this->objectChanged();
@@ -1199,6 +1204,7 @@ class collected_order_invoices_o extends db
global $db;
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
$this->requireCanSplitByOrderMonth();
$orders_by_month = $this->getIncludedOrdersGroupedByCreatedMonth();
@@ -1396,6 +1402,7 @@ class collected_order_invoices_o extends db
public function addVehicleSubscriptionsTransaction(): void
{
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
self::removeVehicleSubscriptionsTransactions();
// Get the orders in the invoice collection
$orders = self::getOrders();
@@ -1466,7 +1473,7 @@ class collected_order_invoices_o extends db
'',
10,
);
$transaction->invoice_collection_id->set($this->id);
$transaction->assignToInvoiceCollection((int)$this->id, false);
$transaction->created_at->set(self::getFirstDayOfMonth($this->created_at->value()));
$transaction->objectChanged();
$this->objectChanged();
@@ -1645,6 +1652,7 @@ class collected_order_invoices_o extends db
public function removeSpecialArrangements(): void
{
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
self::removeVehicleSubscriptionsTransactions();
// Invalidate the cache for the invoice collection
$this->objectChanged();
@@ -1660,6 +1668,7 @@ class collected_order_invoices_o extends db
public function setAllItemsToBeIncludedInInvoice(): void
{
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Get the orders in the invoice collection
$orders = self::getOrders();
// Check if there are any orders in the invoice collection
@@ -1699,6 +1708,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Require the invoice collection to be open
self::requireOpen();
// Require the processor to be Stripe (or null)
@@ -1725,6 +1735,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Get the orders in the invoice collection
$orders = self::getOrders();
self::removeVehicleSubscriptionsTransactions();
@@ -1737,7 +1748,7 @@ class collected_order_invoices_o extends db
'',
10,
);
$transaction->invoice_collection_id->set($this->id);
$transaction->assignToInvoiceCollection((int)$this->id, false);
$transaction->created_at->set(self::getFirstDayOfMonth($this->created_at->value()));
$transaction->objectChanged();
// Add an order item to the transaction with a fixed price
@@ -1786,6 +1797,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Clear the external_id and processor fields
$this->external_id->set(null);
$this->processor->set(null);
@@ -1805,6 +1817,7 @@ class collected_order_invoices_o extends db
public function resetPricesOfItemsNotIncludedInInvoice(): void
{
self::requireSelected();
$paymentMutationLock = $this->acquirePaymentMutationLock();
// Get the orders in the invoice collection
$orders = self::getOrders();
// Check if there are any orders in the invoice collection
@@ -1839,6 +1852,17 @@ class collected_order_invoices_o extends db
$this->objectChanged();
}
private function acquirePaymentMutationLock(): order_payment_lock
{
$lock = order_payment_lock::tryAcquireInvoiceCollectionWithOrders((int)$this->id);
if ($lock === null) {
throw new Exception(
'The invoice collection is currently being changed or paid. Try again.'
);
}
return $lock;
}
/**
* @throws Exception
*/
@@ -5,6 +5,7 @@ namespace objects;
use classes\db;
use classes\customer_order_product_policy;
use classes\object_property;
use classes\order_payment_lock;
use Exception;
use RuntimeException;
use traits\db_object_t;
@@ -126,6 +127,7 @@ class order_items_o extends db
{
global $db, $response;
try {
$paymentMutationLock = self::acquirePaymentMutationLock([$order_id]);
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
// Avoid SQL injection
@@ -180,6 +182,12 @@ class order_items_o extends db
global $db, $response;
$this->id = $id;
try {
$this->getOrderItemById($id);
self::requireSelected();
$paymentMutationLock = self::acquirePaymentMutationLock([
(int)$this->order_id->value(),
$order_id,
]);
// Avoid SQL injection
$reference = $db->escape_string($reference);
$notes = $db->escape_string($notes);
@@ -200,6 +208,7 @@ class order_items_o extends db
{
global $db, $response;
try {
$paymentMutationLock = self::acquirePaymentMutationLock([$order_id]);
// Get the order
$order = (new orders_o())->getOrderById($order_id);
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
@@ -254,6 +263,10 @@ class order_items_o extends db
global $db;
$this->id = $id;
$this->select($this->id);
$this->requireSelected();
$paymentMutationLock = self::acquirePaymentMutationLock([
(int)$this->order_id->value(),
]);
// Inform the order object that an item has been removed
$this->getOrder()->objectChanged();
$sql = "DELETE FROM $this->table WHERE id = $this->id or related_item_id = $this->id";
@@ -307,6 +320,9 @@ class order_items_o extends db
$this->select((int)$id);
$this->requireSelected();
try {
$paymentMutationLock = self::acquirePaymentMutationLock([
(int)$this->order_id->value(),
]);
// Avoid SQL injection
$price = $db->escape_string($price);
$notes = $db->escape_string($notes);
@@ -324,6 +340,20 @@ class order_items_o extends db
}
}
/**
* @param list<int> $orderIds
*/
private static function acquirePaymentMutationLock(array $orderIds): order_payment_lock
{
$lock = order_payment_lock::tryAcquireOrderMutations($orderIds);
if ($lock === null) {
throw new RuntimeException(
'The order or invoice collection is currently being changed or paid. Try again.'
);
}
return $lock;
}
/**
* Get the list of order items based on the criteria.
* @param int[] $department_ids
+32 -3
View File
@@ -9,6 +9,7 @@ use classes\db;
use classes\department_wash_count_service;
use classes\email;
use classes\invoicing_period_utils;
use classes\order_payment_lock;
use classes\orders_schema_bootstrap;
use classes\pdf_generator;
use classes\motorapi;
@@ -465,14 +466,20 @@ class orders_o extends db
* @param int|null $invoiceCollectionId The invoice collection id, if not set, the default invoice collection id will be used.
* @throws Exception If the order is not selected
*/
public function assignToInvoiceCollection(int $invoiceCollectionId = null, bool $notifyChanges = true): void
public function assignToInvoiceCollection(
?int $invoiceCollectionId = null,
bool $notifyChanges = true,
?int $targetCustomerId = null
): void
{
global $response;
// If the invoice collection id is not set, get the default invoice collection id
self::requireSelected();
$previousInvoiceCollectionId = (int)$this->invoice_collection_id->value();
// Get the customer
$customer = new users_o();
$customer_id = (int)$this->customer_id->value();
$originalCustomerId = (int)$this->customer_id->value();
$customer_id = $targetCustomerId ?? $originalCustomerId;
if ($customer_id === 0) {
throw new Exception('The customer id is not set for the order!.');
}
@@ -480,6 +487,28 @@ class orders_o extends db
$customer->requireSelected();
// Get the invoice collection id
$invoiceCollectionId = $invoiceCollectionId ?? $customer->getNewOrderInvoiceCollectionId();
$orderPaymentLock = order_payment_lock::tryAcquireReassignment(
(int)$this->id,
(int)$invoiceCollectionId
);
if ($orderPaymentLock === null) {
$response->error([
'message' => 'The order or invoice collection is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
$this->getObjectProperties();
if ($targetCustomerId === null
&& (int)$this->customer_id->value() !== $originalCustomerId) {
$response->error([
'message' => 'The order customer changed while its invoice collection was being assigned.',
'code' => 'order_payment_contract_mismatch',
], 409);
}
$previousInvoiceCollectionId = (int)$this->invoice_collection_id->value();
if ($targetCustomerId !== null) {
$this->customer_id->set($targetCustomerId);
}
// Assign the order to the invoice collection
$this->invoice_collection_id->set($invoiceCollectionId);
if ($previousInvoiceCollectionId > 0 && $previousInvoiceCollectionId !== (int)$invoiceCollectionId) {
@@ -183,17 +183,20 @@ class stripe_payment_intents_o extends db
/**
* @throws Exception
*/
public function delete(): void
public function delete(): bool
{
self::requireSelected();
self::cancelPaymentIntent();
if (!$this->cancelPaymentIntent()) {
return false;
}
self::deletePermanently();
return true;
}
/**
* @throws Exception
*/
public function cancelPaymentIntent(): void
public function cancelPaymentIntent(): bool
{
self::requireSelected();
$stripe = new stripe();
@@ -211,27 +214,39 @@ class stripe_payment_intents_o extends db
$paymentIntentId = trim((string)($this->payment_intent_id->value() ?? ''));
if ($paymentIntentId === '') {
self::objectChanged();
return;
return true;
}
try {
$paymentIntent = $stripe->payment_intents->get($paymentIntentId);
} catch (\Stripe\Exception\InvalidRequestException) {
self::objectChanged();
return;
return true;
}
$status = strtolower((string)($paymentIntent->status ?? ''));
if (in_array($status, ['succeeded', 'canceled'], true)) {
if ($status === 'succeeded') {
$this->updateStoredPaymentIntent($paymentIntent);
return;
return false;
}
if ($status === 'canceled') {
$this->updateStoredPaymentIntent($paymentIntent);
return true;
}
try {
$cancelledPaymentIntent = $stripe->payment_intents->cancel($paymentIntentId);
$this->updateStoredPaymentIntent($cancelledPaymentIntent);
return strtolower((string)($cancelledPaymentIntent->status ?? '')) !== 'succeeded';
} catch (\Stripe\Exception\InvalidRequestException) {
self::objectChanged();
try {
$paymentIntent = $stripe->payment_intents->get($paymentIntentId);
$this->updateStoredPaymentIntent($paymentIntent);
return strtolower((string)($paymentIntent->status ?? '')) === 'canceled';
} catch (\Stripe\Exception\InvalidRequestException) {
self::objectChanged();
return false;
}
}
}
+1 -1
View File
@@ -9017,6 +9017,7 @@ paths:
tags:
- Orders
summary: Create Stripe payment intent
description: Creates a Stripe Terminal card payment intent with fixed 25% moms.
operationId: createStripePaymentIntent
requestBody:
required: true
@@ -9028,7 +9029,6 @@ paths:
properties:
id: {type: integer}
reader: {type: string}
tax_percentage: {type: integer}
responses:
'200':
description: Success
@@ -1200,9 +1200,11 @@ class orderInvoicesRoute
continue; // Skip if no orders found for this registration number in the date range
}
foreach ($orders as $order) {
$order->customer_id->set((int)$new_invoice_collection->customer_number->value());
$order->assignToInvoiceCollection((int)$new_invoice_collection->id);
$order->objectChanged();
$order->assignToInvoiceCollection(
(int)$new_invoice_collection->id,
true,
(int)$new_invoice_collection->customer_number->value()
);
$moved_invoices[] = $order->id;
}
}
@@ -1259,9 +1261,11 @@ class orderInvoicesRoute
self::requireMinValue((int)$order_id, 1);
$order = (new orders_o())->select((int)$order_id);
$order->requireSelected();
$order->customer_id->set((int)$new_invoice_collection->customer_number->value());
$order->objectChanged();
$order->assignToInvoiceCollection((int)$new_invoice_collection->id);
$order->assignToInvoiceCollection(
(int)$new_invoice_collection->id,
true,
(int)$new_invoice_collection->customer_number->value()
);
$moved_invoices[] = $order->id;
}
$new_invoice_collection->clearCachedData();
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\customer_product_rule_service;
use classes\order_payment_lock;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
@@ -77,6 +78,7 @@ class orderItemsRoute
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id);
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
@@ -210,6 +212,7 @@ class orderItemsRoute
$response->error('Order not found', 404);
}
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
$orderPaymentLock = $this->acquireOrderPaymentLock((int)$orderForAccess->id);
} else {
$response->error('Order item not found', 404);
}
@@ -297,6 +300,7 @@ class orderItemsRoute
$response->error('Order item does not belong to the user', 403);
}
$orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id);
// Update the order item
(new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']);
// Log the incident
@@ -317,4 +321,18 @@ class orderItemsRoute
]
);
}
private function acquireOrderPaymentLock(int $orderId): order_payment_lock
{
global $response;
$lock = order_payment_lock::tryAcquireOrderMutation($orderId);
if ($lock === null) {
$response->error([
'message' => 'The order is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
return $lock;
}
}
+352 -50
View File
@@ -10,6 +10,7 @@ use classes\authentication;
use classes\economic;
use classes\order_reference_suggestions_service;
use classes\orders_input_normalizer;
use classes\order_payment_lock;
use classes\pdf_store;
use classes\response;
use classes\stripe;
@@ -30,6 +31,8 @@ class ordersRoute
{
use route_t;
private const CARD_PAYMENT_TAX_PERCENTAGE = 25;
public function run(): void
{
$this->get('/orders/reference-suggestions', function () {
@@ -284,6 +287,7 @@ class ordersRoute
}
// Check if the user has access to the department
self::requireDepartmentAccess((int)$order->department_id->value());
$orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id);
$confirmed = filter_var($this->fromRequest('confirmed'), FILTER_VALIDATE_BOOLEAN) === true;
$deleteProtection = $order->getDeleteProtectionSummary();
if ($deleteProtection['requires_confirmation'] && !$confirmed) {
@@ -620,14 +624,12 @@ class ordersRoute
$response->error('Reader ID is required', 400);
}
$tax_percentage = isset($data['tax_percentage']) ? (int)$data['tax_percentage'] : null;
if ($tax_percentage !== null && ($tax_percentage < 0 || $tax_percentage > 100)) {
$response->error('Invalid tax percentage', 400);
}
$tax_percentage = self::CARD_PAYMENT_TAX_PERCENTAGE;
$stripe = new stripe();
$stripePaymentIntents = new stripe_payment_intents_o();
$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage ?? 0);
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage);
if ($stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
$stripePaymentIntents->selectOrderPaymentIntent((int)$order->id);
@@ -635,20 +637,44 @@ class ordersRoute
try {
$storedPaymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value());
$stripePaymentIntents->updateStoredPaymentIntent($storedPaymentIntent);
$stripePaymentIntents->setReaderId($readerId);
if ($tax_percentage !== null) {
$storedPaymentIntentStatus = strtolower((string)($storedPaymentIntent->status ?? ''));
if ($storedPaymentIntentStatus === 'succeeded') {
if (!$this->doesStripePaymentIntentMatchOrder(
$storedPaymentIntent,
$expectedPaymentIntentAmount,
$tax_percentage
)) {
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
$stripePaymentIntents->tax_percentage->set($tax_percentage);
$stripePaymentIntents->objectChanged();
$this->recordSucceededStripePayment($order, $storedPaymentIntent);
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused completed Stripe payment intent for order (ID: ' . $data['id'] . ')');
$response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [
'reused' => true,
'already_succeeded' => true,
]));
}
if (
$this->isStripePaymentIntentReusable($storedPaymentIntent)
&& $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage ?? 0)
&& $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage)
) {
if (strtolower((string)($storedPaymentIntent->status ?? '')) === 'requires_capture') {
$stripePaymentIntents->tax_percentage->set($tax_percentage);
$stripePaymentIntents->objectChanged();
$stripePaymentIntents->setReaderId($readerId);
if ($storedPaymentIntentStatus === 'requires_capture') {
$storedPaymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe
$stripe,
$storedPaymentIntent,
$orderPaymentLock
);
}
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused Stripe payment intent for order (ID: ' . $data['id'] . ')');
@@ -657,12 +683,18 @@ class ordersRoute
]));
}
$stripePaymentIntents->deletePermanently();
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment completed while it was being replaced. Reconcile it before starting another payment.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
} catch (\Stripe\Exception\InvalidRequestException) {
$stripePaymentIntents->deletePermanently();
}
}
$this->requireStripePaymentCollectionAvailable($order);
$paymentIntent = $stripe->payment_intents->create(
$expectedPaymentIntentAmount,
[
@@ -671,7 +703,7 @@ class ordersRoute
'order_id' => (string)$order->id,
'customer_id' => (string)$order->customer_id->value(),
'department_id' => (string)$order->department_id->value(),
'tax_percentage' => (string)($tax_percentage ?? 0),
'tax_percentage' => (string)$tax_percentage,
'reader_id' => $readerId,
'reader' => $readerId,
],
@@ -692,10 +724,11 @@ class ordersRoute
try {
$stripe->readers->sendPaymentIntent($readerId, $paymentIntent->id);
} catch (\Stripe\Exception\InvalidRequestException) {
try {
$stripePaymentIntents->delete();
} catch (Exception) {
$stripePaymentIntents->deletePermanently();
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment completed while the reader operation failed. It was not cleared; reconcile the completed payment.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$response->error('Unable to start payment on the selected reader', 409);
}
@@ -704,7 +737,13 @@ class ordersRoute
$paymentIntent = $stripe->payment_intents->get($paymentIntent->id);
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
if (strtolower((string)($paymentIntent->status ?? '')) === 'requires_capture') {
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
$paymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe,
$paymentIntent,
$orderPaymentLock
);
}
} catch (\Stripe\Exception\InvalidRequestException) {
// Keep the created intent payload if Stripe retrieve is temporarily unavailable.
@@ -743,6 +782,7 @@ class ordersRoute
$response->error('Order not found', 400);
}
self::requireDepartmentAccess((int)$order->department_id->value());
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
$stripePaymentIntents = new stripe_payment_intents_o();
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
@@ -771,10 +811,38 @@ class ordersRoute
]));
}
if ($status === 'requires_capture') {
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
$paymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe,
$paymentIntent,
$orderPaymentLock
);
} elseif ($status === 'succeeded') {
$expectedAmount = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (!$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmount,
self::CARD_PAYMENT_TAX_PERCENTAGE
)) {
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
$this->recordSucceededStripePayment($order, $paymentIntent);
}
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
if ($status === 'succeeded') {
$stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE);
$stripePaymentIntents->objectChanged();
}
$response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents));
},
[
@@ -805,6 +873,7 @@ class ordersRoute
$response->error('Order not found', 400);
}
self::requireDepartmentAccess((int)$order->department_id->value());
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
$stripePaymentIntents = new stripe_payment_intents_o();
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
@@ -816,10 +885,11 @@ class ordersRoute
$stripePaymentIntents->selectOrderPaymentIntent((int)$order->id);
try {
$stripePaymentIntents->delete();
} catch (\Stripe\Exception\InvalidRequestException) {
$stripePaymentIntents->deletePermanently();
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment has already completed and was not cleared. Reconcile it before continuing.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_PAYMENT_INTENT', 'Successfully deleted a payment intent (ID: ' . $id . ')');
@@ -854,6 +924,7 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
self::requireDepartmentAccess((int)$order->department_id->value());
$stripePaymentIntents = new stripe_payment_intents_o();
@@ -875,7 +946,27 @@ class ordersRoute
$status = strtolower((string)($paymentIntent->status ?? ''));
if ($status === 'succeeded') {
$response->error('Payment intent has already been captured.', 409);
$expectedAmount = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (!$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmount,
self::CARD_PAYMENT_TAX_PERCENTAGE
)) {
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
$this->recordSucceededStripePayment($order, $paymentIntent);
$stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE);
$stripePaymentIntents->objectChanged();
$response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents, [
'already_succeeded' => true,
]));
}
if ($status === 'canceled') {
$stripePaymentIntents->deletePermanently();
@@ -885,7 +976,13 @@ class ordersRoute
$response->error('Payment intent is not ready to capture.', 409);
}
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
$paymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe,
$paymentIntent,
$orderPaymentLock
);
(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));
@@ -953,7 +1050,7 @@ class ordersRoute
{
return (int)round($this->addTaxNetAmount(
(float)$order->getNetAmount() * 100,
$tax_percentage ?? 0
$tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE
));
}
@@ -962,6 +1059,12 @@ class ordersRoute
if (!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount) {
return false;
}
if (
strtolower((string)($paymentIntent->status ?? '')) === 'succeeded'
&& (!isset($paymentIntent->amount_received) || (int)$paymentIntent->amount_received !== $expectedAmount)
) {
return false;
}
$metadata = $paymentIntent->metadata ?? null;
$storedTaxPercentage = null;
@@ -972,10 +1075,10 @@ class ordersRoute
}
if ($storedTaxPercentage === null || !is_numeric($storedTaxPercentage)) {
return ($tax_percentage ?? 0) === 0;
return ($tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE) === self::CARD_PAYMENT_TAX_PERCENTAGE;
}
return (int)$storedTaxPercentage === ($tax_percentage ?? 0);
return (int)$storedTaxPercentage === ($tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE);
}
private function isStripePaymentIntentReusable(object $paymentIntent): bool
@@ -992,18 +1095,64 @@ class ordersRoute
], true);
}
private function captureApprovedStripePaymentIntent(orders_o $order, stripe_payment_intents_o $stripePaymentIntents, stripe $stripe): object
private function captureApprovedStripePaymentIntent(
orders_o $order,
stripe_payment_intents_o $stripePaymentIntents,
stripe $stripe,
object $paymentIntent,
order_payment_lock $orderPaymentLock
): object
{
global $response;
$order = (new orders_o())->getOrderById((int)$order->id);
$invoiceCollectionId = (int)$order->invoice_collection_id->value();
if ((int)$order->invoice_collection_id->value() !== $invoiceCollectionId) {
$response->error([
'message' => 'The order moved to another invoice collection. Start the card payment again.',
'code' => 'stripe_payment_intent_contract_mismatch',
], 409);
}
$expectedAmount = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (!$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmount,
self::CARD_PAYMENT_TAX_PERCENTAGE
)) {
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment completed while its order changed. Reconcile it before starting another payment.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$response->error([
'message' => 'The card payment no longer matches the current order. Start the payment again.',
'code' => 'stripe_payment_intent_contract_mismatch',
], 409);
}
$this->requireStripePaymentCollectionAvailable($order);
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);
try {
$paymentIntent = $stripe->payment_intents->get(
$stripePaymentIntents->payment_intent_id->value()
);
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
} catch (\Stripe\Exception\InvalidRequestException) {
$stripePaymentIntents->deletePermanently();
$response->error('Stored payment intent is stale. Start the payment again.', 409);
}
if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') {
$response->error('Payment intent could not be captured. Try again.', 409);
}
}
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
@@ -1011,12 +1160,132 @@ class ordersRoute
$response->error('Payment intent is not ready to capture.', 409);
}
$order_collection = $order->getOrderCollection();
$order_collection->paidWithStripe($paymentIntent->id);
$order = (new orders_o())->getOrderById((int)$order->id);
$expectedAmountAfterCapture = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (
$expectedAmountAfterCapture !== $expectedAmount
|| !$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmountAfterCapture,
self::CARD_PAYMENT_TAX_PERCENTAGE
)
) {
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE);
$stripePaymentIntents->objectChanged();
$this->recordSucceededStripePayment($order, $paymentIntent);
return $paymentIntent;
}
private function recordSucceededStripePayment(orders_o $order, object $paymentIntent): void
{
global $response;
$orderCollection = $order->getOrderCollection();
$closedAt = trim((string)($orderCollection->closed_at->value() ?? ''));
$processor = (int)($orderCollection->processor->value() ?? 0);
$externalId = trim((string)($orderCollection->external_id->value() ?? ''));
$isExactRecordedPayment = (
$closedAt !== ''
&& $processor === STRIPE_PROCESSOR
&& $externalId === (string)$paymentIntent->id
);
$isCompatiblePartialPayment = (
$closedAt === ''
&& ($processor === 0 || $processor === STRIPE_PROCESSOR)
&& ($externalId === '' || $externalId === (string)$paymentIntent->id)
);
if ($isExactRecordedPayment) {
return;
}
if ($isCompatiblePartialPayment) {
$orderCollection->paidWithStripe((string)$paymentIntent->id);
return;
}
$response->error([
'message' => 'The order collection is already closed with another payment. Reconcile the completed card payment manually.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
private function requireStripePaymentCollectionAvailable(orders_o $order): void
{
global $response;
$orderCollection = $order->getOrderCollection();
$closedAt = trim((string)($orderCollection->closed_at->value() ?? ''));
$processor = (int)($orderCollection->processor->value() ?? 0);
$externalId = trim((string)($orderCollection->external_id->value() ?? ''));
if ($closedAt !== '' || $processor !== 0 || $externalId !== '') {
$response->error([
'message' => 'The order collection is already closed or assigned to another payment. Reconcile it before capturing funds.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
}
private function acquireOrderPaymentLock(int $orderId): order_payment_lock
{
global $response;
$lock = order_payment_lock::tryAcquireOrderMutation($orderId);
if ($lock === null) {
$response->error([
'message' => 'The order is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
return $lock;
}
/**
* @return array{0:orders_o,1:order_payment_lock}
*/
private function acquireStripePaymentLocks(orders_o $order): array
{
global $response;
$invoiceCollectionId = (int)$order->invoice_collection_id->value();
if ($invoiceCollectionId <= 0) {
$response->error([
'message' => 'The order is not assigned to an invoice collection. Repair it before starting card payment.',
'code' => 'stripe_payment_collection_missing',
], 409);
}
$lock = order_payment_lock::tryAcquireOrderMutation((int)$order->id);
if ($lock === null) {
$response->error([
'message' => 'The order or invoice collection is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
$freshOrder = (new orders_o())->getOrderById((int)$order->id);
if (!$freshOrder->exists()
|| (int)$freshOrder->invoice_collection_id->value() !== $invoiceCollectionId) {
$response->error([
'message' => 'The order moved to another invoice collection. Start the card payment again.',
'code' => 'stripe_payment_intent_contract_mismatch',
], 409);
}
$invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId);
if (!$invoiceCollection->exists()) {
$response->error([
'message' => 'The order invoice collection does not exist. Repair it before starting card payment.',
'code' => 'stripe_payment_collection_missing',
], 409);
}
return [$freshOrder, $lock];
}
private function buildStripePaymentIntentResponse(?object $paymentIntent, ?stripe_payment_intents_o $storedIntent, array $extra = []): array
{
$paymentIntentPayload = null;
@@ -1098,6 +1367,7 @@ class ordersRoute
}
// Own path (classic or subuser) — limited field edits only
if ($isOwnPath) {
$orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id);
// Validate that the order belongs to the effective customer context
if ($subuser_own_path) {
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
@@ -1177,6 +1447,7 @@ class ordersRoute
/** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value();
$originalInvoiceCollectionId = (int)$order->invoice_collection_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
$shouldRefreshAttachedWashCertificate = false;
@@ -1191,11 +1462,59 @@ class ordersRoute
$originalCustomerNumber,
$newCustomerNumber
);
$order->customer_id->set($newCustomerNumber);
}
$targetCustomerNumber = isset($data['customer_id'])
? (int)$data['customer_id']
: (int)$order->customer_id->value();
$targetInvoiceCollectionId = $originalInvoiceCollectionId;
if ($shouldAutoReassignInvoiceCollection) {
$targetInvoiceCollectionId = (new users_o())
->getUserByCustomerNumber($targetCustomerNumber)
->getNewOrderInvoiceCollectionId();
} elseif (isset($data['invoice_collection_id'])) {
$targetInvoiceCollectionId = (int)$data['invoice_collection_id'];
if ($targetInvoiceCollectionId > 0) {
$invoiceCollection = (new collected_order_invoices_o())
->select($targetInvoiceCollectionId);
if (!$invoiceCollection->exists()) {
$response->error('Invoice collection not found', 400);
}
if ((int)$invoiceCollection->customer_number->value() !== $targetCustomerNumber) {
$response->error('Invoice collection does not belong to the order customer', 400);
}
}
}
$assignmentChanges = (
$targetInvoiceCollectionId !== $originalInvoiceCollectionId
|| $newCustomerNumber !== $originalCustomerNumber
);
$orderPaymentLock = $assignmentChanges
? order_payment_lock::tryAcquireReassignment(
(int)$order->id,
$targetInvoiceCollectionId
)
: order_payment_lock::tryAcquireOrderMutation((int)$order->id);
if ($orderPaymentLock === null) {
$response->error([
'message' => 'The order or invoice collection is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
if ((int)$order->customer_id->value() !== $originalCustomerNumber
|| (int)$order->invoice_collection_id->value() !== $originalInvoiceCollectionId) {
$response->error([
'message' => 'The order assignment changed while the update was being prepared.',
'code' => 'order_payment_contract_mismatch',
], 409);
}
if ($assignmentChanges) {
$order->assignToInvoiceCollection(
$targetInvoiceCollectionId,
false,
$targetCustomerNumber
);
}
// If the reference is set, validate it
if (isset($data['reference'])) {
$order->reference->set($data['reference']);
@@ -1250,20 +1569,6 @@ class ordersRoute
$order->booking_id->set($bookingId);
$this->applyBookingPoDefaultToOrder($order, $bookingId);
}
// Check if the invoice collection is set
if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) {
$invoiceCollectionId = (int)$data['invoice_collection_id'];
if ($invoiceCollectionId > 0) {
$invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId);
if (!$invoiceCollection->exists()) {
$response->error('Invoice collection not found', 400);
}
if ((int)$invoiceCollection->customer_number->value() !== $targetCustomerNumber) {
$response->error('Invoice collection does not belong to the order customer', 400);
}
}
$order->invoice_collection_id->set($invoiceCollectionId);
}
// Check if the wash_id is set
if (isset($data['wash_id'])) {
$order->wash_id->set($data['wash_id']);
@@ -1286,9 +1591,6 @@ class ordersRoute
$response->error($e->getMessage(), 400);
}
}
if ($shouldAutoReassignInvoiceCollection) {
$order->assignToInvoiceCollection(null, false);
}
if ($shouldRefreshAttachedWashCertificate) {
$order->regenerateAttachedWashCertificate();
}
@@ -14,25 +14,181 @@ it('wires mobile stripe payment intent routes to normalized lifecycle handling',
$stripeSection = substr($content, (int)$start, (int)$end - (int)$start);
expect($stripeSection)->toContain('buildStripePaymentIntentResponse(');
expect($stripeSection)->toContain('$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage ?? 0);');
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 ?? 0)');
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("Payment intent has already been captured.");
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(\$order, \$stripePaymentIntents, \$stripe)");
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(orders_o $order, stripe_payment_intents_o $stripePaymentIntents, stripe $stripe): object');
expect($content)->toContain("paidWithStripe(\$paymentIntent->id);");
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');
}
});
@@ -1,7 +1,7 @@
<?php
it('wires stripe payment intent persistence to prune duplicates and clear reader state safely', function (): void {
$objectFile = app_path('objects/stripe_payment_intents_o.php');
$objectFile = dirname(__DIR__, 3) . '/objects/stripe_payment_intents_o.php';
$content = file_get_contents($objectFile);
expect($content)->not->toBeFalse();
@@ -15,5 +15,11 @@ it('wires stripe payment intent persistence to prune duplicates and clear reader
expect($content)->toContain('public function setReaderId(?string $readerId): void');
expect($content)->toContain('sendCancelPaymentIntent($readerId);');
expect($content)->toContain('payment_intents->cancel($paymentIntentId);');
expect($content)->toContain('public function delete(): bool');
expect($content)->toContain('if (!$this->cancelPaymentIntent())');
expect($content)->toContain('public function cancelPaymentIntent(): bool');
expect($content)->toContain("if (\$status === 'succeeded')");
expect($content)->toContain('return false;');
expect(substr_count($content, 'payment_intents->get($paymentIntentId)'))->toBeGreaterThanOrEqual(2);
expect($content)->toContain('deleteDuplicateOrderPaymentIntents(int $order_id, int $keepId): void');
});