turnstile

Drupal paywall plugin
Log | Files | Refs | README | LICENSE

commit 06b8bf5e167e96d7bbf02c08bafc2a065d8fab7e
parent b1725091d794663f7a72e5165342f25ede92b407
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed, 20 May 2026 21:45:38 +0200

surface merchant-backend network errors in admin UI

Introduce TalerBackendResult / TalerBackendErrorKind so every
merchant-backend call returns a typed outcome instead of FALSE/[]/0,
and route the precise failure (unreachable, 401/403, 5xx, malformed
response, ...) to messenger() in the price-category, subscription-
prices, and settings forms.

Template-sync failures triggered by entity save/delete are stashed
on the entity instance and surfaced by the forms; syncAllTemplates()
returns per-category results so TurnstileConfigSubscriber can warn
when settings save but templates fail to publish.

Also: REQUEST_TIMEOUT_SECONDS constant, duplicate allow_redirects
key removed, subscriptions/currencies caches keyed by hash(url,
token) so backend/token changes invalidate immediately, checkConfig
logs transport errors instead of swallowing them, and an empty URL
no longer blocks saving the settings form.

Diffstat:
Msrc/Controller/PaivanaController.php | 10+++++++---
Msrc/Entity/TurnstilePriceCategory.php | 19+++++++++++++++++--
Msrc/EventSubscriber/TurnstileConfigSubscriber.php | 53+++++++++++++++++++++++++++++++++++++++++++----------
Msrc/Form/PriceCategoryDeleteForm.php | 16++++++++++++++++
Msrc/Form/PriceCategoryForm.php | 41+++++++++++++++++++++++++++++++++++------
Msrc/Form/SubscriptionPricesForm.php | 27+++++++++++++++++++++------
Msrc/Form/TurnstileSettingsForm.php | 84+++++++++++++++++++++++++++++++++----------------------------------------------
Asrc/TalerBackendErrorKind.php | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/TalerBackendResult.php | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/TalerMerchantApiService.php | 1157+++++++++++++++++++++++++++++++++++++++----------------------------------------
10 files changed, 888 insertions(+), 658 deletions(-)

