Files
orderPRO/src/Modules/Settings/ShopproPaymentStatusSyncService.php
Jacek Pyziak f8db8c0162 refactor(01-tech-debt): extract AllegroTokenManager and StringHelper
Phase 1 complete (2/2 plans):

- Plan 01-01: Extract AllegroTokenManager — OAuth token logic
  centralized from 4 classes into dedicated manager class

- Plan 01-02: Extract StringHelper — nullableString/normalizeDateTime/
  normalizeColorHex extracted from 15+ classes into App\Core\Support\StringHelper;
  removed 19 duplicate private methods

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:36:06 +01:00

385 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use App\Core\Support\StringHelper;
use App\Modules\Orders\OrdersRepository;
use PDO;
use Throwable;
final class ShopproPaymentStatusSyncService
{
private const PAID_STATUS = 2;
private const UNPAID_STATUS = 0;
/**
* @var array<int, string>
*/
private const DEFAULT_FINAL_STATUS_CODES = [
'wyslane',
'zrealizowane',
'anulowane',
'cancelled',
'canceled',
'delivered',
'returned',
'shipped',
];
public function __construct(
private readonly ShopproIntegrationsRepository $integrations,
private readonly ShopproApiClient $apiClient,
private readonly OrdersRepository $orders,
private readonly PDO $pdo
) {
}
/**
* @return array<string, mixed>
*/
public function sync(array $options = []): array
{
$perIntegrationLimit = max(1, min(500, (int) ($options['per_integration_limit'] ?? 100)));
$result = [
'ok' => true,
'checked_integrations' => 0,
'processed_orders' => 0,
'updated_orders' => 0,
'skipped_orders' => 0,
'failed_orders' => 0,
'errors' => [],
];
foreach ($this->integrations->listIntegrations() as $integration) {
$integrationId = (int) ($integration['id'] ?? 0);
if ($integrationId <= 0 || empty($integration['is_active']) || empty($integration['has_api_key'])) {
continue;
}
$baseUrl = trim((string) ($integration['base_url'] ?? ''));
$apiKey = $this->integrations->getApiKeyDecrypted($integrationId);
$timeout = max(1, min(120, (int) ($integration['timeout_seconds'] ?? 10)));
if ($baseUrl === '' || $apiKey === null || trim($apiKey) === '') {
continue;
}
$result['checked_integrations'] = (int) $result['checked_integrations'] + 1;
$watchedStatuses = $this->resolveWatchedStatusCodes($integration);
$orders = $this->findCandidateOrders($integrationId, $watchedStatuses, $perIntegrationLimit);
foreach ($orders as $order) {
$result['processed_orders'] = (int) $result['processed_orders'] + 1;
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
if ($sourceOrderId === '') {
$result['skipped_orders'] = (int) $result['skipped_orders'] + 1;
continue;
}
try {
$updated = $this->syncSingleOrderPayment(
$integrationId,
$baseUrl,
$apiKey,
$timeout,
$order
);
if ($updated) {
$result['updated_orders'] = (int) $result['updated_orders'] + 1;
} else {
$result['skipped_orders'] = (int) $result['skipped_orders'] + 1;
}
} catch (Throwable $exception) {
$result['failed_orders'] = (int) $result['failed_orders'] + 1;
$errors = is_array($result['errors']) ? $result['errors'] : [];
if (count($errors) < 20) {
$errors[] = [
'integration_id' => $integrationId,
'order_id' => (int) ($order['id'] ?? 0),
'source_order_id' => $sourceOrderId,
'error' => $exception->getMessage(),
];
}
$result['errors'] = $errors;
}
}
}
return $result;
}
/**
* @param array<string, mixed> $integration
* @return array<int, string>
*/
private function resolveWatchedStatusCodes(array $integration): array
{
$rawCodes = $integration['payment_sync_status_codes'] ?? [];
if (!is_array($rawCodes)) {
return [];
}
$result = [];
$seen = [];
foreach ($rawCodes as $rawCode) {
$code = strtolower(trim((string) $rawCode));
if ($code === '' || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$result[] = $code;
}
return $result;
}
/**
* @param array<int, string> $watchedStatuses
* @return array<int, array<string, mixed>>
*/
private function findCandidateOrders(int $integrationId, array $watchedStatuses, int $limit): array
{
$where = [
'source = :source',
'integration_id = :integration_id',
'source_order_id IS NOT NULL',
'source_order_id <> ""',
'(payment_status IS NULL OR payment_status <> :paid_status)',
];
$params = [
'source' => 'shoppro',
'integration_id' => $integrationId,
'paid_status' => self::PAID_STATUS,
];
$statusPlaceholders = [];
$statusCodes = $watchedStatuses !== [] ? $watchedStatuses : self::DEFAULT_FINAL_STATUS_CODES;
foreach ($statusCodes as $index => $statusCode) {
$placeholder = ':status_' . $index;
$statusPlaceholders[] = $placeholder;
$params['status_' . $index] = strtolower($statusCode);
}
if ($watchedStatuses !== []) {
$where[] = 'LOWER(COALESCE(external_status_id, "")) IN (' . implode(', ', $statusPlaceholders) . ')';
} else {
$where[] = 'LOWER(COALESCE(external_status_id, "")) NOT IN (' . implode(', ', $statusPlaceholders) . ')';
}
$sql = 'SELECT id, source_order_id, payment_status, total_paid, total_with_tax, currency, external_payment_type_id
FROM orders
WHERE ' . implode(' AND ', $where) . '
ORDER BY source_updated_at DESC, id DESC
LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
if (is_int($value)) {
$stmt->bindValue(':' . $key, $value, PDO::PARAM_INT);
continue;
}
$stmt->bindValue(':' . $key, $value);
}
$stmt->bindValue(':limit', max(1, min(1000, $limit)), PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
/**
* @param array<string, mixed> $order
*/
private function syncSingleOrderPayment(
int $integrationId,
string $baseUrl,
string $apiKey,
int $timeout,
array $order
): bool {
$sourceOrderId = trim((string) ($order['source_order_id'] ?? ''));
if ($sourceOrderId === '') {
return false;
}
$details = $this->apiClient->fetchOrderById($baseUrl, $apiKey, $timeout, $sourceOrderId);
if (($details['ok'] ?? false) !== true || !is_array($details['order'] ?? null)) {
throw new \RuntimeException((string) ($details['message'] ?? 'Blad pobierania szczegolow zamowienia.'));
}
$payload = (array) $details['order'];
$isPaid = $this->resolvePaidFlag($payload);
if ($isPaid === null) {
return false;
}
$newPaymentStatus = $isPaid ? self::PAID_STATUS : self::UNPAID_STATUS;
$existingTotalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null;
$newTotalPaid = $isPaid
? $this->resolvePaidAmount($payload, $existingTotalWithTax)
: 0.0;
$existingPaymentStatus = isset($order['payment_status']) ? (int) $order['payment_status'] : null;
$existingTotalPaid = $order['total_paid'] !== null ? (float) $order['total_paid'] : null;
$paymentMethod = StringHelper::nullableString((string) ($payload['payment_method'] ?? $order['external_payment_type_id'] ?? ''));
$paymentDate = StringHelper::normalizeDateTime((string) ($payload['payment_date'] ?? ''));
$sourceUpdatedAt = StringHelper::normalizeDateTime((string) ($payload['updated_at'] ?? $payload['date_updated'] ?? ''));
if (
$existingPaymentStatus === $newPaymentStatus
&& $this->floatsEqual($existingTotalPaid, $newTotalPaid)
&& $paymentMethod === StringHelper::nullableString((string) ($order['external_payment_type_id'] ?? ''))
) {
return false;
}
$orderId = (int) ($order['id'] ?? 0);
if ($orderId <= 0) {
return false;
}
$this->pdo->beginTransaction();
try {
$this->updateOrderPaymentColumns($orderId, $newPaymentStatus, $newTotalPaid, $paymentMethod, $sourceUpdatedAt);
$this->replaceOrderPaymentRow($orderId, $paymentMethod, $paymentDate, $newTotalPaid, (string) ($order['currency'] ?? 'PLN'), $isPaid);
$this->pdo->commit();
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
$summary = $isPaid
? 'shopPRO: zamowienie oznaczone jako oplacone'
: 'shopPRO: zamowienie oznaczone jako nieoplacone';
$this->orders->recordActivity(
$orderId,
'payment',
$summary,
[
'integration_id' => $integrationId,
'source_order_id' => $sourceOrderId,
'old_payment_status' => $existingPaymentStatus,
'new_payment_status' => $newPaymentStatus,
'old_total_paid' => $existingTotalPaid,
'new_total_paid' => $newTotalPaid,
],
'sync',
'shopPRO'
);
return true;
}
private function updateOrderPaymentColumns(
int $orderId,
int $paymentStatus,
?float $totalPaid,
?string $paymentMethod,
?string $sourceUpdatedAt
): void {
$sql = 'UPDATE orders
SET payment_status = :payment_status,
total_paid = :total_paid,
external_payment_type_id = :external_payment_type_id,
fetched_at = NOW(),
updated_at = NOW()';
$params = [
'id' => $orderId,
'payment_status' => $paymentStatus,
'total_paid' => $totalPaid,
'external_payment_type_id' => $paymentMethod,
];
if ($sourceUpdatedAt !== null) {
$sql .= ', source_updated_at = :source_updated_at';
$params['source_updated_at'] = $sourceUpdatedAt;
}
$sql .= ' WHERE id = :id';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
}
private function replaceOrderPaymentRow(
int $orderId,
?string $paymentMethod,
?string $paymentDate,
?float $amount,
string $currency,
bool $isPaid
): void {
$deleteStmt = $this->pdo->prepare('DELETE FROM order_payments WHERE order_id = :order_id');
$deleteStmt->execute(['order_id' => $orderId]);
$insertStmt = $this->pdo->prepare(
'INSERT INTO order_payments (
order_id, source_payment_id, external_payment_id, payment_type_id, payment_date, amount, currency, comment, payload_json
) VALUES (
:order_id, NULL, NULL, :payment_type_id, :payment_date, :amount, :currency, :comment, NULL
)'
);
$insertStmt->execute([
'order_id' => $orderId,
'payment_type_id' => $paymentMethod ?? 'unknown',
'payment_date' => $paymentDate,
'amount' => $amount,
'currency' => trim($currency) !== '' ? strtoupper($currency) : 'PLN',
'comment' => $isPaid ? 'paid' : 'unpaid',
]);
}
private function resolvePaidFlag(array $payload): ?bool
{
$raw = $payload['paid'] ?? $payload['is_paid'] ?? null;
if ($raw === null) {
return null;
}
if (is_bool($raw)) {
return $raw;
}
$value = strtolower(trim((string) $raw));
if (in_array($value, ['1', 'true', 'yes', 'paid'], true)) {
return true;
}
if (in_array($value, ['0', 'false', 'no', 'unpaid'], true)) {
return false;
}
return null;
}
private function resolvePaidAmount(array $payload, ?float $fallbackGross): ?float
{
$value = $payload['total_paid'] ?? null;
if ($value !== null && is_numeric((string) $value)) {
return (float) $value;
}
$grossCandidates = [
$payload['total_gross'] ?? null,
$payload['total_with_tax'] ?? null,
$payload['summary']['total'] ?? null,
$payload['summary'] ?? null,
];
foreach ($grossCandidates as $candidate) {
if ($candidate !== null && is_numeric((string) $candidate)) {
return (float) $candidate;
}
}
return $fallbackGross;
}
private function floatsEqual(?float $left, ?float $right): bool
{
if ($left === null && $right === null) {
return true;
}
if ($left === null || $right === null) {
return false;
}
return abs($left - $right) < 0.00001;
}
}