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:
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
+}