Serialize VAT collection mutations with payment operations (#326)

## Summary

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

## Exact-head evidence

Current head: `3a0f70d315a94d2efe586a2188d2c54f8ff11cd4`

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

## Safety behavior

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

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
This commit is contained in:
Jeppe B
2026-07-28 22:00:59 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent da0113e3ed
commit 42ddce84bc
16 changed files with 929 additions and 88 deletions
+352 -50
View File
@@ -10,6 +10,7 @@ use classes\authentication;
use classes\economic;
use classes\order_reference_suggestions_service;
use classes\orders_input_normalizer;
use classes\order_payment_lock;
use classes\pdf_store;
use classes\response;
use classes\stripe;
@@ -30,6 +31,8 @@ class ordersRoute
{
use route_t;
private const CARD_PAYMENT_TAX_PERCENTAGE = 25;
public function run(): void
{
$this->get('/orders/reference-suggestions', function () {
@@ -284,6 +287,7 @@ class ordersRoute
}
// Check if the user has access to the department
self::requireDepartmentAccess((int)$order->department_id->value());
$orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id);
$confirmed = filter_var($this->fromRequest('confirmed'), FILTER_VALIDATE_BOOLEAN) === true;
$deleteProtection = $order->getDeleteProtectionSummary();
if ($deleteProtection['requires_confirmation'] && !$confirmed) {
@@ -620,14 +624,12 @@ class ordersRoute
$response->error('Reader ID is required', 400);
}
$tax_percentage = isset($data['tax_percentage']) ? (int)$data['tax_percentage'] : null;
if ($tax_percentage !== null && ($tax_percentage < 0 || $tax_percentage > 100)) {
$response->error('Invalid tax percentage', 400);
}
$tax_percentage = self::CARD_PAYMENT_TAX_PERCENTAGE;
$stripe = new stripe();
$stripePaymentIntents = new stripe_payment_intents_o();
$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage ?? 0);
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
$expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage);
if ($stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
$stripePaymentIntents->selectOrderPaymentIntent((int)$order->id);
@@ -635,20 +637,44 @@ class ordersRoute
try {
$storedPaymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value());
$stripePaymentIntents->updateStoredPaymentIntent($storedPaymentIntent);
$stripePaymentIntents->setReaderId($readerId);
if ($tax_percentage !== null) {
$storedPaymentIntentStatus = strtolower((string)($storedPaymentIntent->status ?? ''));
if ($storedPaymentIntentStatus === 'succeeded') {
if (!$this->doesStripePaymentIntentMatchOrder(
$storedPaymentIntent,
$expectedPaymentIntentAmount,
$tax_percentage
)) {
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
$stripePaymentIntents->tax_percentage->set($tax_percentage);
$stripePaymentIntents->objectChanged();
$this->recordSucceededStripePayment($order, $storedPaymentIntent);
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused completed Stripe payment intent for order (ID: ' . $data['id'] . ')');
$response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [
'reused' => true,
'already_succeeded' => true,
]));
}
if (
$this->isStripePaymentIntentReusable($storedPaymentIntent)
&& $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage ?? 0)
&& $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage)
) {
if (strtolower((string)($storedPaymentIntent->status ?? '')) === 'requires_capture') {
$stripePaymentIntents->tax_percentage->set($tax_percentage);
$stripePaymentIntents->objectChanged();
$stripePaymentIntents->setReaderId($readerId);
if ($storedPaymentIntentStatus === 'requires_capture') {
$storedPaymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe
$stripe,
$storedPaymentIntent,
$orderPaymentLock
);
}
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused Stripe payment intent for order (ID: ' . $data['id'] . ')');
@@ -657,12 +683,18 @@ class ordersRoute
]));
}
$stripePaymentIntents->deletePermanently();
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment completed while it was being replaced. Reconcile it before starting another payment.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
} catch (\Stripe\Exception\InvalidRequestException) {
$stripePaymentIntents->deletePermanently();
}
}
$this->requireStripePaymentCollectionAvailable($order);
$paymentIntent = $stripe->payment_intents->create(
$expectedPaymentIntentAmount,
[
@@ -671,7 +703,7 @@ class ordersRoute
'order_id' => (string)$order->id,
'customer_id' => (string)$order->customer_id->value(),
'department_id' => (string)$order->department_id->value(),
'tax_percentage' => (string)($tax_percentage ?? 0),
'tax_percentage' => (string)$tax_percentage,
'reader_id' => $readerId,
'reader' => $readerId,
],
@@ -692,10 +724,11 @@ class ordersRoute
try {
$stripe->readers->sendPaymentIntent($readerId, $paymentIntent->id);
} catch (\Stripe\Exception\InvalidRequestException) {
try {
$stripePaymentIntents->delete();
} catch (Exception) {
$stripePaymentIntents->deletePermanently();
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment completed while the reader operation failed. It was not cleared; reconcile the completed payment.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$response->error('Unable to start payment on the selected reader', 409);
}
@@ -704,7 +737,13 @@ class ordersRoute
$paymentIntent = $stripe->payment_intents->get($paymentIntent->id);
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
if (strtolower((string)($paymentIntent->status ?? '')) === 'requires_capture') {
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
$paymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe,
$paymentIntent,
$orderPaymentLock
);
}
} catch (\Stripe\Exception\InvalidRequestException) {
// Keep the created intent payload if Stripe retrieve is temporarily unavailable.
@@ -743,6 +782,7 @@ class ordersRoute
$response->error('Order not found', 400);
}
self::requireDepartmentAccess((int)$order->department_id->value());
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
$stripePaymentIntents = new stripe_payment_intents_o();
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
@@ -771,10 +811,38 @@ class ordersRoute
]));
}
if ($status === 'requires_capture') {
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
$paymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe,
$paymentIntent,
$orderPaymentLock
);
} elseif ($status === 'succeeded') {
$expectedAmount = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (!$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmount,
self::CARD_PAYMENT_TAX_PERCENTAGE
)) {
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
$this->recordSucceededStripePayment($order, $paymentIntent);
}
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
if ($status === 'succeeded') {
$stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE);
$stripePaymentIntents->objectChanged();
}
$response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents));
},
[
@@ -805,6 +873,7 @@ class ordersRoute
$response->error('Order not found', 400);
}
self::requireDepartmentAccess((int)$order->department_id->value());
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
$stripePaymentIntents = new stripe_payment_intents_o();
if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) {
@@ -816,10 +885,11 @@ class ordersRoute
$stripePaymentIntents->selectOrderPaymentIntent((int)$order->id);
try {
$stripePaymentIntents->delete();
} catch (\Stripe\Exception\InvalidRequestException) {
$stripePaymentIntents->deletePermanently();
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment has already completed and was not cleared. Reconcile it before continuing.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_PAYMENT_INTENT', 'Successfully deleted a payment intent (ID: ' . $id . ')');
@@ -854,6 +924,7 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
[$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order);
self::requireDepartmentAccess((int)$order->department_id->value());
$stripePaymentIntents = new stripe_payment_intents_o();
@@ -875,7 +946,27 @@ class ordersRoute
$status = strtolower((string)($paymentIntent->status ?? ''));
if ($status === 'succeeded') {
$response->error('Payment intent has already been captured.', 409);
$expectedAmount = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (!$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmount,
self::CARD_PAYMENT_TAX_PERCENTAGE
)) {
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
$this->recordSucceededStripePayment($order, $paymentIntent);
$stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE);
$stripePaymentIntents->objectChanged();
$response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents, [
'already_succeeded' => true,
]));
}
if ($status === 'canceled') {
$stripePaymentIntents->deletePermanently();
@@ -885,7 +976,13 @@ class ordersRoute
$response->error('Payment intent is not ready to capture.', 409);
}
$paymentIntent = $this->captureApprovedStripePaymentIntent($order, $stripePaymentIntents, $stripe);
$paymentIntent = $this->captureApprovedStripePaymentIntent(
$order,
$stripePaymentIntents,
$stripe,
$paymentIntent,
$orderPaymentLock
);
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CONFIRM_PAYMENT_INTENT', 'Successfully confirmed a payment intent (ID: ' . $data['id'] . ')');
$response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents));
@@ -953,7 +1050,7 @@ class ordersRoute
{
return (int)round($this->addTaxNetAmount(
(float)$order->getNetAmount() * 100,
$tax_percentage ?? 0
$tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE
));
}
@@ -962,6 +1059,12 @@ class ordersRoute
if (!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount) {
return false;
}
if (
strtolower((string)($paymentIntent->status ?? '')) === 'succeeded'
&& (!isset($paymentIntent->amount_received) || (int)$paymentIntent->amount_received !== $expectedAmount)
) {
return false;
}
$metadata = $paymentIntent->metadata ?? null;
$storedTaxPercentage = null;
@@ -972,10 +1075,10 @@ class ordersRoute
}
if ($storedTaxPercentage === null || !is_numeric($storedTaxPercentage)) {
return ($tax_percentage ?? 0) === 0;
return ($tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE) === self::CARD_PAYMENT_TAX_PERCENTAGE;
}
return (int)$storedTaxPercentage === ($tax_percentage ?? 0);
return (int)$storedTaxPercentage === ($tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE);
}
private function isStripePaymentIntentReusable(object $paymentIntent): bool
@@ -992,18 +1095,64 @@ class ordersRoute
], true);
}
private function captureApprovedStripePaymentIntent(orders_o $order, stripe_payment_intents_o $stripePaymentIntents, stripe $stripe): object
private function captureApprovedStripePaymentIntent(
orders_o $order,
stripe_payment_intents_o $stripePaymentIntents,
stripe $stripe,
object $paymentIntent,
order_payment_lock $orderPaymentLock
): object
{
global $response;
$order = (new orders_o())->getOrderById((int)$order->id);
$invoiceCollectionId = (int)$order->invoice_collection_id->value();
if ((int)$order->invoice_collection_id->value() !== $invoiceCollectionId) {
$response->error([
'message' => 'The order moved to another invoice collection. Start the card payment again.',
'code' => 'stripe_payment_intent_contract_mismatch',
], 409);
}
$expectedAmount = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (!$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmount,
self::CARD_PAYMENT_TAX_PERCENTAGE
)) {
if (!$stripePaymentIntents->delete()) {
$response->error([
'message' => 'The card payment completed while its order changed. Reconcile it before starting another payment.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$response->error([
'message' => 'The card payment no longer matches the current order. Start the payment again.',
'code' => 'stripe_payment_intent_contract_mismatch',
], 409);
}
$this->requireStripePaymentCollectionAvailable($order);
try {
$paymentIntent = $stripe->payment_intents->capture(
$stripePaymentIntents->payment_intent_id->value(),
[]
);
} catch (\Stripe\Exception\InvalidRequestException) {
$stripePaymentIntents->deletePermanently();
$response->error('Stored payment intent is stale. Start the payment again.', 409);
try {
$paymentIntent = $stripe->payment_intents->get(
$stripePaymentIntents->payment_intent_id->value()
);
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
} catch (\Stripe\Exception\InvalidRequestException) {
$stripePaymentIntents->deletePermanently();
$response->error('Stored payment intent is stale. Start the payment again.', 409);
}
if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') {
$response->error('Payment intent could not be captured. Try again.', 409);
}
}
$stripePaymentIntents->updateStoredPaymentIntent($paymentIntent);
@@ -1011,12 +1160,132 @@ class ordersRoute
$response->error('Payment intent is not ready to capture.', 409);
}
$order_collection = $order->getOrderCollection();
$order_collection->paidWithStripe($paymentIntent->id);
$order = (new orders_o())->getOrderById((int)$order->id);
$expectedAmountAfterCapture = $this->getStripePaymentIntentAmountForOrder(
$order,
self::CARD_PAYMENT_TAX_PERCENTAGE
);
if (
$expectedAmountAfterCapture !== $expectedAmount
|| !$this->doesStripePaymentIntentMatchOrder(
$paymentIntent,
$expectedAmountAfterCapture,
self::CARD_PAYMENT_TAX_PERCENTAGE
)
) {
$response->error([
'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
$stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE);
$stripePaymentIntents->objectChanged();
$this->recordSucceededStripePayment($order, $paymentIntent);
return $paymentIntent;
}
private function recordSucceededStripePayment(orders_o $order, object $paymentIntent): void
{
global $response;
$orderCollection = $order->getOrderCollection();
$closedAt = trim((string)($orderCollection->closed_at->value() ?? ''));
$processor = (int)($orderCollection->processor->value() ?? 0);
$externalId = trim((string)($orderCollection->external_id->value() ?? ''));
$isExactRecordedPayment = (
$closedAt !== ''
&& $processor === STRIPE_PROCESSOR
&& $externalId === (string)$paymentIntent->id
);
$isCompatiblePartialPayment = (
$closedAt === ''
&& ($processor === 0 || $processor === STRIPE_PROCESSOR)
&& ($externalId === '' || $externalId === (string)$paymentIntent->id)
);
if ($isExactRecordedPayment) {
return;
}
if ($isCompatiblePartialPayment) {
$orderCollection->paidWithStripe((string)$paymentIntent->id);
return;
}
$response->error([
'message' => 'The order collection is already closed with another payment. Reconcile the completed card payment manually.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
private function requireStripePaymentCollectionAvailable(orders_o $order): void
{
global $response;
$orderCollection = $order->getOrderCollection();
$closedAt = trim((string)($orderCollection->closed_at->value() ?? ''));
$processor = (int)($orderCollection->processor->value() ?? 0);
$externalId = trim((string)($orderCollection->external_id->value() ?? ''));
if ($closedAt !== '' || $processor !== 0 || $externalId !== '') {
$response->error([
'message' => 'The order collection is already closed or assigned to another payment. Reconcile it before capturing funds.',
'code' => 'stripe_payment_reconciliation_conflict',
], 409);
}
}
private function acquireOrderPaymentLock(int $orderId): order_payment_lock
{
global $response;
$lock = order_payment_lock::tryAcquireOrderMutation($orderId);
if ($lock === null) {
$response->error([
'message' => 'The order is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
return $lock;
}
/**
* @return array{0:orders_o,1:order_payment_lock}
*/
private function acquireStripePaymentLocks(orders_o $order): array
{
global $response;
$invoiceCollectionId = (int)$order->invoice_collection_id->value();
if ($invoiceCollectionId <= 0) {
$response->error([
'message' => 'The order is not assigned to an invoice collection. Repair it before starting card payment.',
'code' => 'stripe_payment_collection_missing',
], 409);
}
$lock = order_payment_lock::tryAcquireOrderMutation((int)$order->id);
if ($lock === null) {
$response->error([
'message' => 'The order or invoice collection is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
$freshOrder = (new orders_o())->getOrderById((int)$order->id);
if (!$freshOrder->exists()
|| (int)$freshOrder->invoice_collection_id->value() !== $invoiceCollectionId) {
$response->error([
'message' => 'The order moved to another invoice collection. Start the card payment again.',
'code' => 'stripe_payment_intent_contract_mismatch',
], 409);
}
$invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId);
if (!$invoiceCollection->exists()) {
$response->error([
'message' => 'The order invoice collection does not exist. Repair it before starting card payment.',
'code' => 'stripe_payment_collection_missing',
], 409);
}
return [$freshOrder, $lock];
}
private function buildStripePaymentIntentResponse(?object $paymentIntent, ?stripe_payment_intents_o $storedIntent, array $extra = []): array
{
$paymentIntentPayload = null;
@@ -1098,6 +1367,7 @@ class ordersRoute
}
// Own path (classic or subuser) — limited field edits only
if ($isOwnPath) {
$orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id);
// Validate that the order belongs to the effective customer context
if ($subuser_own_path) {
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
@@ -1177,6 +1447,7 @@ class ordersRoute
/** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value();
$originalInvoiceCollectionId = (int)$order->invoice_collection_id->value();
$newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false;
$shouldRefreshAttachedWashCertificate = false;
@@ -1191,11 +1462,59 @@ class ordersRoute
$originalCustomerNumber,
$newCustomerNumber
);
$order->customer_id->set($newCustomerNumber);
}
$targetCustomerNumber = isset($data['customer_id'])
? (int)$data['customer_id']
: (int)$order->customer_id->value();
$targetInvoiceCollectionId = $originalInvoiceCollectionId;
if ($shouldAutoReassignInvoiceCollection) {
$targetInvoiceCollectionId = (new users_o())
->getUserByCustomerNumber($targetCustomerNumber)
->getNewOrderInvoiceCollectionId();
} elseif (isset($data['invoice_collection_id'])) {
$targetInvoiceCollectionId = (int)$data['invoice_collection_id'];
if ($targetInvoiceCollectionId > 0) {
$invoiceCollection = (new collected_order_invoices_o())
->select($targetInvoiceCollectionId);
if (!$invoiceCollection->exists()) {
$response->error('Invoice collection not found', 400);
}
if ((int)$invoiceCollection->customer_number->value() !== $targetCustomerNumber) {
$response->error('Invoice collection does not belong to the order customer', 400);
}
}
}
$assignmentChanges = (
$targetInvoiceCollectionId !== $originalInvoiceCollectionId
|| $newCustomerNumber !== $originalCustomerNumber
);
$orderPaymentLock = $assignmentChanges
? order_payment_lock::tryAcquireReassignment(
(int)$order->id,
$targetInvoiceCollectionId
)
: order_payment_lock::tryAcquireOrderMutation((int)$order->id);
if ($orderPaymentLock === null) {
$response->error([
'message' => 'The order or invoice collection is currently being changed or paid. Try again.',
'code' => 'order_payment_locked',
], 409);
}
$order = (new orders_o())->getOrderById((int)$order->id);
if ((int)$order->customer_id->value() !== $originalCustomerNumber
|| (int)$order->invoice_collection_id->value() !== $originalInvoiceCollectionId) {
$response->error([
'message' => 'The order assignment changed while the update was being prepared.',
'code' => 'order_payment_contract_mismatch',
], 409);
}
if ($assignmentChanges) {
$order->assignToInvoiceCollection(
$targetInvoiceCollectionId,
false,
$targetCustomerNumber
);
}
// If the reference is set, validate it
if (isset($data['reference'])) {
$order->reference->set($data['reference']);
@@ -1250,20 +1569,6 @@ class ordersRoute
$order->booking_id->set($bookingId);
$this->applyBookingPoDefaultToOrder($order, $bookingId);
}
// Check if the invoice collection is set
if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) {
$invoiceCollectionId = (int)$data['invoice_collection_id'];
if ($invoiceCollectionId > 0) {
$invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId);
if (!$invoiceCollection->exists()) {
$response->error('Invoice collection not found', 400);
}
if ((int)$invoiceCollection->customer_number->value() !== $targetCustomerNumber) {
$response->error('Invoice collection does not belong to the order customer', 400);
}
}
$order->invoice_collection_id->set($invoiceCollectionId);
}
// Check if the wash_id is set
if (isset($data['wash_id'])) {
$order->wash_id->set($data['wash_id']);
@@ -1286,9 +1591,6 @@ class ordersRoute
$response->error($e->getMessage(), 400);
}
}
if ($shouldAutoReassignInvoiceCollection) {
$order->assignToInvoiceCollection(null, false);
}
if ($shouldRefreshAttachedWashCertificate) {
$order->regenerateAttachedWashCertificate();
}