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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user