Files

882 lines
30 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\ChdAdmin\Service;
use OCA\QortalIntegration\Service\BrokerClient;
use OCA\QortalIntegration\Service\IntegrationConfig;
use OCA\QortalIntegration\Service\NotificationService;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IUserManager;
class BillingAutoFulfillmentService {
private const APP_ID = 'chd_admin';
private const STATE_KEY = 'billing_auto_fulfillment_state';
private const ENABLED_KEY = 'billing_auto_fulfillment_enabled';
private const QORTAL_INTEGRATION_APP_ID = 'qortal_integration';
private const QORTAL_INTEGRATION_KEY_BROKER_BASE_URL = 'broker_base_url';
private const QORTAL_INTEGRATION_KEY_BROKER_INTERNAL_API_TOKEN = 'broker_internal_api_token';
private const QORTAL_INTEGRATION_KEY_CHD_BASE_URL = 'sc_chd_base_url';
private const QORTAL_INTEGRATION_KEY_CHD_ADMIN_API_TOKEN = 'sc_chd_admin_api_token';
private const QORTAL_INTEGRATION_KEY_CHD_REGISTRATION_TOKEN = 'sc_chd_registration_token';
private const QORTAL_INTEGRATION_KEY_OIDC_INVITE_TTL_SECONDS = 'oidc_invite_ttl_seconds';
private const DEFAULT_LOOKBACK_SECONDS = 604800;
private const RETRY_AFTER_EMAIL_FAILURE_SECONDS = 900;
private const RETRY_AFTER_DATA_ISSUE_SECONDS = 86400;
private const RETRY_AFTER_INVITE_FAILURE_SECONDS = 900;
private const CURRENT_MONTHLY_PAYMENT_GRACE_SECONDS = 3888000;
private const MAX_STORED_ORDERS = 500;
private const PAGE_LIMIT = 100;
private const MAX_PAGES = 10;
private const PACKAGE_TIER_ALIASES = [
'nuqloud_team' => 'nuqloud_team_starter',
'replication_basic' => 'nuqloud_starter',
'replication_standard' => 'nuqloud_advanced',
'replication_enterprise' => 'nuqloud_pro',
];
private const ALLOWED_PACKAGE_TIERS = [
'nuqloud_starter',
'nuqloud_advanced',
'nuqloud_pro',
'nuqloud_team_starter',
'nuqloud_team_advanced',
'nuqloud_team_pro',
'nuqloud_branded',
'nuqloud_branded_pro',
'nuqloud_branded_enterprise',
];
public function __construct(
private IConfig $config,
private IClientService $clientService,
private BrokerClient $brokerClient,
private IntegrationConfig $integrationConfig,
private NotificationService $notificationService,
private IUserManager $userManager,
) {
}
/** @return array<string, mixed> */
public function getOverview(): array {
$state = $this->loadState();
return $this->buildOverview($state);
}
/** @return array<string, mixed> */
public function saveEnabled(bool $enabled): array {
$this->config->setAppValue(self::APP_ID, self::ENABLED_KEY, $enabled ? '1' : '0');
return $this->getOverview();
}
/** @return array<string, mixed> */
public function runCycle(bool $force = false): array {
return $this->runFulfillmentCycle($force, '', '', true);
}
/** @return array<string, mixed> */
public function runBackfill(string $since, string $invoiceId): array {
$since = trim($since);
if ($since !== '' && strtotime($since) === false) {
return [
'ok' => false,
'status' => 'error',
'message' => 'Invalid since date.',
'overview' => $this->getOverview(),
];
}
$invoiceId = $this->sanitizeReplayId($invoiceId);
if ($since === '' && $invoiceId === '') {
return [
'ok' => false,
'status' => 'error',
'message' => 'Backfill requires a since date or invoice ID.',
'overview' => $this->getOverview(),
];
}
return $this->runFulfillmentCycle(true, $since, $invoiceId, false);
}
/** @return array<string, mixed> */
private function runFulfillmentCycle(bool $force, string $sinceOverride, string $invoiceId, bool $updateWatermark): array {
$startedAt = gmdate('c');
$state = $this->loadState();
$enabled = $this->isEnabled();
if (!$enabled && !$force) {
$this->updateRunState($state, [
'lastRunStartedAt' => $startedAt,
'lastRunCompletedAt' => gmdate('c'),
'lastRunStatus' => 'skipped',
'lastRunMessage' => 'Automatic invite fulfillment is disabled.',
'lastRunCount' => 0,
'lastRunFulfilledCount' => 0,
'lastRunSkippedCount' => 0,
'lastRunErrorCount' => 0,
]);
$this->saveState($state);
return [
'ok' => true,
'status' => 'skipped',
'startedAt' => $startedAt,
'completedAt' => gmdate('c'),
'processed' => 0,
'fulfilled' => 0,
'skipped' => 0,
'errors' => 0,
'message' => 'Automatic invite fulfillment is disabled.',
'overview' => $this->buildOverview($state),
];
}
try {
$since = $sinceOverride !== ''
? gmdate('c', (int)strtotime($sinceOverride))
: ($invoiceId !== '' ? '' : $this->resolveLookbackSince($state));
$orders = $this->fetchConnectorOrders($since, $invoiceId, $sinceOverride !== '' || $invoiceId !== '');
$summary = [
'ok' => true,
'status' => 'success',
'startedAt' => $startedAt,
'completedAt' => '',
'processed' => 0,
'fulfilled' => 0,
'skipped' => 0,
'errors' => 0,
'message' => '',
];
foreach ($orders as $order) {
if (!is_array($order)) {
continue;
}
if (!$this->isFulfillmentEligibleStatus($order)) {
continue;
}
$summary['processed']++;
$result = $this->processEligibleOrder($order, $state);
$status = (string)($result['status'] ?? 'skipped');
if ($status === 'fulfilled') {
$summary['fulfilled']++;
} elseif ($status === 'error') {
$summary['errors']++;
} else {
$summary['skipped']++;
}
}
$message = $this->buildRunMessage($summary);
$this->updateRunState($state, [
'lastRunStartedAt' => $startedAt,
'lastRunCompletedAt' => gmdate('c'),
'lastRunStatus' => ((int)$summary['errors'] > 0) ? 'error' : 'success',
'lastRunMessage' => $message,
'lastRunCount' => (int)$summary['processed'],
'lastRunFulfilledCount' => (int)$summary['fulfilled'],
'lastRunSkippedCount' => (int)$summary['skipped'],
'lastRunErrorCount' => (int)$summary['errors'],
'lastRunSince' => $since,
]);
if ($updateWatermark) {
$this->updateRunState($state, [
'lastSuccessfulRunCompletedAt' => gmdate('c'),
]);
}
$this->pruneState($state);
$this->saveState($state);
$summary['completedAt'] = gmdate('c');
$summary['message'] = $message;
$summary['overview'] = $this->buildOverview($state);
return $summary;
} catch (\Throwable $e) {
$this->updateRunState($state, [
'lastRunStartedAt' => $startedAt,
'lastRunCompletedAt' => gmdate('c'),
'lastRunStatus' => 'error',
'lastRunMessage' => $e->getMessage(),
]);
$this->saveState($state);
return [
'ok' => false,
'status' => 'error',
'startedAt' => $startedAt,
'completedAt' => gmdate('c'),
'processed' => 0,
'fulfilled' => 0,
'skipped' => 0,
'errors' => 1,
'message' => $e->getMessage(),
'overview' => $this->buildOverview($state),
];
}
}
/** @return array<string, mixed> */
private function processEligibleOrder(array $order, array &$state): array {
$orderKey = $this->resolveOrderKey($order);
if ($orderKey === '') {
return ['status' => 'skipped', 'reason' => 'missing_order_id'];
}
$entry = $this->getEntry($state, $orderKey);
$sourceUpdatedAt = $this->resolveOrderUpdatedAt($order);
if ($this->isEntryFulfilled($entry)) {
return ['status' => 'skipped', 'reason' => 'already_fulfilled'];
}
if ($this->shouldDelayRetry($entry, $sourceUpdatedAt)) {
return ['status' => 'skipped', 'reason' => 'retry_later'];
}
$packageTier = $this->resolvePackageTier($order);
if ($packageTier === '' || !$this->isAllowedPackageTier($packageTier)) {
return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'unsupported_package_tier', self::RETRY_AFTER_DATA_ISSUE_SECONDS);
}
if (!$this->hasCurrentMonthlyPayment($order)) {
return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'stale_monthly_invoice', self::RETRY_AFTER_DATA_ISSUE_SECONDS);
}
$recipientEmail = $this->resolveRecipientEmail($order);
if ($recipientEmail === '') {
return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'missing_recipient_email', self::RETRY_AFTER_DATA_ISSUE_SECONDS);
}
if ($this->isExistingNextcloudUser($recipientEmail)) {
return $this->recordSkip($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'existing_nextcloud_user', self::RETRY_AFTER_DATA_ISSUE_SECONDS);
}
$displayName = $this->resolveRecipientDisplayName($order, $recipientEmail);
$inviteToken = trim((string)($entry['inviteToken'] ?? ''));
if ($inviteToken === '') {
try {
$inviteHours = $this->resolveInviteTtlHours();
$inviteResponse = $this->brokerClient->createInvite(
$inviteHours,
'chd_admin_auto_fulfillment',
$this->buildInviteComment($orderKey, $order, $recipientEmail, $packageTier)
);
$inviteToken = $this->extractInviteToken($inviteResponse);
if ($inviteToken === '') {
throw new \RuntimeException('Invite token was not returned by the broker');
}
$entry['inviteToken'] = $inviteToken;
$entry['inviteCreatedAt'] = gmdate('c');
$entry['inviteResponseStatus'] = (string)($inviteResponse['status'] ?? '');
} catch (\Throwable $e) {
return $this->recordError($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'invite_creation_failed: ' . $e->getMessage(), self::RETRY_AFTER_INVITE_FAILURE_SECONDS);
}
}
$emailResult = $this->notificationService->sendInviteEmailToAddress(
$recipientEmail,
$displayName,
$inviteToken,
[
'orderId' => $orderKey,
'packageTier' => $packageTier,
'paymentStatus' => (string)($order['paymentStatus'] ?? ''),
'instanceId' => (string)($order['instanceId'] ?? ''),
'customerName' => (string)($order['customerName'] ?? ''),
'customerEmail' => $recipientEmail,
]
);
if (empty($emailResult['sent'])) {
$error = trim((string)($emailResult['error'] ?? 'email_send_failed'));
return $this->recordError($state, $orderKey, $entry, $order, $sourceUpdatedAt, 'email_send_failed: ' . $error, self::RETRY_AFTER_EMAIL_FAILURE_SECONDS);
}
$entry['orderId'] = $orderKey;
$entry['invoiceId'] = (string)($order['invoiceId'] ?? '');
$entry['instanceId'] = (string)($order['instanceId'] ?? '');
$entry['packageTier'] = $packageTier;
$entry['customerEmail'] = $recipientEmail;
$entry['displayName'] = $displayName;
$entry['paymentStatus'] = (string)($order['paymentStatus'] ?? '');
$entry['lastSourceUpdatedAt'] = $sourceUpdatedAt;
$entry['inviteToken'] = $inviteToken;
$entry['emailSentAt'] = gmdate('c');
$entry['fulfilledAt'] = gmdate('c');
$entry['retryAfterAt'] = '';
$entry['lastError'] = '';
$entry['lastStatus'] = 'fulfilled';
$entry['lastAttemptAt'] = gmdate('c');
$this->setEntry($state, $orderKey, $entry);
$this->pruneState($state);
$this->saveState($state);
return [
'status' => 'fulfilled',
'orderId' => $orderKey,
'inviteToken' => $inviteToken,
];
}
/** @return array<string, mixed> */
private function recordSkip(array &$state, string $orderKey, array $entry, array $order, string $sourceUpdatedAt, string $reason, int $retryAfterSeconds): array {
$entry['orderId'] = $orderKey;
$entry['invoiceId'] = (string)($order['invoiceId'] ?? '');
$entry['instanceId'] = (string)($order['instanceId'] ?? '');
$entry['packageTier'] = $this->resolvePackageTier($order);
$entry['customerEmail'] = $this->resolveRecipientEmail($order);
$entry['displayName'] = $this->resolveRecipientDisplayName($order, $entry['customerEmail'] ?? '');
$entry['paymentStatus'] = (string)($order['paymentStatus'] ?? '');
$entry['lastSourceUpdatedAt'] = $sourceUpdatedAt;
$entry['lastAttemptAt'] = gmdate('c');
$entry['lastStatus'] = 'skipped';
$entry['lastError'] = $reason;
$entry['retryAfterAt'] = gmdate('c', time() + $retryAfterSeconds);
$this->setEntry($state, $orderKey, $entry);
$this->pruneState($state);
$this->saveState($state);
return [
'status' => 'skipped',
'orderId' => $orderKey,
'reason' => $reason,
];
}
/** @return array<string, mixed> */
private function recordError(array &$state, string $orderKey, array $entry, array $order, string $sourceUpdatedAt, string $reason, int $retryAfterSeconds): array {
$entry['orderId'] = $orderKey;
$entry['invoiceId'] = (string)($order['invoiceId'] ?? '');
$entry['instanceId'] = (string)($order['instanceId'] ?? '');
$entry['packageTier'] = $this->resolvePackageTier($order);
$entry['customerEmail'] = $this->resolveRecipientEmail($order);
$entry['displayName'] = $this->resolveRecipientDisplayName($order, $entry['customerEmail'] ?? '');
$entry['paymentStatus'] = (string)($order['paymentStatus'] ?? '');
$entry['lastSourceUpdatedAt'] = $sourceUpdatedAt;
$entry['lastAttemptAt'] = gmdate('c');
$entry['lastStatus'] = 'error';
$entry['lastError'] = $reason;
$entry['retryAfterAt'] = gmdate('c', time() + $retryAfterSeconds);
$this->setEntry($state, $orderKey, $entry);
$this->pruneState($state);
$this->saveState($state);
return [
'status' => 'error',
'orderId' => $orderKey,
'reason' => $reason,
];
}
/** @return array<string, mixed> */
private function getEntry(array $state, string $orderKey): array {
$processed = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : [];
$entry = $processed[$orderKey] ?? [];
return is_array($entry) ? $entry : [];
}
/** @param array<string, mixed> $entry */
private function setEntry(array &$state, string $orderKey, array $entry): void {
$processed = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : [];
$processed[$orderKey] = $entry;
$state['processedOrders'] = $processed;
}
private function isEntryFulfilled(array $entry): bool {
return trim((string)($entry['fulfilledAt'] ?? '')) !== '';
}
private function shouldDelayRetry(array $entry, string $sourceUpdatedAt): bool {
$retryAfterAt = trim((string)($entry['retryAfterAt'] ?? ''));
if ($retryAfterAt === '') {
return false;
}
$entrySourceUpdatedAt = trim((string)($entry['lastSourceUpdatedAt'] ?? ''));
if ($entrySourceUpdatedAt !== '' && $entrySourceUpdatedAt !== $sourceUpdatedAt) {
return false;
}
$retryTs = strtotime($retryAfterAt);
return $retryTs !== false && $retryTs > time();
}
private function resolveLookbackSince(array $state): string {
$lastRunAt = trim((string)($state['lastSuccessfulRunCompletedAt'] ?? ''));
if ($lastRunAt === '') {
return gmdate('c', max(0, time() - self::DEFAULT_LOOKBACK_SECONDS));
}
$lastTs = strtotime($lastRunAt);
if ($lastTs === false || $lastTs <= 0) {
return gmdate('c', max(0, time() - self::DEFAULT_LOOKBACK_SECONDS));
}
return gmdate('c', max(0, $lastTs - self::RETRY_AFTER_EMAIL_FAILURE_SECONDS));
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchConnectorOrders(string $since, string $invoiceId = '', bool $wideScan = false): array {
$baseUrl = $this->resolveConnectorAdminBaseUrl();
if ($baseUrl === '') {
throw new \RuntimeException('CHD connector base URL is not configured');
}
$adminToken = $this->resolveConnectorAdminToken();
if ($adminToken === '') {
throw new \RuntimeException('CHD connector admin API token is not configured');
}
$query = [
'limit' => self::PAGE_LIMIT,
'offset' => 0,
'maxPages' => $wideScan ? 50 : self::MAX_PAGES,
];
if ($since !== '') {
$query['since'] = $since;
}
if ($invoiceId !== '') {
$query['search'] = $invoiceId;
}
$queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$url = $baseUrl . '/api/sovereign/v1/admin/paymenter/orders' . ($queryString !== '' ? ('?' . $queryString) : '');
$response = $this->requestConnectorAdminEndpoint('GET', $url, $adminToken);
$orders = is_array($response['orders'] ?? null) ? $response['orders'] : [];
if ($invoiceId === '') {
return $orders;
}
return array_values(array_filter($orders, fn ($order): bool => is_array($order) && $this->orderMatchesInvoiceId($order, $invoiceId)));
}
private function isFulfillmentEligibleStatus(array $order): bool {
$status = strtolower(trim((string)($order['status'] ?? '')));
$paymentStatus = strtolower(trim((string)($order['paymentStatus'] ?? '')));
return $this->containsFulfilledMarker($status) || $this->containsFulfilledMarker($paymentStatus);
}
private function containsFulfilledMarker(string $status): bool {
if ($status === '') {
return false;
}
return str_contains($status, 'paid')
|| str_contains($status, 'completed')
|| str_contains($status, 'active')
|| str_contains($status, 'running')
|| str_contains($status, 'provision')
|| str_contains($status, 'fulfilled')
|| str_contains($status, 'sent');
}
private function resolveOrderKey(array $order): string {
$orderId = trim((string)($order['orderId'] ?? $order['invoiceId'] ?? ''));
if ($orderId !== '') {
return $orderId;
}
$paymenterCustomerId = trim((string)($order['paymenterCustomerId'] ?? ''));
$packageTier = trim((string)($order['packageTier'] ?? ''));
$updatedAt = trim((string)($order['updatedAt'] ?? $order['createdAt'] ?? ''));
if ($paymenterCustomerId !== '' && $packageTier !== '' && $updatedAt !== '') {
return $paymenterCustomerId . ':' . $packageTier . ':' . $updatedAt;
}
return '';
}
private function orderMatchesInvoiceId(array $order, string $invoiceId): bool {
$invoiceId = strtolower($this->sanitizeReplayId($invoiceId));
if ($invoiceId === '') {
return true;
}
$candidates = [
(string)($order['invoiceId'] ?? ''),
(string)($order['invoice_id'] ?? ''),
(string)($order['raw']['invoice_id'] ?? ''),
(string)($order['raw']['invoiceId'] ?? ''),
(string)($order['raw']['attributes']['invoice_id'] ?? ''),
(string)($order['raw']['attributes']['invoiceId'] ?? ''),
];
foreach ($candidates as $candidate) {
if (strtolower($this->sanitizeReplayId($candidate)) === $invoiceId) {
return true;
}
}
return false;
}
private function hasCurrentMonthlyPayment(array $order): bool {
$paidAt = $this->resolvePaymentFreshnessTimestamp($order);
if ($paidAt <= 0) {
return true;
}
return $paidAt >= (time() - self::CURRENT_MONTHLY_PAYMENT_GRACE_SECONDS);
}
private function resolvePaymentFreshnessTimestamp(array $order): int {
$candidates = [
(string)($order['paidAt'] ?? ''),
(string)($order['paid_at'] ?? ''),
(string)($order['dueAt'] ?? ''),
(string)($order['due_at'] ?? ''),
(string)($order['updatedAt'] ?? ''),
(string)($order['updated_at'] ?? ''),
(string)($order['createdAt'] ?? ''),
(string)($order['created_at'] ?? ''),
(string)($order['raw']['paid_at'] ?? ''),
(string)($order['raw']['attributes']['paid_at'] ?? ''),
(string)($order['raw']['due_at'] ?? ''),
(string)($order['raw']['attributes']['due_at'] ?? ''),
(string)($order['raw']['updated_at'] ?? ''),
(string)($order['raw']['attributes']['updated_at'] ?? ''),
(string)($order['raw']['created_at'] ?? ''),
(string)($order['raw']['attributes']['created_at'] ?? ''),
];
foreach ($candidates as $candidate) {
$ts = strtotime(trim($candidate));
if ($ts !== false && $ts > 0) {
return $ts;
}
}
return 0;
}
private function sanitizeReplayId(string $value): string {
return preg_replace('/[^A-Za-z0-9_.:-]/', '', trim($value)) ?? '';
}
private function resolveOrderUpdatedAt(array $order): string {
$updatedAt = trim((string)($order['updatedAt'] ?? ''));
if ($updatedAt !== '') {
return $updatedAt;
}
return trim((string)($order['createdAt'] ?? ''));
}
private function resolvePackageTier(array $order): string {
$candidates = [
(string)($order['packageTier'] ?? ''),
(string)($order['packageTierHint'] ?? ''),
(string)($order['productName'] ?? ''),
];
foreach ($candidates as $candidate) {
$normalized = $this->normalizePackageTier($candidate);
if ($normalized !== '' && $normalized !== 'free_core') {
return $normalized;
}
}
return '';
}
private function normalizePackageTier(string $packageTier): string {
$normalized = strtolower(trim($packageTier));
if ($normalized === '') {
return '';
}
if (isset(self::PACKAGE_TIER_ALIASES[$normalized])) {
$normalized = self::PACKAGE_TIER_ALIASES[$normalized];
}
return $normalized;
}
private function isAllowedPackageTier(string $packageTier): bool {
return in_array($this->normalizePackageTier($packageTier), self::ALLOWED_PACKAGE_TIERS, true);
}
private function resolveRecipientEmail(array $order): string {
$candidates = [
(string)($order['customerEmail'] ?? ''),
(string)($order['mspContactEmail'] ?? ''),
(string)($order['adminEmail'] ?? ''),
];
foreach ($candidates as $candidate) {
$email = trim($candidate);
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false) {
return $email;
}
}
return '';
}
private function resolveRecipientDisplayName(array $order, string $recipientEmail): string {
$candidates = [
(string)($order['customerName'] ?? ''),
(string)($order['displayName'] ?? ''),
(string)($order['mspContactEmail'] ?? ''),
(string)($order['adminEmail'] ?? ''),
];
foreach ($candidates as $candidate) {
$displayName = trim($candidate);
if ($displayName !== '') {
return $displayName;
}
}
return $recipientEmail !== '' ? $recipientEmail : 'NuQloud customer';
}
private function isExistingNextcloudUser(string $email): bool {
if (!method_exists($this->userManager, 'getByEmail')) {
return false;
}
try {
$matches = $this->userManager->getByEmail($email);
return is_array($matches) ? $matches !== [] : (bool)$matches;
} catch (\Throwable) {
return false;
}
}
private function resolveInviteTtlHours(): ?int {
$settings = $this->integrationConfig->getSettings();
$secondsRaw = trim((string)($settings['oidcInviteTtlSeconds'] ?? ''));
if ($secondsRaw === '' || !ctype_digit($secondsRaw)) {
return null;
}
$seconds = (int)$secondsRaw;
if ($seconds <= 0) {
return null;
}
return max(1, (int)ceil($seconds / 3600));
}
private function buildInviteComment(string $orderKey, array $order, string $recipientEmail, string $packageTier): string {
$parts = [
'auto-fulfillment',
'order=' . $orderKey,
'instance=' . trim((string)($order['instanceId'] ?? '')),
'tier=' . $packageTier,
'email=' . $recipientEmail,
];
$comment = implode('; ', array_filter($parts, static fn (string $value): bool => trim($value) !== ''));
return substr($comment, 0, 500);
}
/** @param array<string, mixed> $inviteResponse */
private function extractInviteToken(array $inviteResponse): string {
$candidates = [
$inviteResponse['token'] ?? null,
$inviteResponse['invite']['token'] ?? null,
$inviteResponse['invite']['inviteToken'] ?? null,
$inviteResponse['data']['token'] ?? null,
$inviteResponse['data']['invite']['token'] ?? null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
return '';
}
/** @return array<string, mixed> */
private function buildRunMessage(array $summary): string {
$processed = (int)($summary['processed'] ?? 0);
$fulfilled = (int)($summary['fulfilled'] ?? 0);
$skipped = (int)($summary['skipped'] ?? 0);
$errors = (int)($summary['errors'] ?? 0);
if ($processed === 0) {
return 'No eligible paid orders were found.';
}
return sprintf(
'Processed %d paid orders: %d fulfilled, %d skipped, %d errors.',
$processed,
$fulfilled,
$skipped,
$errors
);
}
/** @param array<string, mixed> $state */
private function buildOverview(array $state): array {
$processedOrders = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : [];
$processedCount = 0;
$fulfilledCount = 0;
$pendingCount = 0;
$errorCount = 0;
foreach ($processedOrders as $entry) {
if (!is_array($entry)) {
continue;
}
$processedCount++;
$fulfilledAt = trim((string)($entry['fulfilledAt'] ?? ''));
$inviteToken = trim((string)($entry['inviteToken'] ?? ''));
$lastError = trim((string)($entry['lastError'] ?? ''));
if ($fulfilledAt !== '') {
$fulfilledCount++;
} elseif ($inviteToken !== '') {
$pendingCount++;
}
if ($lastError !== '') {
$errorCount++;
}
}
return [
'enabled' => $this->isEnabled(),
'lastRunStartedAt' => trim((string)($state['lastRunStartedAt'] ?? '')),
'lastRunCompletedAt' => trim((string)($state['lastRunCompletedAt'] ?? '')),
'lastSuccessfulRunCompletedAt' => trim((string)($state['lastSuccessfulRunCompletedAt'] ?? '')),
'lastRunStatus' => trim((string)($state['lastRunStatus'] ?? 'idle')),
'lastRunMessage' => trim((string)($state['lastRunMessage'] ?? '')),
'lastRunCount' => (int)($state['lastRunCount'] ?? 0),
'lastRunFulfilledCount' => (int)($state['lastRunFulfilledCount'] ?? 0),
'lastRunSkippedCount' => (int)($state['lastRunSkippedCount'] ?? 0),
'lastRunErrorCount' => (int)($state['lastRunErrorCount'] ?? 0),
'lastRunSince' => trim((string)($state['lastRunSince'] ?? '')),
'processedCount' => $processedCount,
'fulfilledCount' => $fulfilledCount,
'pendingCount' => $pendingCount,
'errorCount' => $errorCount,
];
}
/** @param array<string, mixed> $state */
private function updateRunState(array &$state, array $updates): void {
foreach ($updates as $key => $value) {
if (!is_string($key) || $key === '') {
continue;
}
$state[$key] = $value;
}
}
/** @return array<string, mixed> */
private function loadState(): array {
$raw = trim((string)$this->config->getAppValue(self::APP_ID, self::STATE_KEY, ''));
if ($raw === '') {
return [];
}
try {
$decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : [];
} catch (\Throwable) {
return [];
}
}
/** @param array<string, mixed> $state */
private function saveState(array $state): void {
$state['processedOrders'] = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : [];
$encoded = json_encode($state, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($encoded) || $encoded === '') {
return;
}
$this->config->setAppValue(self::APP_ID, self::STATE_KEY, $encoded);
}
private function pruneState(array &$state): void {
$processedOrders = is_array($state['processedOrders'] ?? null) ? $state['processedOrders'] : [];
if ($processedOrders === []) {
$state['processedOrders'] = [];
return;
}
uasort($processedOrders, static function (array $left, array $right): int {
$leftTs = strtotime(trim((string)($left['fulfilledAt'] ?? $left['lastAttemptAt'] ?? $left['inviteCreatedAt'] ?? ''))) ?: 0;
$rightTs = strtotime(trim((string)($right['fulfilledAt'] ?? $right['lastAttemptAt'] ?? $right['inviteCreatedAt'] ?? ''))) ?: 0;
return $rightTs <=> $leftTs;
});
$state['processedOrders'] = array_slice($processedOrders, 0, self::MAX_STORED_ORDERS, true);
}
private function isEnabled(): bool {
return $this->config->getAppValue(self::APP_ID, self::ENABLED_KEY, '0') === '1';
}
private function resolveConnectorAdminBaseUrl(): string {
$config = $this->loadConnectorRoutingConfig();
$activeProfile = trim((string)($config['activeProfile'] ?? ''));
$profileBaseUrl = $this->sanitizeConnectorBaseUrl((string)($config['profiles'][$activeProfile]['baseUrl'] ?? ''));
if ($profileBaseUrl !== '') {
return $profileBaseUrl;
}
return $this->sanitizeConnectorBaseUrl($this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_BASE_URL, ''));
}
private function resolveConnectorAdminToken(): string {
$token = trim($this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_ADMIN_API_TOKEN, ''));
if ($token !== '') {
return $token;
}
return trim($this->getIntegrationAppValue(self::QORTAL_INTEGRATION_KEY_CHD_REGISTRATION_TOKEN, ''));
}
private function getIntegrationAppValue(string $key, string $default = ''): string {
return trim((string)$this->config->getAppValue(self::QORTAL_INTEGRATION_APP_ID, $key, $default));
}
/**
* @return array<string, mixed>
*/
private function loadConnectorRoutingConfig(): array {
$raw = trim((string)$this->config->getAppValue(self::APP_ID, 'connector_routing_config', ''));
if ($raw === '') {
return $this->normalizeConnectorRoutingConfig([]);
}
try {
$decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
return is_array($decoded) ? $this->normalizeConnectorRoutingConfig($decoded) : $this->normalizeConnectorRoutingConfig([]);
} catch (\Throwable) {
return $this->normalizeConnectorRoutingConfig([]);
}
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeConnectorRoutingConfig(array $payload): array {
$profilesIn = is_array($payload['profiles'] ?? null) ? $payload['profiles'] : [];
$profiles = [];
foreach (['test', 'production'] as $profileId) {
$entry = is_array($profilesIn[$profileId] ?? null) ? $profilesIn[$profileId] : [];
$profiles[$profileId] = [
'baseUrl' => $this->sanitizeConnectorBaseUrl((string)($entry['baseUrl'] ?? '')),
];
}
$activeProfile = trim((string)($payload['activeProfile'] ?? ''));
if (!in_array($activeProfile, ['test', 'production'], true)) {
$activeProfile = '';
}
return [
'version' => 1,
'activeProfile' => $activeProfile,
'profiles' => $profiles,
];
}
private function sanitizeConnectorBaseUrl(string $value): string {
$value = trim($value);
if ($value === '') {
return '';
}
if (!preg_match('#^https?://#i', $value)) {
return '';
}
return rtrim($value, '/');
}
/** @param array<string, mixed> $payload */
/** @return array<string, mixed> */
private function requestConnectorAdminEndpoint(string $method, string $url, string $adminToken): array {
$client = $this->clientService->newClient();
$options = [
'timeout' => 20,
'connect_timeout' => 3,
'nextcloud' => [
'allow_local_address' => true,
],
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'X-SC-Admin-Token' => $adminToken,
],
];
$verb = strtoupper(trim($method));
$response = $verb === 'POST'
? $client->post($url, $options)
: $client->get($url, $options);
$statusCode = (int)$response->getStatusCode();
$rawBody = (string)$response->getBody();
try {
$decoded = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable) {
throw new \RuntimeException('Invalid JSON response from connector admin endpoint');
}
if (!is_array($decoded)) {
throw new \RuntimeException('Invalid connector admin response payload');
}
if ($statusCode >= 400 || (array_key_exists('ok', $decoded) && empty($decoded['ok']))) {
$message = trim((string)($decoded['error'] ?? $decoded['message'] ?? ('HTTP ' . $statusCode)));
throw new \RuntimeException($message !== '' ? $message : ('HTTP ' . $statusCode));
}
return $decoded;
}
}