feat(119): protect total_paid from re-import overwrite

OrderImportRepository::updateOrderDelta() przechodzi na dynamic SET builder.
total_paid jest dolaczane do UPDATE tylko gdy payment_status realnie sie
zmienia; is_canceled_by_buyer analogicznie, ale z override przez
cancelledBySource (cancel ze zrodla nadal propaguje sie do bazy).

Chroni reczne korekty operatora (zwroty czesciowe) przed cichym
nadpisaniem z payloadu zrodla przy kolejnym sync. Incydent #976:
operator zwrocil klientowi 28,00 PLN obnizajac total_paid 119->91,
co bez tej zmiany byloby cofniete przez kolejny re-import shoppro.

Boundaries: identical-payload guard, paymentTransition, statusOverwriteAllowed,
cancel propagation (status_code='anulowane') - bez zmian.

Tests: tests/Unit/OrderImportRepositoryTest.php - 3 scenariusze
(preserve / transition / cancel propagation) via Reflection + sqlite
in-memory. PHPUnit run odroczony (vendor/ gitignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:57:04 +02:00
parent bcbb35bc6b
commit 3a2c419c25
4 changed files with 578 additions and 18 deletions

View File

@@ -86,7 +86,8 @@ final class OrderImportRepository
];
}
$this->updateOrderDelta($orderId, $orderData);
$paymentStatusUnchanged = (int) ($orderData['payment_status'] ?? 0) === (int) $oldPaymentStatus;
$this->updateOrderDelta($orderId, $orderData, $paymentStatusUnchanged, $cancelledBySource);
if ($paymentTransition || $statusOverwriteAllowed) {
$this->replacePayments($orderId, $payments);
@@ -189,33 +190,48 @@ final class OrderImportRepository
* send_date_*, ordered_at, source_created_at, preferences_json, is_invoice, is_encrypted,
* external_carrier_*, external_payment_type_id) are NOT overwritten on re-import.
*
* Phase 119-01: When `payment_status` is unchanged between DB and source payload,
* `total_paid` and `is_canceled_by_buyer` are NOT included in the UPDATE. This protects
* manual operator corrections to `total_paid` (e.g. partial refunds) from being silently
* reverted to the source-side amount on the next sync. `is_canceled_by_buyer` is still
* forced into the UPDATE when `$cancelledBySource=true` so source-side cancellations
* propagate even if the payment status did not move.
*
* @param array<string, mixed> $orderData
*/
private function updateOrderDelta(int $orderId, array $orderData): int
private function updateOrderDelta(int $orderId, array $orderData, bool $paymentStatusUnchanged, bool $cancelledBySource): int
{
$statement = $this->pdo->prepare(
'UPDATE orders
SET status_code = :status_code,
payment_status = :payment_status,
total_paid = :total_paid,
is_canceled_by_buyer = :is_canceled_by_buyer,
source_updated_at = :source_updated_at,
payload_json = :payload_json,
fetched_at = :fetched_at,
updated_at = NOW()
WHERE id = :id'
);
$setFragments = [
'status_code = :status_code',
'payment_status = :payment_status',
'source_updated_at = :source_updated_at',
'payload_json = :payload_json',
'fetched_at = :fetched_at',
'updated_at = NOW()',
];
$statement->execute([
$params = [
'id' => $orderId,
'status_code' => $orderData['status_code'] ?? null,
'payment_status' => $orderData['payment_status'] ?? null,
'total_paid' => $orderData['total_paid'] ?? null,
'is_canceled_by_buyer' => !empty($orderData['is_canceled_by_buyer']) ? 1 : 0,
'source_updated_at' => $orderData['source_updated_at'] ?? null,
'payload_json' => $this->encodeJson($orderData['payload_json'] ?? null),
'fetched_at' => $orderData['fetched_at'] ?? date('Y-m-d H:i:s'),
]);
];
if (!$paymentStatusUnchanged) {
$setFragments[] = 'total_paid = :total_paid';
$params['total_paid'] = $orderData['total_paid'] ?? null;
}
if (!$paymentStatusUnchanged || $cancelledBySource) {
$setFragments[] = 'is_canceled_by_buyer = :is_canceled_by_buyer';
$params['is_canceled_by_buyer'] = !empty($orderData['is_canceled_by_buyer']) ? 1 : 0;
}
$sql = 'UPDATE orders SET ' . implode(', ', $setFragments) . ' WHERE id = :id';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
return $orderId;
}