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:
co-authored by
Jeppe Bundgaard
parent
da0113e3ed
commit
42ddce84bc
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</chapter>
|
||||
<chapter title="Operation" id="operation">
|
||||
<p>Operation ID: <code>createStripePaymentIntent</code></p>
|
||||
<p>Create Stripe payment intent</p>
|
||||
<p>Creates a Stripe Terminal card payment intent with fixed 25% moms.</p>
|
||||
</chapter>
|
||||
<chapter title="Authentication" id="authentication">
|
||||
<p>Security requirements:</p>
|
||||
@@ -32,9 +32,6 @@
|
||||
},
|
||||
"reader": {
|
||||
"type": "string"
|
||||
},
|
||||
"tax_percentage": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+162
-6
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user