## 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>
258 lines
8.4 KiB
PHP
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);
|
|
}
|
|
}
|