Files
api/services/nginx/app/objects/stripe_payment_intents_o.php
T
Jeppe BandJeppe Bundgaard 42ddce84bc 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>
2026-07-28 22:00:59 +02:00

258 lines
8.4 KiB
PHP

<?php
namespace objects;
use classes\db;
use classes\object_property;
use classes\stripe;
use Exception;
use traits\db_object_t;
class stripe_payment_intents_o extends db
{
use db_object_t;
public object_property $order_id;
public object_property $payment_intent_id;
public object_property $client_secret;
public object_property $data;
public object_property $reader_id;
public object_property $tax_percentage;
public function structure(): void
{
$this->setTable('stripe_payment_intents');
}
/**
* Add a new payment intent
* @throws Exception
*/
public function add(
int $order_id,
string $payment_intent_id,
string $client_secret,
mixed $data = null,
?string $reader_id = null,
int $tax_percentage = null
): void {
if (is_object($data) || is_array($data)) {
$data = json_encode($data);
}
$this->clearOrderPaymentIntents($order_id);
$tmp_id = self::add_object([
'order_id' => $order_id,
'payment_intent_id' => $payment_intent_id,
'client_secret' => $client_secret,
'data' => $data,
'reader_id' => $reader_id,
'tax_percentage' => ($tax_percentage !== null) ? (int)$tax_percentage : 0,
]);
$this->id = $tmp_id;
self::getObjectProperties();
self::objectChanged();
}
public function getObjectProperties(): void
{
$this->order_id = new object_property($this->table, $this->id, 'order_id', 'int', false);
$this->payment_intent_id = new object_property($this->table, $this->id, 'payment_intent_id', 'string', false);
$this->client_secret = new object_property($this->table, $this->id, 'client_secret', 'string', false);
$this->data = new object_property($this->table, $this->id, 'data', 'string', false);
$this->reader_id = new object_property($this->table, $this->id, 'reader_id', 'string', false);
$this->tax_percentage = new object_property($this->table, $this->id, 'tax_percentage', 'int', false);
}
public function objectChanged(): void
{
// TODO: Add cache invalidation
}
public function doesOrderHavePaymentIntent(int $order_id): bool
{
return count($this->getOrderPaymentIntentRows($order_id)) > 0;
}
/**
* @throws Exception
*/
public function selectOrderPaymentIntent(int $order_id): self
{
$rows = $this->getOrderPaymentIntentRows($order_id);
if (count($rows) === 0) {
throw new Exception('No payment intent found for order id: ' . $order_id);
}
$this->id = (int)$rows[0]['id'];
self::getObjectProperties();
$this->deleteDuplicateOrderPaymentIntents($order_id, $this->id);
return $this;
}
/**
* @return array<int, array<string, mixed>>
*/
public function getOrderPaymentIntentRows(int $order_id): array
{
$rows = self::getFieldsWhere(
[
'order_id' => $order_id,
],
['id', 'payment_intent_id', 'reader_id', 'tax_percentage']
);
usort($rows, static fn(array $a, array $b): int => ((int)($b['id'] ?? 0)) <=> ((int)($a['id'] ?? 0)));
return $rows;
}
public function clearOrderPaymentIntents(int $order_id, ?int $keepId = null): void
{
foreach ($this->getOrderPaymentIntentRows($order_id) as $row) {
$rowId = (int)($row['id'] ?? 0);
if ($rowId <= 0 || ($keepId !== null && $rowId === $keepId)) {
continue;
}
self::delete_object($this->getTable(), $rowId);
}
}
public function updateStoredPaymentIntent(mixed $paymentIntent): void
{
self::requireSelected();
if (is_object($paymentIntent) && method_exists($paymentIntent, 'toJSON')) {
$encodedData = $paymentIntent->toJSON();
} elseif (is_array($paymentIntent) || is_object($paymentIntent)) {
$encodedData = json_encode($paymentIntent);
} else {
$encodedData = (string)$paymentIntent;
}
$this->data->set($encodedData);
$normalizedClientSecret = null;
$normalizedReaderId = null;
$normalizedTaxPercentage = null;
if (is_object($paymentIntent) || is_array($paymentIntent)) {
$metadata = is_array($paymentIntent)
? ($paymentIntent['metadata'] ?? [])
: ($paymentIntent->metadata ?? null);
$normalizedClientSecret = is_array($paymentIntent)
? ($paymentIntent['client_secret'] ?? null)
: ($paymentIntent->client_secret ?? null);
if (is_array($metadata)) {
$normalizedReaderId = $metadata['reader_id'] ?? $metadata['reader'] ?? null;
$normalizedTaxPercentage = $metadata['tax_percentage'] ?? null;
} elseif (is_object($metadata)) {
$normalizedReaderId = $metadata->reader_id ?? $metadata->reader ?? null;
$normalizedTaxPercentage = $metadata->tax_percentage ?? null;
}
}
if (is_string($normalizedClientSecret) && $normalizedClientSecret !== '') {
$this->client_secret->set($normalizedClientSecret);
}
if ($normalizedReaderId !== null) {
$this->reader_id->set((string)$normalizedReaderId);
}
if ($normalizedTaxPercentage !== null && is_numeric($normalizedTaxPercentage)) {
$this->tax_percentage->set((int)$normalizedTaxPercentage);
}
self::objectChanged();
}
public function setReaderId(?string $readerId): void
{
self::requireSelected();
if ($readerId === null || trim($readerId) === '') {
$this->reader_id->nullify();
self::objectChanged();
return;
}
$this->reader_id->set(trim($readerId));
self::objectChanged();
}
/**
* @throws Exception
*/
public function delete(): bool
{
self::requireSelected();
if (!$this->cancelPaymentIntent()) {
return false;
}
self::deletePermanently();
return true;
}
/**
* @throws Exception
*/
public function cancelPaymentIntent(): bool
{
self::requireSelected();
$stripe = new stripe();
$readerId = trim((string)($this->reader_id->value() ?? ''));
if ($readerId !== '') {
try {
$stripe->readers->sendCancelPaymentIntent($readerId);
} catch (\Stripe\Exception\InvalidRequestException) {
// The reader may already be idle or missing. Clearing local state is sufficient.
}
$this->reader_id->nullify();
}
$paymentIntentId = trim((string)($this->payment_intent_id->value() ?? ''));
if ($paymentIntentId === '') {
self::objectChanged();
return true;
}
try {
$paymentIntent = $stripe->payment_intents->get($paymentIntentId);
} catch (\Stripe\Exception\InvalidRequestException) {
self::objectChanged();
return true;
}
$status = strtolower((string)($paymentIntent->status ?? ''));
if ($status === 'succeeded') {
$this->updateStoredPaymentIntent($paymentIntent);
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) {
try {
$paymentIntent = $stripe->payment_intents->get($paymentIntentId);
$this->updateStoredPaymentIntent($paymentIntent);
return strtolower((string)($paymentIntent->status ?? '')) === 'canceled';
} catch (\Stripe\Exception\InvalidRequestException) {
self::objectChanged();
return false;
}
}
}
private function deleteDuplicateOrderPaymentIntents(int $order_id, int $keepId): void
{
$this->clearOrderPaymentIntents($order_id, $keepId);
}
}