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