diff --git a/src/Controller/PaivanaController.php b/src/Controller/PaivanaController.php @@ -192,9 +192,13 @@ class PaivanaController extends ControllerBase { if (! $price_category) { return new JsonResponse(['error' => 'no_price_category'], 404); } - $expected_amounts = $price_category->getAcceptableAmounts( - $this->apiService->getSubscriptions() - ); + // getSubscriptions() always returns a result with at least the + // "%none%" entry in data, even on backend failure, so the + // acceptable-amounts list can still be computed for non-subscription + // prices when the backend is briefly unreachable. + $subs_result = $this->apiService->getSubscriptions(); + $subscriptions = is_array($subs_result->data) ? $subs_result->data : []; + $expected_amounts = $price_category->getAcceptableAmounts($subscriptions); if (empty($expected_amounts)) { return new JsonResponse(['error' => 'no_choices_configured'], 500); } diff --git a/src/Entity/TurnstilePriceCategory.php b/src/Entity/TurnstilePriceCategory.php @@ -10,6 +10,7 @@ namespace Drupal\taler_turnstile\Entity; use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\taler_turnstile\TalerBackendResult; /** * Defines the Price Category entity. @@ -82,6 +83,20 @@ class TurnstilePriceCategory extends ConfigEntityBase { protected $prices = []; /** + * Result of the most recent merchant-backend sync triggered by + * postSave() / postDelete() on this entity instance. NULL if no + * sync has been attempted yet. Read by PriceCategoryForm::save() + * and PriceCategoryDeleteForm::submitForm() to surface backend + * failures that would otherwise be silently logged. + * + * Not persisted; this property is intentionally not in + * config_export and not in the schema. + * + * @var \Drupal\taler_turnstile\TalerBackendResult|null + */ + public ?TalerBackendResult $lastSyncResult = NULL; + + /** * Gets the description. * * @return string @@ -345,7 +360,7 @@ class TurnstilePriceCategory extends ConfigEntityBase { parent::postSave($storage, $update); /** @var \Drupal\taler_turnstile\TalerMerchantApiService $api */ $api = \Drupal::service('taler_turnstile.api_service'); - $api->syncTemplate($this); + $this->lastSyncResult = $api->syncTemplate($this); } @@ -361,7 +376,7 @@ class TurnstilePriceCategory extends ConfigEntityBase { $api = \Drupal::service('taler_turnstile.api_service'); foreach ($entities as $entity) { /** @var TurnstilePriceCategory $entity */ - $api->deleteTemplate($entity->getTemplateId()); + $entity->lastSyncResult = $api->deleteTemplate($entity->getTemplateId()); } } diff --git a/src/EventSubscriber/TurnstileConfigSubscriber.php b/src/EventSubscriber/TurnstileConfigSubscriber.php @@ -131,18 +131,51 @@ class TurnstileConfigSubscriber implements EventSubscriberInterface { ]); } // Subscription prices feed every category's "buy subscription" - // choices, so keep all merchant templates in sync when they change. - if ($event->isChanged('subscription_prices')) { - $this->apiService->syncAllTemplates(); + // choices, so keep all merchant templates in sync when they + // change. Also re-push everything when the operator (re)configures + // the backend or rotates the access token: the new instance will + // not yet have any of our templates. + $needs_sync = $event->isChanged('subscription_prices') + || $event->isChanged('payment_backend_url') + || $event->isChanged('access_token'); + if ($needs_sync) { + $results = $this->apiService->syncAllTemplates(); + $this->reportSyncResults($results, (string) ($config->get('payment_backend_url') ?? '')); } - // When the operator (re)configures the merchant backend or - // rotates the access token, the new instance will not yet have - // any of our templates — push them all so the paywall keeps - // working without manual intervention. - if ($event->isChanged('payment_backend_url') || - $event->isChanged('access_token')) { - $this->apiService->syncAllTemplates(); + } + + /** + * Report the outcome of syncAllTemplates() to the admin via the + * messenger. Categories that hit NOT_CONFIGURED are ignored: this + * is the expected state when the operator deliberately clears the + * backend URL. + * + * @param array<string, \Drupal\taler_turnstile\TalerBackendResult> $results + * Map of price-category id => sync result. + * @param string $backend_url + * Backend URL the calls were made against (for the error message). + */ + protected function reportSyncResults(array $results, string $backend_url): void { + $failed = []; + foreach ($results as $id => $r) { + if (!$r->isOk() + && $r->kind !== \Drupal\taler_turnstile\TalerBackendErrorKind::NOT_CONFIGURED) { + $failed[$id] = $r; + } + } + if (empty($failed)) { + return; } + $first = reset($failed); + $this->messenger->addError($this->t( + 'Settings saved, but @n of @t price-category templates failed to publish to the merchant backend. First failure (@id): @err', + [ + '@n' => count($failed), + '@t' => count($results), + '@id' => array_key_first($failed), + '@err' => $first->toUserMessage($backend_url), + ] + )); } /** diff --git a/src/Form/PriceCategoryDeleteForm.php b/src/Form/PriceCategoryDeleteForm.php @@ -12,6 +12,7 @@ namespace Drupal\taler_turnstile\Form; use Drupal\Core\Entity\EntityConfirmFormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; +use Drupal\taler_turnstile\TalerBackendErrorKind; /** * Builds the form to delete a price category. @@ -51,6 +52,21 @@ class PriceCategoryDeleteForm extends EntityConfirmFormBase { '%label' => $this->entity->label(), ])); + // postDelete() stashes the merchant-backend delete result on the + // entity instance. The local delete has already succeeded; if the + // template did NOT make it off the backend, warn the admin so + // they know to clean it up manually rather than discover stale + // templates later. + $sync = $this->entity->lastSyncResult; + if ($sync !== NULL && !$sync->isOk() + && $sync->kind !== TalerBackendErrorKind::NOT_CONFIGURED) { + $backend_url = (string) (\Drupal::config('taler_turnstile.settings')->get('payment_backend_url') ?? ''); + $this->messenger()->addWarning($this->t( + 'Deleted locally, but failed to remove the matching template from the merchant backend: @err', + ['@err' => $sync->toUserMessage($backend_url)] + )); + } + $form_state->setRedirectUrl($this->getCancelUrl()); } diff --git a/src/Form/PriceCategoryForm.php b/src/Form/PriceCategoryForm.php @@ -11,6 +11,7 @@ namespace Drupal\taler_turnstile\Form; use Drupal\Core\Entity\EntityForm; use Drupal\Core\Form\FormStateInterface; +use Drupal\taler_turnstile\TalerBackendErrorKind; use Drupal\taler_turnstile\TalerMerchantApiService; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -78,13 +79,27 @@ class PriceCategoryForm extends EntityForm { '#description' => $this->t('A description of this price category.'), ]; - // Get subscriptions and currencies from API. - $subscriptions = $this->apiService->getSubscriptions(); - $currencies = $this->apiService->getCurrencies(); - - if (empty($subscriptions) || empty($currencies)) { - $this->messenger()->addWarning($this->t('Unable to load subscriptions or currencies from API. Please check your configuration.')); + // Get subscriptions and currencies from API. Show the specific + // error and disable the form if either call failed: saving a + // half-built price category would publish a broken template. + $backend_url = (string) ($this->config('taler_turnstile.settings')->get('payment_backend_url') ?? ''); + $subs_result = $this->apiService->getSubscriptions(); + $cur_result = $this->apiService->getCurrencies(); + foreach ([$subs_result, $cur_result] as $r) { + if (!$r->isOk()) { + $this->messenger()->addError($r->toUserMessage($backend_url)); + } + } + // A currency list is mandatory; without it we cannot render any + // meaningful price inputs. Subscriptions can be empty (no token + // families configured yet), so only currency failure is fatal. + if (!$cur_result->isOk()) { + $form['#disabled'] = TRUE; + $form['actions']['#access'] = FALSE; + return $form; } + $subscriptions = is_array($subs_result->data) ? $subs_result->data : []; + $currencies = is_array($cur_result->data) ? $cur_result->data : []; $form['prices'] = [ '#type' => 'fieldset', @@ -192,6 +207,20 @@ class PriceCategoryForm extends EntityForm { ])); } + // postSave() stashes the merchant-backend sync result on the + // entity. The local config save has already succeeded; if the + // template did NOT make it to the backend, the admin needs to + // know — otherwise the paywall will quote stale prices. + $sync = $price_category->lastSyncResult; + if ($sync !== NULL && !$sync->isOk() + && $sync->kind !== TalerBackendErrorKind::NOT_CONFIGURED) { + $backend_url = (string) ($this->config('taler_turnstile.settings')->get('payment_backend_url') ?? ''); + $this->messenger()->addError($this->t( + 'Saved locally, but failed to publish the price category to the merchant backend: @err', + ['@err' => $sync->toUserMessage($backend_url)] + )); + } + $form_state->setRedirectUrl($price_category->toUrl('collection')); } diff --git a/src/Form/SubscriptionPricesForm.php b/src/Form/SubscriptionPricesForm.php @@ -82,16 +82,31 @@ class SubscriptionPricesForm extends ConfigFormBase { '#markup' => $this->t('<p>Set the price for buying each subscription type in different currencies. Leave a field empty to prevent users from buying that subscription with that currency.</p>'), ]; - // Get subscriptions and currencies from API. - $subscriptions = $this->apiService->getSubscriptions(); - $currencies = $this->apiService->getCurrencies(); - + // Get subscriptions and currencies from API. Show the specific + // backend error (timeout / 401 / 502 / ...) so the admin doesn't + // have to dig through the logs to figure out what's wrong. + $subs_result = $this->apiService->getSubscriptions(); + $cur_result = $this->apiService->getCurrencies(); + + if (!$cur_result->isOk()) { + $this->messenger()->addError($cur_result->toUserMessage($backend_url)); + return parent::buildForm($form, $form_state); + } + $currencies = is_array($cur_result->data) ? $cur_result->data : []; if (empty($currencies)) { - $this->messenger()->addError($this->t('Unable to load currencies from the API. Please check your backend configuration.')); + $this->messenger()->addError($this->t('The merchant backend returned no currencies.')); return parent::buildForm($form, $form_state); } - if (empty($subscriptions)) { + if (!$subs_result->isOk()) { + $this->messenger()->addError($subs_result->toUserMessage($backend_url)); + return parent::buildForm($form, $form_state); + } + // %none% is always present; strip it to count "real" subscriptions. + $subscriptions = is_array($subs_result->data) ? $subs_result->data : []; + $real = $subscriptions; + unset($real['%none%']); + if (empty($real)) { $this->messenger()->addWarning($this->t('No subscriptions configured in Taler merchant backend.')); return parent::buildForm($form, $form_state); } diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php @@ -6,6 +6,7 @@ use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\taler_turnstile\TalerBackendErrorKind; use Drupal\taler_turnstile\TurnstileFieldManager; use Drupal\taler_turnstile\TalerMerchantApiService; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -137,18 +138,23 @@ class TurnstileSettingsForm extends ConfigFormBase { $payment_backend_url = $form_state->getValue('payment_backend_url'); $access_token = $form_state->getValue('access_token'); - if ( (!empty($payment_backend_url)) && - (!str_ends_with($payment_backend_url, '/')) ) - { - $form_state->setErrorByName('payment_backend_url', - $this->t('Payment backend URL must end with a "/".')); - return; - } - - if (! $this->apiService->checkConfig($payment_backend_url)) { - $form_state->setErrorByName('payment_backend_url', - $this->t('Invalid payment backend URL')); - return; + // Validate the URL only if one was actually supplied; leaving + // the backend unconfigured is an intentional path (covered by + // the "warn but don't block" branch at the bottom). + if (!empty($payment_backend_url)) { + if (!str_ends_with($payment_backend_url, '/')) { + $form_state->setErrorByName('payment_backend_url', + $this->t('Payment backend URL must end with a "/".')); + return; + } + $cfg_result = $this->apiService->checkConfig($payment_backend_url); + if (!$cfg_result->isOk()) { + $form_state->setErrorByName( + 'payment_backend_url', + $cfg_result->toUserMessage($payment_backend_url) + ); + return; + } } if ( (!empty($access_token)) && @@ -158,44 +164,24 @@ class TurnstileSettingsForm extends ConfigFormBase { return; } - $http_status = $this->apiService->checkAccess($payment_backend_url, $access_token); - switch ($http_status) { - case 502: - $form_state->setErrorByName('payment_backend_url', - $this->t('Bad gateway (502) trying to access the merchant backend')); - return; - case 500: - $form_state->setErrorByName('payment_backend_url', - $this->t('Internal server error (500) of the merchant backend reported')); + // Only verify the access token when both fields are present; + // otherwise we hit the warn-but-don't-block branch below. + // AUTH_FAILED is attached to the access_token field; every other + // failure (unreachable, 5xx, unknown instance, ...) is attached + // to the URL field. + if (!empty($payment_backend_url) && !empty($access_token)) { + $access_result = $this->apiService->checkAccess($payment_backend_url, $access_token); + if (!$access_result->isOk()) { + $field = ($access_result->kind === TalerBackendErrorKind::AUTH_FAILED) + ? 'access_token' + : 'payment_backend_url'; + $form_state->setErrorByName( + $field, + $access_result->toUserMessage($payment_backend_url) + ); return; - case 404: - $form_state->setErrorByName('payment_backend_url', - $this->t('The specified instance is unknown to the merchant backend')); - return; - case 403: - $form_state->setErrorByName('access_token', - $this->t('Access token not accepted by the merchant backend')); - return; - case 401: - $form_state->setErrorByName('access_token', - $this->t('Access token not accepted by the merchant backend')); - return; - case 204: - // Empty order list is OK - break; - case 200: - // Success is great - break; - case 0: - $form_state->setErrorByName('payment_backend_url', - $this->t('HTTP request failed')); - return; - default: - $form_state->setErrorByName('payment_backend_url', - $this->t('Unexpected response (@status) from merchant backend', - [ '@status' => $http_status ])); - return; - } // end switch on HTTP status + } + } // If the merchant backend is not configured at all, we allow the user // to save the settings. But, we warn them if they did not set diff --git a/src/TalerBackendErrorKind.php b/src/TalerBackendErrorKind.php @@ -0,0 +1,43 @@ +<?php + +/** + * @file + * Location: src/TalerBackendErrorKind.php + * + * Coarse classification of merchant-backend call outcomes. Used by + * TalerBackendResult to let callers render a precise user-facing + * message and decide whether to retry, refuse, or fall back. + */ + +namespace Drupal\taler_turnstile; + +enum TalerBackendErrorKind: string { + + /** Request succeeded (HTTP 2xx). */ + case OK = 'ok'; + + /** Backend URL or access token not configured at all. */ + case NOT_CONFIGURED = 'not_configured'; + + /** + * No HTTP response was received: DNS, ECONNREFUSED, TLS error, + * timeout, etc. The transport field carries the underlying message. + */ + case UNREACHABLE = 'unreachable'; + + /** HTTP 401 or 403 from the backend. */ + case AUTH_FAILED = 'auth_failed'; + + /** HTTP 404 from the backend (instance/order/template unknown). */ + case NOT_FOUND = 'not_found'; + + /** HTTP 5xx from the backend. */ + case SERVER_ERROR = 'server_error'; + + /** + * Anything else: unexpected HTTP status, malformed JSON, missing + * required fields, wrong /config "name", etc. + */ + case PROTOCOL_ERROR = 'protocol_error'; + +} diff --git a/src/TalerBackendResult.php b/src/TalerBackendResult.php @@ -0,0 +1,96 @@ +<?php + +/** + * @file + * Location: src/TalerBackendResult.php + * + * Uniform return type for every TalerMerchantApiService method that + * talks to the merchant backend. Lets callers either consume the + * payload (data) on success or render a precise error message to the + * admin via toUserMessage(). + */ + +namespace Drupal\taler_turnstile; + +use Drupal\Core\StringTranslation\StringTranslationTrait; + +final class TalerBackendResult { + + use StringTranslationTrait; + + /** + * @param TalerBackendErrorKind $kind + * Coarse outcome classification. + * @param int|null $httpStatus + * HTTP status code, if a response was received. + * @param string|null $transport + * Underlying transport/parse error message (Guzzle exception + * message, "missing field X", etc.). Useful for the admin and the + * log; not always set. + * @param string|null $hint + * "hint" field from the Taler error envelope, if any. + * @param string|null $detail + * "detail" field from the Taler error envelope, if any. + * @param int|null $talerEc + * "code" field from the Taler error envelope (TALER_EC_*), if any. + * @param mixed $data + * Parsed payload on success (shape depends on the method). May + * also carry partial data alongside an error kind when the caller + * wants something usable to fall back to (e.g. the "%none%" + * subscription entry when the backend is unreachable). + */ + public function __construct( + public readonly TalerBackendErrorKind $kind, + public readonly ?int $httpStatus = NULL, + public readonly ?string $transport = NULL, + public readonly ?string $hint = NULL, + public readonly ?string $detail = NULL, + public readonly ?int $talerEc = NULL, + public readonly mixed $data = NULL, + ) {} + + public function isOk(): bool { + return $this->kind === TalerBackendErrorKind::OK; + } + + /** + * Render a precise, translated, admin-facing message describing + * what went wrong. Safe to pass straight to messenger()->addError(). + * + * @param string $backendUrl + * The backend URL the call was made against, included in the + * "unreachable" message so the admin can see which host failed. + */ + public function toUserMessage(string $backendUrl = ''): \Stringable|string { + return match ($this->kind) { + TalerBackendErrorKind::OK => $this->t('OK'), + TalerBackendErrorKind::NOT_CONFIGURED => $this->t( + 'The GNU Taler merchant backend is not configured.'), + TalerBackendErrorKind::UNREACHABLE => $this->t( + 'Cannot reach the GNU Taler merchant backend at @url: @err', + [ + '@url' => $backendUrl !== '' ? $backendUrl : '(unset)', + '@err' => $this->transport ?? 'unknown network error', + ]), + TalerBackendErrorKind::AUTH_FAILED => $this->t( + 'The merchant backend rejected our credentials (HTTP @s). Check the access token.', + ['@s' => $this->httpStatus ?? 0]), + TalerBackendErrorKind::NOT_FOUND => $this->t( + 'The merchant backend returned 404: @hint', + ['@hint' => $this->hint ?? '(no hint)']), + TalerBackendErrorKind::SERVER_ERROR => $this->t( + 'The merchant backend returned HTTP @s: @hint', + [ + '@s' => $this->httpStatus ?? 0, + '@hint' => $this->hint ?? '(no hint)', + ]), + TalerBackendErrorKind::PROTOCOL_ERROR => $this->t( + 'Unexpected response from the merchant backend (HTTP @s): @err. See logs for details.', + [ + '@s' => $this->httpStatus ?? 0, + '@err' => $this->transport ?? ($this->hint ?? 'protocol error'), + ]), + }; + } + +} diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php @@ -5,6 +5,13 @@ * Location: src/TalerMerchantApiService.php * * Service for interacting with the Taler Merchant Backend. + * + * Every method that hits the network returns a TalerBackendResult so + * callers can render a precise error to the admin and decide how to + * proceed. The shared runRequest() helper classifies Guzzle outcomes + * (transport vs. HTTP status vs. malformed JSON) into a single + * TalerBackendErrorKind, so per-method code only has to handle the + * Taler-specific semantics (e.g. "409 on POST template means PATCH"). */ namespace Drupal\taler_turnstile; @@ -12,6 +19,7 @@ namespace Drupal\taler_turnstile; use Drupal\Core\Http\ClientFactory; use Psr\Log\LoggerInterface; use Drupal\taler_turnstile\Entity\TurnstilePriceCategory; +use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -51,6 +59,13 @@ class TalerMerchantApiService { const CACHE_BACKEND_DATA_SECONDS = 60; /** + * Timeout for every HTTP request we make to the merchant backend. + * Kept short on purpose: these calls run synchronously inside + * admin form submissions and the paywall request path. + */ + const REQUEST_TIMEOUT_SECONDS = 5; + + /** * Merchant backend protocol version (libtool "current") required by * this module. The backend's /config "version" string is libtool-style * "CURRENT:REVISION:AGE": the backend supports interfaces in the range @@ -88,6 +103,112 @@ class TalerMerchantApiService { /** + * Build an HTTP client with the default options for talking to the + * merchant backend: do not throw on HTTP error status codes, follow + * redirects, and bound runtime by REQUEST_TIMEOUT_SECONDS. + * + * @param array $headers + * Extra headers (typically Authorization and/or Content-Type). + */ + private function client(array $headers = []) { + $opts = [ + 'http_errors' => FALSE, + 'allow_redirects' => TRUE, + 'timeout' => self::REQUEST_TIMEOUT_SECONDS, + ]; + if (!empty($headers)) { + $opts['headers'] = $headers; + } + return $this->httpClientFactory->fromOptions($opts); + } + + + /** + * Run an HTTP closure and classify its result. + * + * The closure must return a Psr\Http\Message\ResponseInterface. + * Network/transport errors (DNS, ECONNREFUSED, TLS, timeout) come + * back as UNREACHABLE; HTTP status codes are mapped to AUTH_FAILED, + * NOT_FOUND, SERVER_ERROR or PROTOCOL_ERROR; 2xx is OK. + * + * The decoded JSON body (or [] if the body was not valid JSON) is + * always exposed in $result->data so callers can pull payload + * fields out without re-decoding. + */ + private function runRequest(callable $fn): TalerBackendResult { + try { + $response = $fn(); + } + catch (ConnectException $e) { + return new TalerBackendResult( + TalerBackendErrorKind::UNREACHABLE, + transport: $e->getMessage(), + ); + } + catch (RequestException $e) { + return new TalerBackendResult( + TalerBackendErrorKind::UNREACHABLE, + transport: $e->getMessage(), + ); + } + catch (\Throwable $e) { + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + transport: $e->getMessage(), + ); + } + $status = $response->getStatusCode(); + $jbody = json_decode((string) $response->getBody(), TRUE); + if (!is_array($jbody)) { + $jbody = []; + } + $kind = match (TRUE) { + $status >= 200 && $status < 300 => TalerBackendErrorKind::OK, + $status === 401 || $status === 403 => TalerBackendErrorKind::AUTH_FAILED, + $status === 404 => TalerBackendErrorKind::NOT_FOUND, + $status >= 500 => TalerBackendErrorKind::SERVER_ERROR, + default => TalerBackendErrorKind::PROTOCOL_ERROR, + }; + return new TalerBackendResult($kind, + httpStatus: $status, + hint: $jbody['hint'] ?? NULL, + detail: $jbody['detail'] ?? NULL, + talerEc: $jbody['code'] ?? NULL, + data: $jbody, + ); + } + + + /** + * Log a backend failure with the full envelope. AUTH_FAILED is + * logged at warning (operator-fixable); everything else at error. + * + * @param string $action + * Short description of what we were trying to do + * (used in the log message verbatim). + * @param TalerBackendResult $r + * The failed result to log. + */ + private function logBackendFailure(string $action, TalerBackendResult $r): void { + $level = ($r->kind === TalerBackendErrorKind::AUTH_FAILED) ? 'warning' : 'error'; + $body = is_array($r->data) + ? json_encode($r->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + : 'N/A'; + $this->logger->log($level, + 'Failed to @action: HTTP @status (@kind), @hint (@detail, #@ec), transport=@transport, body=@body', [ + '@action' => $action, + '@status' => $r->httpStatus ?? 0, + '@kind' => $r->kind->value, + '@hint' => $r->hint ?? 'N/A', + '@detail' => $r->detail ?? 'N/A', + '@ec' => $r->talerEc ?? 'N/A', + '@transport' => $r->transport ?? 'N/A', + '@body' => $body, + ]); + } + + + /** * Return the base URL for the given backend URL (without instance!) * * @param string $backend_url @@ -114,41 +235,62 @@ class TalerMerchantApiService { /** - * Checks if the given backend URL points to a Taler merchant backend. + * Checks if the given backend URL points to a Taler merchant backend + * speaking a compatible protocol version. * * @param string $backend_url - * Backend URL to check, may include '/instances/$ID' path - * @return bool - * TRUE if this is a valid backend URL for a Taler backend - * that speaks a protocol compatible with this module + * Backend URL to check, may include '/instances/$ID' path. + * @return TalerBackendResult + * OK if the URL responds with a compatible /config; on failure, + * the kind/transport fields describe what went wrong. */ - public function checkConfig(string $backend_url) { + public function checkConfig(string $backend_url): TalerBackendResult { $base_url = $this->getBaseURL($backend_url); if (NULL === $base_url) { - return FALSE; + return new TalerBackendResult( + TalerBackendErrorKind::NOT_CONFIGURED, + transport: 'backend URL is empty or does not end with "/"', + ); } - try { - $http_client = $this->httpClientFactory->fromOptions([ - 'http_errors' => false, - 'allow_redirects' => TRUE, - 'timeout' => 5, // seconds + $http = $this->client(); + $r = $this->runRequest(fn() => $http->get($base_url . 'config')); + if (!$r->isOk()) { + $this->logBackendFailure('fetch /config from ' . $base_url, $r); + return $r; + } + $body = $r->data; + if (!isset($body['name']) || $body['name'] !== 'taler-merchant') { + $this->logger->warning('URL @url responded to /config but is not a Taler merchant backend (name=@name).', [ + '@url' => $base_url, + '@name' => $body['name'] ?? '(missing)', ]); - $response = $http_client->get($base_url . 'config'); - if ($response->getStatusCode() !== 200) { - return FALSE; - } - $body = json_decode($response->getBody(), TRUE); - if (!isset($body['name']) || $body['name'] !== 'taler-merchant') { - return FALSE; - } - if (!isset($body['version']) || !is_string($body['version'])) { - $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot verify protocol compatibility.'); - return FALSE; - } - return $this->checkVersion($body['version']); - } catch (\Exception $e) { - return FALSE; + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + httpStatus: $r->httpStatus, + transport: 'response does not look like a Taler merchant /config', + ); } + if (!isset($body['version']) || !is_string($body['version'])) { + $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot verify protocol compatibility.'); + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + httpStatus: $r->httpStatus, + transport: 'missing "version" field in /config response', + ); + } + if (!$this->checkVersion($body['version'])) { + // checkVersion() already logged the specific reason. + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + httpStatus: $r->httpStatus, + transport: 'incompatible backend protocol version: ' . $body['version'], + ); + } + return new TalerBackendResult( + TalerBackendErrorKind::OK, + httpStatus: $r->httpStatus, + data: $body, + ); } @@ -196,235 +338,203 @@ class TalerMerchantApiService { } /** - * Checks if the given backend URL points to a Taler merchant backend. + * Verify that the configured access token is accepted by the backend + * instance (private/orders is a cheap, harmless ping). * * @param string $backend_url - * Backend URL to check, may include '/instances/$ID' path + * Backend URL to check, may include '/instances/$ID' path. * @param string $access_token - * Access token to talk to the instance - * @return int - * HTTP status from a plain GET to the order list, - * 200 or 204 if the backend is configured and accessible, - * 0 on other error, otherwise HTTP status code indicating the error + * Access token to talk to the instance. + * @return TalerBackendResult + * OK on 200/204; AUTH_FAILED on 401/403; NOT_FOUND on 404 + * (unknown instance); UNREACHABLE on transport error; etc. */ - public function checkAccess(string $backend_url, string $access_token) { - try { - $http_client = $this->httpClientFactory->fromOptions([ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - // Do not throw exceptions on 4xx/5xx status codes - 'http_errors' => false, - 'allow_redirects' => TRUE, - 'timeout' => 5, // seconds - ]); - $response = $http_client->get( - $backend_url . 'private/orders?limit=1' - ); - return $response->getStatusCode(); - } catch (\Exception $e) { - return 0; + public function checkAccess(string $backend_url, string $access_token): TalerBackendResult { + if (empty($backend_url) || empty($access_token)) { + return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED); } + $http = $this->client(['Authorization' => 'Bearer ' . $access_token]); + return $this->runRequest(fn() => $http->get( + $backend_url . 'private/orders?limit=1' + )); } /** - * Gets the list of available subscriptions. Always includes a special - * entry for "No reduction" with ID "". + * Gets the list of available subscriptions. Always includes a + * special "%none%" entry for "No reduction" so the form can offer + * a no-subscription price if no subscriptions are configured or + * if the backend is unreachable. * - * @return array - * Array mapping token family IDs to subscription data each with a 'name' and 'label' (usually the slug), 'description' and 'description_i18n'. + * @return TalerBackendResult + * data field: array mapping token family slugs (plus "%none%") + * to ['name', 'label', 'description', 'description_i18n', + * 'valid_before_s']. On NOT_CONFIGURED / UNREACHABLE the data + * field still contains the "%none%" entry so callers can render + * something usable. */ - public function getSubscriptions() { - $cid = 'taler_turnstile:subscriptions'; - if ($cache = \Drupal::cache()->get($cid)) { - return $cache->data; - } + public function getSubscriptions(): TalerBackendResult { + $config = \Drupal::config('taler_turnstile.settings'); + $backend_url = $config->get('payment_backend_url'); + $access_token = $config->get('access_token'); - // Per default, we always have "no subscription" as an option. - $result = []; - $description = $this->t('No subscription', [], [ - 'langcode' => 'en', // force English version here! - ]); - $description_i18n = $this->buildTranslationMap ( - 'No subscription'); - $result['%none%'] = [ + // Always include "no subscription" so callers (forms, paywall) + // can render something usable even on failure. + $base = []; + $base['%none%'] = [ 'name' => 'none', 'label' => 'No reduction', - 'description' => $description, - 'description_i18n' => $description_i18n, + 'description' => (string) $this->t('No subscription', [], ['langcode' => 'en']), + 'description_i18n' => $this->buildTranslationMap('No subscription'), ]; - $config = \Drupal::config('taler_turnstile.settings'); - $backend_url = $config->get('payment_backend_url'); - $access_token = $config->get('access_token'); - if (empty($backend_url) || - empty($access_token)) { + if (empty($backend_url) || empty($access_token)) { $this->logger->debug('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.'); - return $result; + return new TalerBackendResult( + TalerBackendErrorKind::NOT_CONFIGURED, + data: $base, + ); } - $jbody = []; - try { - $http_client = $this->httpClientFactory->fromOptions([ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - // Do not throw exceptions on 4xx/5xx status codes - 'http_errors' => false, - 'allow_redirects' => TRUE, - 'timeout' => 5, // seconds - ]); - $response = $http_client->get($backend_url . 'private/tokenfamilies'); - // Get JSON result parsed as associative array - $http_status = $response->getStatusCode(); - $body = $response->getBody(); - $jbody = json_decode($body, TRUE); - switch ($http_status) - { - case 200: - if (! isset($jbody['token_families'])) { - $this->logger->error('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.'); - return $result; - } - // Success, handled below - break; - case 204: - // empty list - return $result; - case 403: - $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); - return $result; - case 404: - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to fetch token family list: @hint (@ec): @body', ['@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); - return $result; - default: - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status code @status trying to fetch token family list: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); - return $result; - } // end switch on HTTP status - - $tokenFamilies = $jbody['token_families']; - $now = time (); // in seconds since Epoch - foreach ($tokenFamilies as $family) { - $valid_before = ($family['valid_before']['t_s'] === 'never') - ? PHP_INT_MAX - : $family['valid_before']['t_s']; - if ( ($family['kind'] === 'subscription') && - ($family['valid_after']['t_s'] < $now) && - ($valid_before >= $now) ) { - $slug = $family['slug']; - $result[$slug] = [ - 'name' => $family['name'], - 'label' => $slug, - 'valid_before_s' => $valid_before, - 'description' => $family['description'], - 'description_i18n' => ($family['description_i18n'] ?? NULL), - ]; - $found = TRUE; - } - else - { - $this->logger->info('Token family @slug is not valid right now, skipping it.', ['@slug' => $family['slug']]); - } - }; // end foreach token family - \Drupal::cache()->set($cid, - $result, - time() + self::CACHE_BACKEND_DATA_SECONDS); - return $result; + // Key the cache on (url, token) so changing the backend or + // rotating the token immediately invalidates the previous entry. + $cid = 'taler_turnstile:subscriptions:' . md5($backend_url . '|' . $access_token); + if ($cache = \Drupal::cache()->get($cid)) { + return new TalerBackendResult(TalerBackendErrorKind::OK, data: $cache->data); + } + + $http = $this->client(['Authorization' => 'Bearer ' . $access_token]); + $r = $this->runRequest(fn() => $http->get($backend_url . 'private/tokenfamilies')); + + // 204 = empty list. Cache and return "just %none%". + if ($r->httpStatus === 204) { + \Drupal::cache()->set($cid, $base, time() + self::CACHE_BACKEND_DATA_SECONDS); + return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: 204, data: $base); + } + if (!$r->isOk()) { + $this->logBackendFailure('fetch token family list', $r); + // Hand back a result that still has %none% so the form can render. + return new TalerBackendResult( + $r->kind, + httpStatus: $r->httpStatus, + transport: $r->transport, + hint: $r->hint, + detail: $r->detail, + talerEc: $r->talerEc, + data: $base, + ); } - catch (RequestException $e) { - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to obtain list of token families: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log_fmt ?? 'N/A']); + if (!isset($r->data['token_families'])) { + $this->logger->error('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.'); + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + httpStatus: $r->httpStatus, + transport: 'missing "token_families" field in tokenfamilies response', + data: $base, + ); + } + + $result = $base; + $now = time(); + foreach ($r->data['token_families'] as $family) { + $valid_before = ($family['valid_before']['t_s'] === 'never') + ? PHP_INT_MAX + : $family['valid_before']['t_s']; + if (($family['kind'] === 'subscription') && + ($family['valid_after']['t_s'] < $now) && + ($valid_before >= $now)) { + $slug = $family['slug']; + $result[$slug] = [ + 'name' => $family['name'], + 'label' => $slug, + 'valid_before_s' => $valid_before, + 'description' => $family['description'], + 'description_i18n' => ($family['description_i18n'] ?? NULL), + ]; + } + else { + $this->logger->info('Token family @slug is not valid right now, skipping it.', ['@slug' => $family['slug']]); + } } - return $result; + \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS); + return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: $r->httpStatus, data: $result); } /** * Gets the list of available currencies. * - * @return array - * Array of currencies with 'code' (currency code), 'name' and 'label' - * and 'step' (typically 0 for JPY or 0.01 for EUR/USD). + * @return TalerBackendResult + * data field: array of currency dicts with 'code', 'name', + * 'label', 'step'. data is [] on failure. */ - public function getCurrencies() { - $cid = 'taler_turnstile:currencies'; - if ($cache = \Drupal::cache()->get($cid)) { - return $cache->data; - } - + public function getCurrencies(): TalerBackendResult { $config = \Drupal::config('taler_turnstile.settings'); $payment_backend_url = $config->get('payment_backend_url'); if (empty($payment_backend_url)) { $this->logger->error('Taler merchant backend not configured; cannot obtain currency list'); - return []; + return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED, data: []); } - try { - // Fetch backend configuration. - $http_client = $this->httpClientFactory->fromOptions([ - 'allow_redirects' => TRUE, - 'http_errors' => FALSE, - 'allow_redirects' => TRUE, - 'timeout' => 5, // seconds - ]); - - $config_url = $payment_backend_url . 'config'; - $response = $http_client->get($config_url); - - if ($response->getStatusCode() !== 200) { - $this->logger->error('Taler merchant backend did not respond; cannot obtain currency list'); - return []; - } - - $backend_config = json_decode($response->getBody(), TRUE); - if (!$backend_config || !is_array($backend_config)) { - // Invalid response, fallback to grant_access_on_error setting. - $this->logger->error('Taler merchant backend returned invalid /config response; cannot obtain currency list'); - return []; - } - - if (!isset($backend_config['version']) || !is_string($backend_config['version'])) { - $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot obtain currency list.'); - return []; - } - if (!$this->checkVersion($backend_config['version'])) { - // checkVersion() already logged the specific reason. - return []; - } - - if (! isset($backend_config['currencies'])) - { - $this->logger->error('Backend returned malformed response for /config'); - return []; - } - - // Parse and validate each amount in the comma-separated list. - $currencies = $backend_config['currencies']; - - $result = array_map(function ($currency) { - return [ - 'code' => $currency['currency'], - 'name' => $currency['name'], - 'label' => $currency['alt_unit_names'][0] ?? $currency['id'], - 'step' => pow(0.1, $currency['num_fractional_input_digits'] ?? 2), - ]; - }, - $currencies + $cid = 'taler_turnstile:currencies:' . md5($payment_backend_url); + if ($cache = \Drupal::cache()->get($cid)) { + return new TalerBackendResult(TalerBackendErrorKind::OK, data: $cache->data); + } + + $http = $this->client(); + $r = $this->runRequest(fn() => $http->get($payment_backend_url . 'config')); + if (!$r->isOk()) { + $this->logBackendFailure('fetch /config for currency list', $r); + return new TalerBackendResult( + $r->kind, + httpStatus: $r->httpStatus, + transport: $r->transport, + hint: $r->hint, + detail: $r->detail, + talerEc: $r->talerEc, + data: [], + ); + } + $body = $r->data; + if (!isset($body['version']) || !is_string($body['version'])) { + $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot obtain currency list.'); + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + httpStatus: $r->httpStatus, + transport: 'missing "version" field in /config', + data: [], + ); + } + if (!$this->checkVersion($body['version'])) { + // checkVersion() already logged the specific reason. + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + httpStatus: $r->httpStatus, + transport: 'incompatible backend protocol version: ' . $body['version'], + data: [], ); + } + if (!isset($body['currencies'])) { + $this->logger->error('Backend returned malformed response for /config (no "currencies")'); + return new TalerBackendResult( + TalerBackendErrorKind::PROTOCOL_ERROR, + httpStatus: $r->httpStatus, + transport: 'missing "currencies" field in /config', + data: [], + ); + } - \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS); - return $result; - } catch (\Exception $e) { + $result = array_map(function ($currency) { + return [ + 'code' => $currency['currency'], + 'name' => $currency['name'], + 'label' => $currency['alt_unit_names'][0] ?? $currency['id'], + 'step' => pow(0.1, $currency['num_fractional_input_digits'] ?? 2), + ]; + }, $body['currencies']); - // On exception, fall back to grant_access_on_error setting. - $this->logger->error('Failed to validate obtain configuration from backend: @error', [ - '@error' => $e->getMessage(), - ]); - return []; - } + \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS); + return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: $r->httpStatus, data: $result); } @@ -432,6 +542,10 @@ class TalerMerchantApiService { * Check order status with Taler backend. Used only for diagnostic * code paths; the main paywall flow uses verifyPaidOrder(). * + * Returns the legacy array-or-FALSE shape because the only caller + * is internal diagnostics; the surrounding admin UI does not need + * to render a specific message for this method. + * * @param string $order_id * The order ID to check. * @@ -443,158 +557,111 @@ class TalerMerchantApiService { $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); - if (empty($backend_url) || - empty($access_token)) { + if (empty($backend_url) || empty($access_token)) { $this->logger->debug('No GNU Taler Turnstile backend configured, cannot check order status!'); return FALSE; } - try { - $http_client = $this->httpClientFactory->fromOptions([ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - // Do not throw exceptions on 4xx/5xx status codes - 'http_errors' => false, - 'allow_redirects' => TRUE, - 'timeout' => 5, // seconds - ]); - $response = $http_client->get($backend_url . 'private/orders/' . $order_id); - - $http_status = $response->getStatusCode(); - $body = $response->getBody(); - $jbody = json_decode($body, TRUE); - switch ($http_status) - { - case 200: - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->debug('Got existing contract: @body', ['@body' => $body_log_fmt ?? 'N/A']); - // Success, handled below - break; - case 403: - $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your GNU Taler Turnstile configuration!'); - return FALSE; - case 404: - // Order unknown or instance unknown - /** @var TalerErrorCode $ec */ - $ec = TalerErrorCode::tryFrom ($jbody['code']) ?? TalerErrorCode::TALER_EC_NONE; - switch ($ec) - { - case TalerErrorCode::TALER_EC_NONE: - // Protocol violation. Could happen if the backend domain was - // taken over by someone else. - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Invalid response from merchant backend when trying to obtain order status. Check your GNU Taler Turnstile configuration! @body', ['@body' => $body_log_fmt ?? 'N/A']); - return FALSE; - case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN: - // This could happen if our instance was deleted after the configuration was - // checked. Very bad, log serious error. - $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your GNU Taler Turnstile configuration!', ['@detail' => $jbody['detail'] ?? 'N/A']); - return FALSE; - case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN: - // This could happen if the instance owner manually deleted - // an order while the customer was looking at the article. - $this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]); - return FALSE; - default: - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected error code @ec with HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); - return FALSE; - } - default: - // Internal server errors and the like... - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status code @status from Taler merchant backend when trying to get order status: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@detail' => $jbody['detail'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); - return FALSE; + $http = $this->client(['Authorization' => 'Bearer ' . $access_token]); + $r = $this->runRequest(fn() => $http->get($backend_url . 'private/orders/' . $order_id)); + + if (!$r->isOk()) { + // Special-case 404: try to surface the specific Taler EC. + if ($r->httpStatus === 404) { + $ec = TalerErrorCode::tryFrom((int) ($r->talerEc ?? 0)) ?? TalerErrorCode::TALER_EC_NONE; + switch ($ec) { + case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN: + $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your GNU Taler Turnstile configuration!', ['@detail' => $r->detail ?? 'N/A']); + return FALSE; + case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN: + $this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]); + return FALSE; + default: + $this->logBackendFailure('get order status (' . $order_id . ')', $r); + return FALSE; + } } + $this->logBackendFailure('get order status (' . $order_id . ')', $r); + return FALSE; + } - - $order_status = $jbody['order_status'] ?? 'unknown'; - $subscription_expiration = 0; - $subscription_slug = FALSE; - $pay_deadline = 0; - $paid = FALSE; - switch ($order_status) - { - case 'unpaid': - // 'pay_deadline' is only available since v21 rev 1, so for now we - // fall back to creation_time + offset. FIXME later! - $pay_deadline = $jbody['pay_deadline']['t_s'] ?? - (self::ORDER_VALIDITY_SECONDS + $jbody['creation_time']['t_s'] ?? 0); - break; - case 'claimed': - $contract_terms = $jbody['contract_terms']; - $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0; + $jbody = $r->data; + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->debug('Got existing contract: @body', ['@body' => $body_log_fmt ?? 'N/A']); + + $order_status = $jbody['order_status'] ?? 'unknown'; + $subscription_expiration = 0; + $subscription_slug = FALSE; + $pay_deadline = 0; + $paid = FALSE; + switch ($order_status) { + case 'unpaid': + // 'pay_deadline' is only available since v21 rev 1, so for now we + // fall back to creation_time + offset. FIXME later! + $pay_deadline = $jbody['pay_deadline']['t_s'] ?? + (self::ORDER_VALIDITY_SECONDS + $jbody['creation_time']['t_s'] ?? 0); + break; + case 'claimed': + $contract_terms = $jbody['contract_terms']; + $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0; + break; + case 'paid': + $paid = TRUE; + $contract_terms = $jbody['contract_terms']; + $contract_version = $contract_terms['version'] ?? 0; + $now = time(); + switch ($contract_version) { + case 0: + $this->logger->warning('Got unexpected v0 contract version: Contract: @contract', [ + '@contract' => json_encode($contract_terms, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ]); break; - case 'paid': - $paid = TRUE; - $contract_terms = $jbody['contract_terms']; - $contract_version = $contract_terms['version'] ?? 0; - $now = time(); - switch ($contract_version) { - case 0: - $this->logger->warning('Got unexpected v0 contract version: Contract: @contract', - [ - '@contract' => json_encode ($contract_terms, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), - ], - ); - break; - case 1: - $choice_index = $jbody['choice_index'] ?? 0; - $token_families = $contract_terms['token_families']; - $contract_choice = $contract_terms['choices'][$choice_index]; - $outputs = $contract_choice['outputs']; - $found = FALSE; - foreach ($outputs as $output) { - $slug = $output['token_family_slug']; - $token_family = $token_families[$slug]; - $details = $token_family['details']; - if ('subscription' !== $details['class']) { - continue; - } - $keys = $token_family['keys']; - foreach ($keys as $key) { - $signature_validity_start = $key['signature_validity_start']['t_s']; - $signature_validity_end = $key['signature_validity_end']['t_s']; - if ( ($signature_validity_start <= $now) && - ($signature_validity_end > $now) ) - { - // Theoretically, one contract could buy multiple - // subscriptions. But GNU Taler Turnstile does not - // generate such contracts and we do not support - // that case here. - $subscription_slug = $slug; - $subscription_expiration = $signature_validity_end; - $found = TRUE; - break; - } - } // end of for each key - if ($found) - break; - } // end of for each output + case 1: + $choice_index = $jbody['choice_index'] ?? 0; + $token_families = $contract_terms['token_families']; + $contract_choice = $contract_terms['choices'][$choice_index]; + $outputs = $contract_choice['outputs']; + $found = FALSE; + foreach ($outputs as $output) { + $slug = $output['token_family_slug']; + $token_family = $token_families[$slug]; + $details = $token_family['details']; + if ('subscription' !== $details['class']) { + continue; + } + $keys = $token_family['keys']; + foreach ($keys as $key) { + $signature_validity_start = $key['signature_validity_start']['t_s']; + $signature_validity_end = $key['signature_validity_end']['t_s']; + if (($signature_validity_start <= $now) && + ($signature_validity_end > $now)) { + $subscription_slug = $slug; + $subscription_expiration = $signature_validity_end; + $found = TRUE; + break; + } + } + if ($found) { break; - default: - $this->logger->error('Got unsupported contract version "@version"', ['@version' => $contract_version]); - break; - } // end switch on contract version + } + } break; - default: - $this->logger->error('Got unexpected order status "@status"', ['@status' => $order_status]); - break; - } // switch on $order_status - return [ - 'order_id' => $order_id, - 'paid' => $paid, - 'subscription_slug' => $subscription_slug, - 'subscription_expiration' => $subscription_expiration, - 'order_expiration' => $pay_deadline, - ]; - } - catch (RequestException $e) { - // Any kind of error that is outside of the spec. - $this->logger->error('Failed to check order status: @message', ['@message' => $e->getMessage()]); - return FALSE; + default: + $this->logger->error('Got unsupported contract version "@version"', ['@version' => $contract_version]); + break; + } + break; + default: + $this->logger->error('Got unexpected order status "@status"', ['@status' => $order_status]); + break; } + return [ + 'order_id' => $order_id, + 'paid' => $paid, + 'subscription_slug' => $subscription_slug, + 'subscription_expiration' => $subscription_expiration, + 'order_expiration' => $pay_deadline, + ]; } @@ -610,7 +677,10 @@ class TalerMerchantApiService { * of payment choices. */ private function buildTemplateBody(TurnstilePriceCategory $price_category) { - $subscriptions = $this->getSubscriptions(); + // Use whatever subscriptions we can get; we still want to publish + // a usable template even if the cached/live data is incomplete. + $subs = $this->getSubscriptions(); + $subscriptions = is_array($subs->data) ? $subs->data : []; $choices = $price_category->getPaymentChoices($subscriptions); if (empty($choices)) { return FALSE; @@ -628,8 +698,8 @@ class TalerMerchantApiService { 'choices' => $choices, // Limit how long a paywall page is valid; the cookie // we hand out cannot outlive the order. - 'pay_duration' => [ 'd_us' => self::ORDER_VALIDITY_SECONDS * 1000000 ], - 'max_pickup_duration' => [ 'd_us' => self::ORDER_VALIDITY_SECONDS * 1000000 ], + 'pay_duration' => ['d_us' => self::ORDER_VALIDITY_SECONDS * 1000000], + 'max_pickup_duration' => ['d_us' => self::ORDER_VALIDITY_SECONDS * 1000000], ], ]; } @@ -643,16 +713,16 @@ class TalerMerchantApiService { * * @param TurnstilePriceCategory $price_category * The price category to publish as a template. - * @return bool - * TRUE on success, FALSE on any error + * @return TalerBackendResult + * OK on success; otherwise the specific error from the backend. */ - public function syncTemplate(TurnstilePriceCategory $price_category): bool { + public function syncTemplate(TurnstilePriceCategory $price_category): TalerBackendResult { $config = \Drupal::config('taler_turnstile.settings'); $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); if (empty($backend_url) || empty($access_token)) { $this->logger->debug('No backend, skipping template sync for @id', ['@id' => $price_category->id()]); - return FALSE; + return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED); } $body = $this->buildTemplateBody($price_category); if (FALSE === $body) { @@ -661,75 +731,37 @@ class TalerMerchantApiService { } $template_id = $body['template_id']; - $http_client = $this->httpClientFactory->fromOptions([ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - 'Content-Type' => 'application/json', - ], - 'http_errors' => FALSE, - 'allow_redirects' => TRUE, - 'timeout' => 5, + $http = $this->client([ + 'Authorization' => 'Bearer ' . $access_token, + 'Content-Type' => 'application/json', ]); - try { - $response = $http_client->post($backend_url . 'private/templates', [ - 'json' => $body, - ]); - $http_status = $response->getStatusCode(); - if ($http_status === 204) { - $this->logger->info('Created template @tid', ['@tid' => $template_id]); - return TRUE; - } - $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($http_status === 409) { - // Template already exists, fall through to PATCH below. - $this->logger->debug('Template @tid already exists, updating via PATCH', ['@tid' => $template_id]); - } - else { - $this->logger->error('Unexpected HTTP status @status creating template @tid: @hint (@detail, #@ec): @body', [ - '@status' => $http_status, - '@tid' => $template_id, - '@hint' => $jbody['hint'] ?? 'N/A', - '@detail' => $jbody['detail'] ?? 'N/A', - '@ec' => $jbody['code'] ?? 'N/A', - '@body' => $body_log_fmt ?? 'N/A', - ]); - if ($http_status !== 409) { - return FALSE; - } - } - // PATCH path. Note that PATCH does NOT take template_id in the body. - $patch_body = $body; - unset($patch_body['template_id']); - $response = $http_client->patch( - $backend_url . 'private/templates/' . rawurlencode($template_id), - ['json' => $patch_body] - ); - $http_status = $response->getStatusCode(); - if ($http_status === 204) { - $this->logger->info('Updated template @tid', ['@tid' => $template_id]); - return TRUE; - } - $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status @status updating template @tid: @hint (@detail, #@ec): @body', [ - '@status' => $http_status, - '@tid' => $template_id, - '@hint' => $jbody['hint'] ?? 'N/A', - '@detail' => $jbody['detail'] ?? 'N/A', - '@ec' => $jbody['code'] ?? 'N/A', - '@body' => $body_log_fmt ?? 'N/A', - ]); - return FALSE; - } - catch (RequestException $e) { - $this->logger->error('Failed to sync template @tid: @message', [ - '@tid' => $template_id, - '@message' => $e->getMessage(), - ]); - return FALSE; - } + $r = $this->runRequest(fn() => $http->post( + $backend_url . 'private/templates', + ['json' => $body] + )); + if ($r->isOk()) { + $this->logger->info('Created template @tid', ['@tid' => $template_id]); + return $r; + } + if ($r->httpStatus !== 409) { + $this->logBackendFailure('create template ' . $template_id, $r); + return $r; + } + // 409 -> already exists, fall through to PATCH. + $this->logger->debug('Template @tid already exists, updating via PATCH', ['@tid' => $template_id]); + $patch_body = $body; + unset($patch_body['template_id']); + $r = $this->runRequest(fn() => $http->patch( + $backend_url . 'private/templates/' . rawurlencode($template_id), + ['json' => $patch_body] + )); + if ($r->isOk()) { + $this->logger->info('Updated template @tid', ['@tid' => $template_id]); + return $r; + } + $this->logBackendFailure('update template ' . $template_id, $r); + return $r; } @@ -740,53 +772,30 @@ class TalerMerchantApiService { * * @param string $template_id * The full template ID to delete. - * @return bool - * TRUE on success or 404, FALSE on any other error + * @return TalerBackendResult + * OK on 204 or 404; otherwise the specific error from the backend. */ - public function deleteTemplate(string $template_id): bool { + public function deleteTemplate(string $template_id): TalerBackendResult { $config = \Drupal::config('taler_turnstile.settings'); $backend_url = $config->get('payment_backend_url'); $access_token = $config->get('access_token'); if (empty($backend_url) || empty($access_token)) { $this->logger->debug('No backend, skipping template delete for @tid', ['@tid' => $template_id]); - return FALSE; - } - try { - $http_client = $this->httpClientFactory->fromOptions([ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - 'http_errors' => FALSE, - 'allow_redirects' => TRUE, - 'timeout' => 5, - ]); - $response = $http_client->delete( - $backend_url . 'private/templates/' . rawurlencode($template_id) - ); - $http_status = $response->getStatusCode(); - if ($http_status === 204 || $http_status === 404) { - $this->logger->info('Template @tid removed (HTTP @status)', ['@tid' => $template_id, '@status' => $http_status]); - return TRUE; - } - $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status @status deleting template @tid: @hint (@detail, #@ec): @body', [ - '@status' => $http_status, - '@tid' => $template_id, - '@hint' => $jbody['hint'] ?? 'N/A', - '@detail' => $jbody['detail'] ?? 'N/A', - '@ec' => $jbody['code'] ?? 'N/A', - '@body' => $body_log_fmt ?? 'N/A', - ]); - return FALSE; - } - catch (RequestException $e) { - $this->logger->error('Failed to delete template @tid: @message', [ + return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED); + } + $http = $this->client(['Authorization' => 'Bearer ' . $access_token]); + $r = $this->runRequest(fn() => $http->delete( + $backend_url . 'private/templates/' . rawurlencode($template_id) + )); + if ($r->isOk() || $r->kind === TalerBackendErrorKind::NOT_FOUND) { + $this->logger->info('Template @tid removed (HTTP @status)', [ '@tid' => $template_id, - '@message' => $e->getMessage(), + '@status' => $r->httpStatus ?? 0, ]); - return FALSE; + return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: $r->httpStatus); } + $this->logBackendFailure('delete template ' . $template_id, $r); + return $r; } @@ -794,8 +803,12 @@ class TalerMerchantApiService { * Re-publish all known TurnstilePriceCategory templates. * Useful after settings changes that affect the contents * of every category template (e.g. subscription_prices). + * + * @return array<string, TalerBackendResult> + * Map of price-category id => sync result. Empty if categories + * could not even be loaded. */ - public function syncAllTemplates(): void { + public function syncAllTemplates(): array { try { $categories = \Drupal::entityTypeManager() ->getStorage('taler_turnstile_price_category') @@ -803,11 +816,13 @@ class TalerMerchantApiService { } catch (\Exception $e) { $this->logger->error('Failed to load price categories: @message', ['@message' => $e->getMessage()]); - return; + return []; } - foreach ($categories as $category) { - $this->syncTemplate($category); + $results = []; + foreach ($categories as $id => $category) { + $results[$id] = $this->syncTemplate($category); } + return $results; } @@ -826,9 +841,13 @@ class TalerMerchantApiService { * Whitelist of acceptable "currency:amount" strings, typically * built from the price category for the node. * @return array|FALSE - * On success: ['paid' => TRUE, 'subscription_slug' => ?, + * On success: ['paid' => TRUE, 'amount' => ..., + * 'subscription_slug' => ?, * 'subscription_expiration' => ?]. - * FALSE on any failure (also logs). + * FALSE on any failure (also logs). Kept array-or-FALSE rather + * than TalerBackendResult because the sole caller is the + * /taler-turnstile/paivana endpoint, which returns its own JSON + * regardless of which failure mode we hit. */ public function verifyPaidOrder(string $order_id, string $session_id, @@ -841,116 +860,91 @@ class TalerMerchantApiService { $this->logger->debug('No backend configured, cannot verify order'); return FALSE; } - try { - $http_client = $this->httpClientFactory->fromOptions([ - 'headers' => [ - 'Authorization' => 'Bearer ' . $access_token, - ], - 'http_errors' => FALSE, - 'allow_redirects' => TRUE, - 'timeout' => 5, + $http = $this->client(['Authorization' => 'Bearer ' . $access_token]); + $url = $backend_url . 'private/orders/' . rawurlencode($order_id) + . '?session_id=' . rawurlencode($session_id); + $r = $this->runRequest(fn() => $http->get($url)); + if (!$r->isOk()) { + $this->logBackendFailure('verify order ' . $order_id, $r); + return FALSE; + } + $jbody = $r->data; + if (($jbody['order_status'] ?? '') !== 'paid') { + $this->logger->info('Order @oid not (yet) paid, status=@s', [ + '@oid' => $order_id, + '@s' => $jbody['order_status'] ?? 'unknown', ]); - $url = $backend_url . 'private/orders/' . rawurlencode($order_id) - . '?session_id=' . rawurlencode($session_id); - $response = $http_client->get($url); - $http_status = $response->getStatusCode(); - $jbody = json_decode((string) $response->getBody(), TRUE) ?? []; - if ($http_status !== 200) { - $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->warning('Unexpected HTTP status @status verifying order @oid: @hint (@detail, #@ec): @body', [ - '@status' => $http_status, - '@oid' => $order_id, - '@hint' => $jbody['hint'] ?? 'N/A', - '@detail' => $jbody['detail'] ?? 'N/A', - '@ec' => $jbody['code'] ?? 'N/A', - '@body' => $body_log_fmt ?? 'N/A', - ]); - return FALSE; - } - if (($jbody['order_status'] ?? '') !== 'paid') { - $this->logger->info('Order @oid not (yet) paid, status=@s', [ - '@oid' => $order_id, - '@s' => $jbody['order_status'] ?? 'unknown', - ]); - return FALSE; - } - $contract_terms = $jbody['contract_terms'] ?? []; - $contract_fulfillment = $contract_terms['fulfillment_url'] ?? ''; - if ($contract_fulfillment !== $website) { - $this->logger->warning('Paid order @oid is for fulfillment URL "@got" but client claimed "@want"', [ - '@oid' => $order_id, - '@got' => $contract_fulfillment, - '@want' => $website, - ]); - return FALSE; - } - // Pull out the paid amount. Contract version 1 stores choices. - $contract_version = $contract_terms['version'] ?? 0; - $paid_amount = NULL; - $subscription_slug = FALSE; - $subscription_expiration = 0; - if (1 === $contract_version) { - $choice_index = $jbody['choice_index'] ?? 0; - $contract_choice = $contract_terms['choices'][$choice_index] ?? []; - $paid_amount = $contract_choice['amount'] ?? NULL; - // Detect any subscription tokens generated by this purchase. - $token_families = $contract_terms['token_families'] ?? []; - $outputs = $contract_choice['outputs'] ?? []; - $now = time(); - foreach ($outputs as $output) { - $slug = $output['token_family_slug'] ?? NULL; - if (!$slug || !isset($token_families[$slug])) { - continue; - } - $token_family = $token_families[$slug]; - if (($token_family['details']['class'] ?? NULL) !== 'subscription') { - continue; - } - foreach ($token_family['keys'] ?? [] as $key) { - $start = $key['signature_validity_start']['t_s'] ?? 0; - $end = $key['signature_validity_end']['t_s'] ?? 0; - if (($start <= $now) && ($end > $now)) { - $subscription_slug = $slug; - $subscription_expiration = $end; - break 2; - } + return FALSE; + } + $contract_terms = $jbody['contract_terms'] ?? []; + $contract_fulfillment = $contract_terms['fulfillment_url'] ?? ''; + if ($contract_fulfillment !== $website) { + $this->logger->warning('Paid order @oid is for fulfillment URL "@got" but client claimed "@want"', [ + '@oid' => $order_id, + '@got' => $contract_fulfillment, + '@want' => $website, + ]); + return FALSE; + } + // Pull out the paid amount. Contract version 1 stores choices. + $contract_version = $contract_terms['version'] ?? 0; + $paid_amount = NULL; + $subscription_slug = FALSE; + $subscription_expiration = 0; + if (1 === $contract_version) { + $choice_index = $jbody['choice_index'] ?? 0; + $contract_choice = $contract_terms['choices'][$choice_index] ?? []; + $paid_amount = $contract_choice['amount'] ?? NULL; + // Detect any subscription tokens generated by this purchase. + $token_families = $contract_terms['token_families'] ?? []; + $outputs = $contract_choice['outputs'] ?? []; + $now = time(); + foreach ($outputs as $output) { + $slug = $output['token_family_slug'] ?? NULL; + if (!$slug || !isset($token_families[$slug])) { + continue; + } + $token_family = $token_families[$slug]; + if (($token_family['details']['class'] ?? NULL) !== 'subscription') { + continue; + } + foreach ($token_family['keys'] ?? [] as $key) { + $start = $key['signature_validity_start']['t_s'] ?? 0; + $end = $key['signature_validity_end']['t_s'] ?? 0; + if (($start <= $now) && ($end > $now)) { + $subscription_slug = $slug; + $subscription_expiration = $end; + break 2; } } } - else { - $this->logger->error('Unsupported contract version @v for order @oid', [ - '@v' => $contract_version, - '@oid' => $order_id, - ]); - return FALSE; - } - if ($paid_amount === NULL) { - $this->logger->error('Could not determine paid amount for order @oid', ['@oid' => $order_id]); - return FALSE; - } - if (!in_array($paid_amount, $expected_amounts, TRUE)) { - $this->logger->warning('Paid order @oid has amount @got which is not among acceptable amounts (@want) for fulfillment @url', [ - '@oid' => $order_id, - '@got' => $paid_amount, - '@want' => implode(', ', $expected_amounts), - '@url' => $website, - ]); - return FALSE; - } - return [ - 'paid' => TRUE, - 'amount' => $paid_amount, - 'subscription_slug' => $subscription_slug, - 'subscription_expiration' => $subscription_expiration, - ]; } - catch (RequestException $e) { - $this->logger->error('Failed to verify order @oid: @message', [ + else { + $this->logger->error('Unsupported contract version @v for order @oid', [ + '@v' => $contract_version, + '@oid' => $order_id, + ]); + return FALSE; + } + if ($paid_amount === NULL) { + $this->logger->error('Could not determine paid amount for order @oid', ['@oid' => $order_id]); + return FALSE; + } + if (!in_array($paid_amount, $expected_amounts, TRUE)) { + $this->logger->warning('Paid order @oid has amount @got which is not among acceptable amounts (@want) for fulfillment @url', [ '@oid' => $order_id, - '@message' => $e->getMessage(), + '@got' => $paid_amount, + '@want' => implode(', ', $expected_amounts), + '@url' => $website, ]); return FALSE; } + return [ + 'paid' => TRUE, + 'amount' => $paid_amount, + 'subscription_slug' => $subscription_slug, + 'subscription_expiration' => $subscription_expiration, + ]; } @@ -979,4 +973,4 @@ class TalerMerchantApiService { } -} -\ No newline at end of file +}