diff --git a/documentation/generated/openapi.json b/documentation/generated/openapi.json index dd0f1394..cea7a287 100644 --- a/documentation/generated/openapi.json +++ b/documentation/generated/openapi.json @@ -9856,6 +9856,7 @@ "Orders" ], "summary": "Create Stripe payment intent", + "description": "Creates a Stripe Terminal card payment intent with fixed 25% moms.", "operationId": "createStripePaymentIntent", "requestBody": { "required": true, @@ -9873,9 +9874,6 @@ }, "reader": { "type": "string" - }, - "tax_percentage": { - "type": "integer" } } } diff --git a/documentation/topics/generated/createStripePaymentIntent.topic b/documentation/topics/generated/createStripePaymentIntent.topic index 397e461a..59aedd45 100644 --- a/documentation/topics/generated/createStripePaymentIntent.topic +++ b/documentation/topics/generated/createStripePaymentIntent.topic @@ -12,7 +12,7 @@

Operation ID: createStripePaymentIntent

-

Create Stripe payment intent

+

Creates a Stripe Terminal card payment intent with fixed 25% moms.

Security requirements:

@@ -32,9 +32,6 @@ }, "reader": { "type": "string" - }, - "tax_percentage": { - "type": "integer" } }, "required": [ diff --git a/openapi.yaml b/openapi.yaml index 7a6367d1..de73f5f9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8475,6 +8475,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 @@ -8486,7 +8487,6 @@ paths: properties: id: {type: integer} reader: {type: string} - tax_percentage: {type: integer} responses: '200': description: Success diff --git a/services/nginx/app/classes/invoice_collection_bulk_action_service.php b/services/nginx/app/classes/invoice_collection_bulk_action_service.php index a5fed29d..5eac93f8 100644 --- a/services/nginx/app/classes/invoice_collection_bulk_action_service.php +++ b/services/nginx/app/classes/invoice_collection_bulk_action_service.php @@ -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 { diff --git a/services/nginx/app/classes/order_payment_lock.php b/services/nginx/app/classes/order_payment_lock.php new file mode 100644 index 00000000..366a1b80 --- /dev/null +++ b/services/nginx/app/classes/order_payment_lock.php @@ -0,0 +1,241 @@ + */ + 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 $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 $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 $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 + */ + 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 $lockNames + */ + private function __construct(array $lockNames) + { + $this->acquireNames($lockNames); + } + + /** + * @param list $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(); + } +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index 79c3f915..1fd5900a 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -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; diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 128ae189..48f57852 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -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 */ diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index 16b9dca9..e0589c71 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -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 $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 diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 05816ada..a550fefa 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -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) { diff --git a/services/nginx/app/objects/stripe_payment_intents_o.php b/services/nginx/app/objects/stripe_payment_intents_o.php index 5d137d9b..f1fa7b81 100644 --- a/services/nginx/app/objects/stripe_payment_intents_o.php +++ b/services/nginx/app/objects/stripe_payment_intents_o.php @@ -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; + } } } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index b622fec6..ae04a4b6 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -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 diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 846c2858..2588e137 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -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(); diff --git a/services/nginx/app/routes/orderItemsRoute.php b/services/nginx/app/routes/orderItemsRoute.php index a70954c5..0e2d174c 100644 --- a/services/nginx/app/routes/orderItemsRoute.php +++ b/services/nginx/app/routes/orderItemsRoute.php @@ -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; + } } diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index 7fa61878..879aa9ec 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -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(); } diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php index 06387bc8..9d5f9b29 100644 --- a/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php +++ b/services/nginx/app/tests/Unit/Orders/OrdersRouteStripePaymentIntentLifecycleWiringTest.php @@ -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'); + } }); diff --git a/services/nginx/app/tests/Unit/Orders/StripePaymentIntentsPersistenceWiringTest.php b/services/nginx/app/tests/Unit/Orders/StripePaymentIntentsPersistenceWiringTest.php index 9968b4a4..37be2417 100644 --- a/services/nginx/app/tests/Unit/Orders/StripePaymentIntentsPersistenceWiringTest.php +++ b/services/nginx/app/tests/Unit/Orders/StripePaymentIntentsPersistenceWiringTest.php @@ -1,7 +1,7 @@ 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'); });