## 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>
242 lines
7.7 KiB
PHP
242 lines
7.7 KiB
PHP
<?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();
|
|
}
|
|
}
|