turnstile

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

TalerMerchantApiService.php (37544B)


      1 <?php
      2 
      3 /**
      4  * @file
      5  * Location: src/TalerMerchantApiService.php
      6  *
      7  * Service for interacting with the Taler Merchant Backend.
      8  *
      9  * Every method that hits the network returns a TalerBackendResult so
     10  * callers can render a precise error to the admin and decide how to
     11  * proceed. The shared runRequest() helper classifies Guzzle outcomes
     12  * (transport vs. HTTP status vs. malformed JSON) into a single
     13  * TalerBackendErrorKind, so per-method code only has to handle the
     14  * Taler-specific semantics (e.g. "409 on POST template means PATCH").
     15  */
     16 
     17 namespace Drupal\taler_turnstile;
     18 
     19 use Drupal\Core\Http\ClientFactory;
     20 use Psr\Log\LoggerInterface;
     21 use Drupal\taler_turnstile\Entity\TurnstilePriceCategory;
     22 use GuzzleHttp\Exception\ConnectException;
     23 use GuzzleHttp\Exception\RequestException;
     24 use Drupal\Core\StringTranslation\StringTranslationTrait;
     25 
     26 
     27 /**
     28  * Taler error codes used in this module. We do not define
     29  * the full list here as that would be excessive and could
     30  * just slow down PHP unnecessarily.
     31  */
     32 enum TalerErrorCode: int {
     33     case TALER_EC_NONE = 0;
     34     case TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN = 2000;
     35     case TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN = 2005;
     36     case TALER_EC_MERCHANT_GENERIC_TEMPLATE_UNKNOWN = 2030;
     37     case TALER_EC_MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS = 2603;
     38 }
     39 
     40 
     41 /**
     42  * Service for fetching subscriptions and currencies from external API.
     43  */
     44 class TalerMerchantApiService {
     45 
     46   /**
     47    * For i18n, gives us the t() function.
     48    */
     49   use StringTranslationTrait;
     50 
     51   /**
     52    * How long are orders valid by default? 24h.
     53    */
     54   const ORDER_VALIDITY_SECONDS = 86400;
     55 
     56   /**
     57    * How long do we cache /config and token family data from the backend?
     58    */
     59   const CACHE_BACKEND_DATA_SECONDS = 60;
     60 
     61   /**
     62    * Timeout for every HTTP request we make to the merchant backend.
     63    * Kept short on purpose: these calls run synchronously inside
     64    * admin form submissions and the paywall request path.
     65    */
     66   const REQUEST_TIMEOUT_SECONDS = 5;
     67 
     68   /**
     69    * Merchant backend protocol version (libtool "current") required by
     70    * this module. The backend's /config "version" string is libtool-style
     71    * "CURRENT:REVISION:AGE": the backend supports interfaces in the range
     72    * [CURRENT-AGE, CURRENT], so we require this number to fall in that
     73    * range.
     74    */
     75   const REQUIRED_PROTOCOL_VERSION = 29;
     76 
     77   /**
     78    * The HTTP client factory.
     79    *
     80    * @var \Drupal\Core\Http\ClientFactory
     81    */
     82   protected $httpClientFactory;
     83 
     84   /**
     85    * The logger.
     86    *
     87    * @var \Psr\Log\LoggerInterface
     88    */
     89   protected $logger;
     90 
     91   /**
     92    * Constructs a TalerMerchantApiService object.
     93    *
     94    * @param \Drupal\Core\Http\ClientFactory $http_client_factory
     95    *   The HTTP client factory.
     96    * @param \Psr\Log\LoggerInterface $logger
     97    *   The logger.
     98    */
     99   public function __construct(ClientFactory $http_client_factory, LoggerInterface $logger) {
    100     $this->httpClientFactory = $http_client_factory;
    101     $this->logger = $logger;
    102   }
    103 
    104 
    105   /**
    106    * Build an HTTP client with the default options for talking to the
    107    * merchant backend: do not throw on HTTP error status codes, follow
    108    * redirects, and bound runtime by REQUEST_TIMEOUT_SECONDS.
    109    *
    110    * @param array $headers
    111    *   Extra headers (typically Authorization and/or Content-Type).
    112    */
    113   private function client(array $headers = []) {
    114     $opts = [
    115       'http_errors'     => FALSE,
    116       'allow_redirects' => TRUE,
    117       'timeout'         => self::REQUEST_TIMEOUT_SECONDS,
    118     ];
    119     if (!empty($headers)) {
    120       $opts['headers'] = $headers;
    121     }
    122     return $this->httpClientFactory->fromOptions($opts);
    123   }
    124 
    125 
    126   /**
    127    * Run an HTTP closure and classify its result.
    128    *
    129    * The closure must return a Psr\Http\Message\ResponseInterface.
    130    * Network/transport errors (DNS, ECONNREFUSED, TLS, timeout) come
    131    * back as UNREACHABLE; HTTP status codes are mapped to AUTH_FAILED,
    132    * NOT_FOUND, SERVER_ERROR or PROTOCOL_ERROR; 2xx is OK.
    133    *
    134    * The decoded JSON body (or [] if the body was not valid JSON) is
    135    * always exposed in $result->data so callers can pull payload
    136    * fields out without re-decoding.
    137    */
    138   private function runRequest(callable $fn): TalerBackendResult {
    139     try {
    140       $response = $fn();
    141     }
    142     catch (ConnectException $e) {
    143       return new TalerBackendResult(
    144         TalerBackendErrorKind::UNREACHABLE,
    145         transport: $e->getMessage(),
    146       );
    147     }
    148     catch (RequestException $e) {
    149       return new TalerBackendResult(
    150         TalerBackendErrorKind::UNREACHABLE,
    151         transport: $e->getMessage(),
    152       );
    153     }
    154     catch (\Throwable $e) {
    155       return new TalerBackendResult(
    156         TalerBackendErrorKind::PROTOCOL_ERROR,
    157         transport: $e->getMessage(),
    158       );
    159     }
    160     $status = $response->getStatusCode();
    161     $jbody = json_decode((string) $response->getBody(), TRUE);
    162     if (!is_array($jbody)) {
    163       $jbody = [];
    164     }
    165     $kind = match (TRUE) {
    166       $status >= 200 && $status < 300     => TalerBackendErrorKind::OK,
    167       $status === 401 || $status === 403  => TalerBackendErrorKind::AUTH_FAILED,
    168       $status === 404                     => TalerBackendErrorKind::NOT_FOUND,
    169       $status >= 500                      => TalerBackendErrorKind::SERVER_ERROR,
    170       default                             => TalerBackendErrorKind::PROTOCOL_ERROR,
    171     };
    172     return new TalerBackendResult($kind,
    173       httpStatus: $status,
    174       hint:       $jbody['hint']   ?? NULL,
    175       detail:     $jbody['detail'] ?? NULL,
    176       talerEc:    $jbody['code']   ?? NULL,
    177       data:       $jbody,
    178     );
    179   }
    180 
    181 
    182   /**
    183    * Log a backend failure with the full envelope. AUTH_FAILED is
    184    * logged at warning (operator-fixable); everything else at error.
    185    *
    186    * @param string $action
    187    *   Short description of what we were trying to do
    188    *   (used in the log message verbatim).
    189    * @param TalerBackendResult $r
    190    *   The failed result to log.
    191    */
    192   private function logBackendFailure(string $action, TalerBackendResult $r): void {
    193     $level = ($r->kind === TalerBackendErrorKind::AUTH_FAILED) ? 'warning' : 'error';
    194     $body = is_array($r->data)
    195       ? json_encode($r->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
    196       : 'N/A';
    197     $this->logger->log($level,
    198       'Failed to @action: HTTP @status (@kind), @hint (@detail, #@ec), transport=@transport, body=@body', [
    199         '@action'    => $action,
    200         '@status'    => $r->httpStatus ?? 0,
    201         '@kind'      => $r->kind->value,
    202         '@hint'      => $r->hint      ?? 'N/A',
    203         '@detail'    => $r->detail    ?? 'N/A',
    204         '@ec'        => $r->talerEc   ?? 'N/A',
    205         '@transport' => $r->transport ?? 'N/A',
    206         '@body'      => $body,
    207       ]);
    208   }
    209 
    210 
    211   /**
    212    * Return the base URL for the given backend URL (without instance!)
    213    *
    214    * @param string $backend_url
    215    *   Backend URL to check, may include '/instances/$ID' path
    216    * @return string|null
    217    *   base URL, or NULL if the backend URL is invalid
    218    */
    219   private function getBaseURL(string $backend_url) {
    220     if (empty($backend_url)) {
    221       return NULL;
    222     }
    223     if (!str_ends_with($backend_url, '/')) {
    224       return NULL;
    225     }
    226     $parsed_url = parse_url($backend_url);
    227     $path = $parsed_url['path'] ?? '/';
    228     $cleaned_path = preg_replace('#^/instances/[^/]+/?#', '/', $path);
    229     $base = $parsed_url['scheme'] . '://' . $parsed_url['host'];
    230     if (isset($parsed_url['port'])) {
    231       $base .= ':' . $parsed_url['port'];
    232     }
    233     return $base . $cleaned_path;
    234   }
    235 
    236 
    237   /**
    238    * Checks if the given backend URL points to a Taler merchant backend
    239    * speaking a compatible protocol version.
    240    *
    241    * @param string $backend_url
    242    *   Backend URL to check, may include '/instances/$ID' path.
    243    * @return TalerBackendResult
    244    *   OK if the URL responds with a compatible /config; on failure,
    245    *   the kind/transport fields describe what went wrong.
    246    */
    247   public function checkConfig(string $backend_url): TalerBackendResult {
    248     $base_url = $this->getBaseURL($backend_url);
    249     if (NULL === $base_url) {
    250       return new TalerBackendResult(
    251         TalerBackendErrorKind::NOT_CONFIGURED,
    252         transport: 'backend URL is empty or does not end with "/"',
    253       );
    254     }
    255     $http = $this->client();
    256     $r = $this->runRequest(fn() => $http->get($base_url . 'config'));
    257     if (!$r->isOk()) {
    258       $this->logBackendFailure('fetch /config from ' . $base_url, $r);
    259       return $r;
    260     }
    261     $body = $r->data;
    262     if (!isset($body['name']) || $body['name'] !== 'taler-merchant') {
    263       $this->logger->warning('URL @url responded to /config but is not a Taler merchant backend (name=@name).', [
    264         '@url' => $base_url,
    265         '@name' => $body['name'] ?? '(missing)',
    266       ]);
    267       return new TalerBackendResult(
    268         TalerBackendErrorKind::PROTOCOL_ERROR,
    269         httpStatus: $r->httpStatus,
    270         transport: 'response does not look like a Taler merchant /config',
    271       );
    272     }
    273     if (!isset($body['version']) || !is_string($body['version'])) {
    274       $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot verify protocol compatibility.');
    275       return new TalerBackendResult(
    276         TalerBackendErrorKind::PROTOCOL_ERROR,
    277         httpStatus: $r->httpStatus,
    278         transport: 'missing "version" field in /config response',
    279       );
    280     }
    281     if (!$this->checkVersion($body['version'])) {
    282       // checkVersion() already logged the specific reason.
    283       return new TalerBackendResult(
    284         TalerBackendErrorKind::PROTOCOL_ERROR,
    285         httpStatus: $r->httpStatus,
    286         transport: 'incompatible backend protocol version: ' . $body['version'],
    287       );
    288     }
    289     return new TalerBackendResult(
    290       TalerBackendErrorKind::OK,
    291       httpStatus: $r->httpStatus,
    292       data: $body,
    293     );
    294   }
    295 
    296 
    297   /**
    298    * Verify that a libtool-style "CURRENT:REVISION:AGE" version string
    299    * advertises support for self::REQUIRED_PROTOCOL_VERSION. The backend
    300    * supports interfaces in [CURRENT-AGE, CURRENT]; we require the
    301    * required version to lie within that range. Logs an error when it
    302    * does not.
    303    *
    304    * @param string $version
    305    *   The "version" field from the backend's /config response.
    306    * @return bool
    307    *   TRUE iff the backend speaks a compatible protocol version.
    308    */
    309   private function checkVersion(string $version): bool {
    310     $parts = explode(':', $version);
    311     if (count($parts) !== 3
    312         || !ctype_digit($parts[0])
    313         || !ctype_digit($parts[1])
    314         || !ctype_digit($parts[2])) {
    315       $this->logger->error('Taler merchant backend reported malformed version "@version" (expected libtool-style CURRENT:REVISION:AGE).', [
    316         '@version' => $version,
    317       ]);
    318       return FALSE;
    319     }
    320     $current = (int) $parts[0];
    321     $age = (int) $parts[2];
    322     $required = self::REQUIRED_PROTOCOL_VERSION;
    323     if ($current < $required) {
    324       $this->logger->error('Taler merchant backend protocol version "@version is too old; this module requires protocol v@required or newer.', [
    325         '@version' => $version,
    326         '@required' => $required,
    327       ]);
    328       return FALSE;
    329     }
    330     if ($current - $age > $required) {
    331       $this->logger->warning('Taler merchant backend protocol version "@version" MAY no longer support v@required required by this module. Proceed with caution.', [
    332         '@version' => $version,
    333         '@required' => $required,
    334       ]);
    335       return TRUE;
    336     }
    337     return TRUE;
    338   }
    339 
    340   /**
    341    * Verify that the configured access token is accepted by the backend
    342    * instance (private/orders is a cheap, harmless ping).
    343    *
    344    * @param string $backend_url
    345    *   Backend URL to check, may include '/instances/$ID' path.
    346    * @param string $access_token
    347    *   Access token to talk to the instance.
    348    * @return TalerBackendResult
    349    *   OK on 200/204; AUTH_FAILED on 401/403; NOT_FOUND on 404
    350    *   (unknown instance); UNREACHABLE on transport error; etc.
    351    */
    352   public function checkAccess(string $backend_url, string $access_token): TalerBackendResult {
    353     if (empty($backend_url) || empty($access_token)) {
    354       return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED);
    355     }
    356     $http = $this->client(['Authorization' => 'Bearer ' . $access_token]);
    357     return $this->runRequest(fn() => $http->get(
    358       $backend_url . 'private/orders?limit=1'
    359     ));
    360   }
    361 
    362   /**
    363    * Gets the list of available subscriptions. Always includes a
    364    * special "%none%" entry for "No reduction" so the form can offer
    365    * a no-subscription price if no subscriptions are configured or
    366    * if the backend is unreachable.
    367    *
    368    * @return TalerBackendResult
    369    *   data field: array mapping token family slugs (plus "%none%")
    370    *   to ['name', 'label', 'description', 'description_i18n',
    371    *   'valid_before_s']. On NOT_CONFIGURED / UNREACHABLE the data
    372    *   field still contains the "%none%" entry so callers can render
    373    *   something usable.
    374    */
    375   public function getSubscriptions(): TalerBackendResult {
    376     $config = \Drupal::config('taler_turnstile.settings');
    377     $backend_url = $config->get('payment_backend_url');
    378     $access_token = $config->get('access_token');
    379 
    380     // Always include "no subscription" so callers (forms, paywall)
    381     // can render something usable even on failure.
    382     $base = [];
    383     $base['%none%'] = [
    384       'name' => 'none',
    385       'label' => 'No reduction',
    386       'description' => (string) $this->t('No subscription', [], ['langcode' => 'en']),
    387       'description_i18n' => $this->buildTranslationMap('No subscription'),
    388     ];
    389 
    390     if (empty($backend_url) || empty($access_token)) {
    391       $this->logger->debug('No GNU Taler Turnstile backend configured, returning "none" for subscriptions.');
    392       return new TalerBackendResult(
    393         TalerBackendErrorKind::NOT_CONFIGURED,
    394         data: $base,
    395       );
    396     }
    397 
    398     // Key the cache on (url, token) so changing the backend or
    399     // rotating the token immediately invalidates the previous entry.
    400     $cid = 'taler_turnstile:subscriptions:' . md5($backend_url . '|' . $access_token);
    401     if ($cache = \Drupal::cache()->get($cid)) {
    402       return new TalerBackendResult(TalerBackendErrorKind::OK, data: $cache->data);
    403     }
    404 
    405     $http = $this->client(['Authorization' => 'Bearer ' . $access_token]);
    406     $r = $this->runRequest(fn() => $http->get($backend_url . 'private/tokenfamilies'));
    407 
    408     // 204 = empty list. Cache and return "just %none%".
    409     if ($r->httpStatus === 204) {
    410       \Drupal::cache()->set($cid, $base, time() + self::CACHE_BACKEND_DATA_SECONDS);
    411       return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: 204, data: $base);
    412     }
    413     if (!$r->isOk()) {
    414       $this->logBackendFailure('fetch token family list', $r);
    415       // Hand back a result that still has %none% so the form can render.
    416       return new TalerBackendResult(
    417         $r->kind,
    418         httpStatus: $r->httpStatus,
    419         transport: $r->transport,
    420         hint: $r->hint,
    421         detail: $r->detail,
    422         talerEc: $r->talerEc,
    423         data: $base,
    424       );
    425     }
    426     if (!isset($r->data['token_families'])) {
    427       $this->logger->error('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.');
    428       return new TalerBackendResult(
    429         TalerBackendErrorKind::PROTOCOL_ERROR,
    430         httpStatus: $r->httpStatus,
    431         transport: 'missing "token_families" field in tokenfamilies response',
    432         data: $base,
    433       );
    434     }
    435 
    436     $result = $base;
    437     $now = time();
    438     foreach ($r->data['token_families'] as $family) {
    439       // Defensively skip malformed entries rather than dereferencing
    440       // missing keys (which would emit PHP warnings and could yield
    441       // bogus comparisons).
    442       $slug = $family['slug'] ?? NULL;
    443       if (!is_string($slug) || $slug === '') {
    444         $this->logger->warning('Token family entry without a usable slug, skipping it.');
    445         continue;
    446       }
    447       if (($family['kind'] ?? NULL) !== 'subscription') {
    448         $this->logger->info('Token family @slug is not a subscription, skipping it.', ['@slug' => $slug]);
    449         continue;
    450       }
    451       $valid_after_raw  = $family['valid_after']['t_s']  ?? NULL;
    452       $valid_before_raw = $family['valid_before']['t_s'] ?? NULL;
    453       if (!is_int($valid_after_raw) || $valid_before_raw === NULL) {
    454         $this->logger->warning('Token family @slug has malformed validity window, skipping it.', ['@slug' => $slug]);
    455         continue;
    456       }
    457       $valid_before = ($valid_before_raw === 'never')
    458         ? PHP_INT_MAX
    459         : (is_int($valid_before_raw) ? $valid_before_raw : NULL);
    460       if ($valid_before === NULL) {
    461         $this->logger->warning('Token family @slug has non-integer valid_before, skipping it.', ['@slug' => $slug]);
    462         continue;
    463       }
    464       if (!($valid_after_raw < $now && $valid_before >= $now)) {
    465         $this->logger->info('Token family @slug is not valid right now, skipping it.', ['@slug' => $slug]);
    466         continue;
    467       }
    468       $result[$slug] = [
    469         'name' => $family['name'] ?? $slug,
    470         'label' => $slug,
    471         'valid_before_s' => $valid_before,
    472         'description' => $family['description'] ?? '',
    473         'description_i18n' => $family['description_i18n'] ?? NULL,
    474       ];
    475     }
    476     \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS);
    477     return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: $r->httpStatus, data: $result);
    478   }
    479 
    480 
    481   /**
    482    * Gets the list of available currencies.
    483    *
    484    * @return TalerBackendResult
    485    *   data field: array of currency dicts with 'code', 'name',
    486    *   'label', 'step'. data is [] on failure.
    487    */
    488   public function getCurrencies(): TalerBackendResult {
    489     $config = \Drupal::config('taler_turnstile.settings');
    490     $payment_backend_url = $config->get('payment_backend_url');
    491 
    492     if (empty($payment_backend_url)) {
    493       $this->logger->error('Taler merchant backend not configured; cannot obtain currency list');
    494       return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED, data: []);
    495     }
    496 
    497     $cid = 'taler_turnstile:currencies:' . md5($payment_backend_url);
    498     if ($cache = \Drupal::cache()->get($cid)) {
    499       return new TalerBackendResult(TalerBackendErrorKind::OK, data: $cache->data);
    500     }
    501 
    502     $http = $this->client();
    503     $r = $this->runRequest(fn() => $http->get($payment_backend_url . 'config'));
    504     if (!$r->isOk()) {
    505       $this->logBackendFailure('fetch /config for currency list', $r);
    506       return new TalerBackendResult(
    507         $r->kind,
    508         httpStatus: $r->httpStatus,
    509         transport: $r->transport,
    510         hint: $r->hint,
    511         detail: $r->detail,
    512         talerEc: $r->talerEc,
    513         data: [],
    514       );
    515     }
    516     $body = $r->data;
    517     if (!isset($body['version']) || !is_string($body['version'])) {
    518       $this->logger->error('Taler merchant backend /config response is missing the "version" field; cannot obtain currency list.');
    519       return new TalerBackendResult(
    520         TalerBackendErrorKind::PROTOCOL_ERROR,
    521         httpStatus: $r->httpStatus,
    522         transport: 'missing "version" field in /config',
    523         data: [],
    524       );
    525     }
    526     if (!$this->checkVersion($body['version'])) {
    527       // checkVersion() already logged the specific reason.
    528       return new TalerBackendResult(
    529         TalerBackendErrorKind::PROTOCOL_ERROR,
    530         httpStatus: $r->httpStatus,
    531         transport: 'incompatible backend protocol version: ' . $body['version'],
    532         data: [],
    533       );
    534     }
    535     if (!isset($body['currencies'])) {
    536       $this->logger->error('Backend returned malformed response for /config (no "currencies")');
    537       return new TalerBackendResult(
    538         TalerBackendErrorKind::PROTOCOL_ERROR,
    539         httpStatus: $r->httpStatus,
    540         transport: 'missing "currencies" field in /config',
    541         data: [],
    542       );
    543     }
    544 
    545     $result = array_map(function ($currency) {
    546       // Amount::step gives an exact Amount for the smallest (usually) user-enterable
    547       // unit given the number of fractional digits from the currency spec.
    548       return [
    549         'code' => $currency['currency'],
    550         'name' => $currency['name'],
    551         'label' => $currency['alt_unit_names'][0] ?? $currency['id'],
    552         'step' => Amount::step(
    553           $currency['currency'],
    554           $currency['num_fractional_input_digits'] ?? 2
    555         ),
    556       ];
    557     }, $body['currencies']);
    558 
    559     \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS);
    560     return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: $r->httpStatus, data: $result);
    561   }
    562 
    563 
    564   /**
    565    * Check order status with Taler backend. Used only for diagnostic
    566    * code paths; the main paywall flow uses verifyPaidOrder().
    567    *
    568    * Returns the legacy array-or-FALSE shape because the only caller
    569    * is internal diagnostics; the surrounding admin UI does not need
    570    * to render a specific message for this method.
    571    *
    572    * @param string $order_id
    573    *   The order ID to check.
    574    *
    575    * @return array|FALSE
    576    *   Order status information or FALSE on failure.
    577    */
    578   public function checkOrderStatus($order_id) {
    579     $config = \Drupal::config('taler_turnstile.settings');
    580     $backend_url = $config->get('payment_backend_url');
    581     $access_token = $config->get('access_token');
    582 
    583     if (empty($backend_url) || empty($access_token)) {
    584       $this->logger->debug('No GNU Taler Turnstile backend configured, cannot check order status!');
    585       return FALSE;
    586     }
    587 
    588     $http = $this->client(['Authorization' => 'Bearer ' . $access_token]);
    589     $r = $this->runRequest(fn() => $http->get($backend_url . 'private/orders/' . $order_id));
    590 
    591     if (!$r->isOk()) {
    592       // Special-case 404: try to surface the specific Taler EC.
    593       if ($r->httpStatus === 404) {
    594         $ec = TalerErrorCode::tryFrom((int) ($r->talerEc ?? 0)) ?? TalerErrorCode::TALER_EC_NONE;
    595         switch ($ec) {
    596           case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_INSTANCE_UNKNOWN:
    597             $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your GNU Taler Turnstile configuration!', ['@detail' => $r->detail ?? 'N/A']);
    598             return FALSE;
    599           case TalerErrorCode::TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN:
    600             $this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]);
    601             return FALSE;
    602           default:
    603             $this->logBackendFailure('get order status (' . $order_id . ')', $r);
    604             return FALSE;
    605         }
    606       }
    607       $this->logBackendFailure('get order status (' . $order_id . ')', $r);
    608       return FALSE;
    609     }
    610 
    611     $jbody = $r->data;
    612     $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    613     $this->logger->debug('Got existing contract: @body', ['@body' => $body_log_fmt ?? 'N/A']);
    614 
    615     $order_status = $jbody['order_status'] ?? 'unknown';
    616     $subscription_expiration = 0;
    617     $subscription_slug = FALSE;
    618     $pay_deadline = 0;
    619     $paid = FALSE;
    620     switch ($order_status) {
    621       case 'unpaid':
    622         // 'pay_deadline' is only available since v21 rev 1, so for now we
    623         // fall back to creation_time + offset. FIXME later!
    624         $pay_deadline = $jbody['pay_deadline']['t_s'] ??
    625                         (self::ORDER_VALIDITY_SECONDS + $jbody['creation_time']['t_s'] ?? 0);
    626         break;
    627       case 'claimed':
    628         $contract_terms = $jbody['contract_terms'];
    629         $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0;
    630         break;
    631       case 'paid':
    632         $paid = TRUE;
    633         $contract_terms = $jbody['contract_terms'];
    634         $contract_version = $contract_terms['version'] ?? 0;
    635         $now = time();
    636         switch ($contract_version) {
    637           case 0:
    638             $this->logger->warning('Got unexpected v0 contract version: Contract: @contract', [
    639               '@contract' => json_encode($contract_terms, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
    640             ]);
    641             break;
    642           case 1:
    643             $choice_index = $jbody['choice_index'] ?? 0;
    644             $token_families = $contract_terms['token_families'];
    645             $contract_choice = $contract_terms['choices'][$choice_index];
    646             $outputs = $contract_choice['outputs'];
    647             $found = FALSE;
    648             foreach ($outputs as $output) {
    649               $slug = $output['token_family_slug'];
    650               $token_family = $token_families[$slug];
    651               $details = $token_family['details'];
    652               if ('subscription' !== $details['class']) {
    653                 continue;
    654               }
    655               $keys = $token_family['keys'];
    656               foreach ($keys as $key) {
    657                 $signature_validity_start = $key['signature_validity_start']['t_s'];
    658                 $signature_validity_end = $key['signature_validity_end']['t_s'];
    659                 if (($signature_validity_start <= $now) &&
    660                     ($signature_validity_end > $now)) {
    661                   $subscription_slug = $slug;
    662                   $subscription_expiration = $signature_validity_end;
    663                   $found = TRUE;
    664                   break;
    665                 }
    666               }
    667               if ($found) {
    668                 break;
    669               }
    670             }
    671             break;
    672           default:
    673             $this->logger->error('Got unsupported contract version "@version"', ['@version' => $contract_version]);
    674             break;
    675         }
    676         break;
    677       default:
    678         $this->logger->error('Got unexpected order status "@status"', ['@status' => $order_status]);
    679         break;
    680     }
    681     return [
    682       'order_id' => $order_id,
    683       'paid' => $paid,
    684       'subscription_slug' => $subscription_slug,
    685       'subscription_expiration' => $subscription_expiration,
    686       'order_expiration' => $pay_deadline,
    687     ];
    688   }
    689 
    690 
    691   /**
    692    * Build the request body for a "paivana"-style template
    693    * mirroring the prices of the given $price_category.
    694    *
    695    * @param TurnstilePriceCategory $price_category
    696    *   The price category to mirror.
    697    * @return array|FALSE
    698    *   Body suitable for POST/PATCH on /private/templates,
    699    *   or FALSE if the category does not yield a usable set
    700    *   of payment choices.
    701    */
    702   private function buildTemplateBody(TurnstilePriceCategory $price_category) {
    703     // Use whatever subscriptions we can get; we still want to publish
    704     // a usable template even if the cached/live data is incomplete.
    705     $subs = $this->getSubscriptions();
    706     $subscriptions = is_array($subs->data) ? $subs->data : [];
    707     $choices = $price_category->getPaymentChoices($subscriptions);
    708     if (empty($choices)) {
    709       return FALSE;
    710     }
    711     $description = $price_category->getDescription();
    712     if (empty($description)) {
    713       $description = $price_category->label() ?? $price_category->id();
    714     }
    715     return [
    716       'template_id' => $price_category->getTemplateId(),
    717       'template_description' => $description,
    718       'template_contract' => [
    719         'template_type' => 'paivana',
    720         'summary' => 'Access to: @' . $price_category->id(),
    721         'choices' => $choices,
    722         // Limit how long a paywall page is valid; the cookie
    723         // we hand out cannot outlive the order.
    724         'pay_duration' => ['d_us' => self::ORDER_VALIDITY_SECONDS * 1000000],
    725         'max_pickup_duration' => ['d_us' => self::ORDER_VALIDITY_SECONDS * 1000000],
    726       ],
    727     ];
    728   }
    729 
    730 
    731   /**
    732    * Create or update the "paivana"-style template in the merchant
    733    * backend that mirrors the prices configured in $price_category.
    734    * Performs a POST and falls back to PATCH if the template already
    735    * exists.
    736    *
    737    * @param TurnstilePriceCategory $price_category
    738    *   The price category to publish as a template.
    739    * @return TalerBackendResult
    740    *   OK on success; otherwise the specific error from the backend.
    741    */
    742   public function syncTemplate(TurnstilePriceCategory $price_category): TalerBackendResult {
    743     $config = \Drupal::config('taler_turnstile.settings');
    744     $backend_url = $config->get('payment_backend_url');
    745     $access_token = $config->get('access_token');
    746     if (empty($backend_url) || empty($access_token)) {
    747       $this->logger->debug('No backend, skipping template sync for @id', ['@id' => $price_category->id()]);
    748       return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED);
    749     }
    750     $body = $this->buildTemplateBody($price_category);
    751     if (FALSE === $body) {
    752       $this->logger->info('Price category @id has no usable choices, deleting any existing template', ['@id' => $price_category->id()]);
    753       return $this->deleteTemplate($price_category->getTemplateId());
    754     }
    755 
    756     $template_id = $body['template_id'];
    757     $http = $this->client([
    758       'Authorization' => 'Bearer ' . $access_token,
    759       'Content-Type'  => 'application/json',
    760     ]);
    761 
    762     $r = $this->runRequest(fn() => $http->post(
    763       $backend_url . 'private/templates',
    764       ['json' => $body]
    765     ));
    766     if ($r->isOk()) {
    767       $this->logger->info('Created template @tid', ['@tid' => $template_id]);
    768       return $r;
    769     }
    770     if ($r->httpStatus !== 409) {
    771       $this->logBackendFailure('create template ' . $template_id, $r);
    772       return $r;
    773     }
    774     // 409 -> already exists, fall through to PATCH.
    775     $this->logger->debug('Template @tid already exists, updating via PATCH', ['@tid' => $template_id]);
    776     $patch_body = $body;
    777     unset($patch_body['template_id']);
    778     $r = $this->runRequest(fn() => $http->patch(
    779       $backend_url . 'private/templates/' . rawurlencode($template_id),
    780       ['json' => $patch_body]
    781     ));
    782     if ($r->isOk()) {
    783       $this->logger->info('Updated template @tid', ['@tid' => $template_id]);
    784       return $r;
    785     }
    786     $this->logBackendFailure('update template ' . $template_id, $r);
    787     return $r;
    788   }
    789 
    790 
    791   /**
    792    * Delete the template with the given ID in the merchant backend.
    793    * A 404 from the backend is treated as success, since the desired
    794    * end state is "no such template".
    795    *
    796    * @param string $template_id
    797    *   The full template ID to delete.
    798    * @return TalerBackendResult
    799    *   OK on 204 or 404; otherwise the specific error from the backend.
    800    */
    801   public function deleteTemplate(string $template_id): TalerBackendResult {
    802     $config = \Drupal::config('taler_turnstile.settings');
    803     $backend_url = $config->get('payment_backend_url');
    804     $access_token = $config->get('access_token');
    805     if (empty($backend_url) || empty($access_token)) {
    806       $this->logger->debug('No backend, skipping template delete for @tid', ['@tid' => $template_id]);
    807       return new TalerBackendResult(TalerBackendErrorKind::NOT_CONFIGURED);
    808     }
    809     $http = $this->client(['Authorization' => 'Bearer ' . $access_token]);
    810     $r = $this->runRequest(fn() => $http->delete(
    811       $backend_url . 'private/templates/' . rawurlencode($template_id)
    812     ));
    813     if ($r->isOk() || $r->kind === TalerBackendErrorKind::NOT_FOUND) {
    814       $this->logger->info('Template @tid removed (HTTP @status)', [
    815         '@tid' => $template_id,
    816         '@status' => $r->httpStatus ?? 0,
    817       ]);
    818       return new TalerBackendResult(TalerBackendErrorKind::OK, httpStatus: $r->httpStatus);
    819     }
    820     $this->logBackendFailure('delete template ' . $template_id, $r);
    821     return $r;
    822   }
    823 
    824 
    825   /**
    826    * Re-publish all known TurnstilePriceCategory templates.
    827    * Useful after settings changes that affect the contents
    828    * of every category template (e.g. subscription_prices).
    829    *
    830    * @return array<string, TalerBackendResult>
    831    *   Map of price-category id => sync result. Empty if categories
    832    *   could not even be loaded.
    833    */
    834   public function syncAllTemplates(): array {
    835     try {
    836       $categories = \Drupal::entityTypeManager()
    837         ->getStorage('taler_turnstile_price_category')
    838         ->loadMultiple();
    839     }
    840     catch (\Exception $e) {
    841       $this->logger->error('Failed to load price categories: @message', ['@message' => $e->getMessage()]);
    842       return [];
    843     }
    844     $results = [];
    845     foreach ($categories as $id => $category) {
    846       $results[$id] = $this->syncTemplate($category);
    847     }
    848     return $results;
    849   }
    850 
    851 
    852   /**
    853    * Look up a paid order with the given session ID and verify that
    854    * it actually pays for the given $website (fulfillment URL) at
    855    * one of the prices listed in @a expected_amounts.
    856    *
    857    * @param string $order_id
    858    *   The order ID claimed by the client.
    859    * @param string $session_id
    860    *   The session ID (paivana_id) the client computed.
    861    * @param string $website
    862    *   The fulfillment URL that the contract must reference.
    863    * @param array $expected_amounts
    864    *   Whitelist of acceptable "currency:amount" strings, typically
    865    *   built from the price category for the node.
    866    * @return array|FALSE
    867    *   On success: ['paid' => TRUE, 'amount' => ...,
    868    *                'subscription_slug' => ?,
    869    *                'subscription_expiration' => ?].
    870    *   FALSE on any failure (also logs). Kept array-or-FALSE rather
    871    *   than TalerBackendResult because the sole caller is the
    872    *   /taler-turnstile/paivana endpoint, which returns its own JSON
    873    *   regardless of which failure mode we hit.
    874    */
    875   public function verifyPaidOrder(string $order_id,
    876                                   string $session_id,
    877                                   string $website,
    878                                   array $expected_amounts) {
    879     $config = \Drupal::config('taler_turnstile.settings');
    880     $backend_url = $config->get('payment_backend_url');
    881     $access_token = $config->get('access_token');
    882     if (empty($backend_url) || empty($access_token)) {
    883       $this->logger->debug('No backend configured, cannot verify order');
    884       return FALSE;
    885     }
    886     $http = $this->client(['Authorization' => 'Bearer ' . $access_token]);
    887     $url = $backend_url . 'private/orders/' . rawurlencode($order_id)
    888          . '?session_id=' . rawurlencode($session_id);
    889     $r = $this->runRequest(fn() => $http->get($url));
    890     if (!$r->isOk()) {
    891       $this->logBackendFailure('verify order ' . $order_id, $r);
    892       return FALSE;
    893     }
    894     $jbody = $r->data;
    895     if (($jbody['order_status'] ?? '') !== 'paid') {
    896       $this->logger->info('Order @oid not (yet) paid, status=@s', [
    897         '@oid' => $order_id,
    898         '@s' => $jbody['order_status'] ?? 'unknown',
    899       ]);
    900       return FALSE;
    901     }
    902     $contract_terms = $jbody['contract_terms'] ?? [];
    903     $contract_fulfillment = $contract_terms['fulfillment_url'] ?? '';
    904     if ($contract_fulfillment !== $website) {
    905       $this->logger->warning('Paid order @oid is for fulfillment URL "@got" but client claimed "@want"', [
    906         '@oid' => $order_id,
    907         '@got' => $contract_fulfillment,
    908         '@want' => $website,
    909       ]);
    910       return FALSE;
    911     }
    912     // Pull out the paid amount. Contract version 1 stores choices.
    913     $contract_version = $contract_terms['version'] ?? 0;
    914     $paid_amount = NULL;
    915     $subscription_slug = FALSE;
    916     $subscription_expiration = 0;
    917     if (1 === $contract_version) {
    918       $choice_index = $jbody['choice_index'] ?? 0;
    919       $contract_choice = $contract_terms['choices'][$choice_index] ?? [];
    920       $paid_amount = $contract_choice['amount'] ?? NULL;
    921       // Detect any subscription tokens generated by this purchase.
    922       $token_families = $contract_terms['token_families'] ?? [];
    923       $outputs = $contract_choice['outputs'] ?? [];
    924       $now = time();
    925       foreach ($outputs as $output) {
    926         $slug = $output['token_family_slug'] ?? NULL;
    927         if (!$slug || !isset($token_families[$slug])) {
    928           continue;
    929         }
    930         $token_family = $token_families[$slug];
    931         if (($token_family['details']['class'] ?? NULL) !== 'subscription') {
    932           continue;
    933         }
    934         foreach ($token_family['keys'] ?? [] as $key) {
    935           $start = $key['signature_validity_start']['t_s'] ?? 0;
    936           $end = $key['signature_validity_end']['t_s'] ?? 0;
    937           if (($start <= $now) && ($end > $now)) {
    938             $subscription_slug = $slug;
    939             $subscription_expiration = $end;
    940             break 2;
    941           }
    942         }
    943       }
    944     }
    945     else {
    946       $this->logger->error('Unsupported contract version @v for order @oid', [
    947         '@v' => $contract_version,
    948         '@oid' => $order_id,
    949       ]);
    950       return FALSE;
    951     }
    952     if ($paid_amount === NULL) {
    953       $this->logger->error('Could not determine paid amount for order @oid', ['@oid' => $order_id]);
    954       return FALSE;
    955     }
    956     if (!in_array($paid_amount, $expected_amounts, TRUE)) {
    957       $this->logger->warning('Paid order @oid has amount @got which is not among acceptable amounts (@want) for fulfillment @url', [
    958         '@oid' => $order_id,
    959         '@got' => $paid_amount,
    960         '@want' => implode(', ', $expected_amounts),
    961         '@url' => $website,
    962       ]);
    963       return FALSE;
    964     }
    965     return [
    966       'paid' => TRUE,
    967       'amount' => $paid_amount,
    968       'subscription_slug' => $subscription_slug,
    969       'subscription_expiration' => $subscription_expiration,
    970     ];
    971   }
    972 
    973 
    974   /**
    975    * Build a translation map for all enabled languages.
    976    *
    977    * @param string $string
    978    *   The translatable string.
    979    * @param array $args
    980    *   Placeholder replacements.
    981    *
    982    * @return array
    983    *   Map of language codes to translated strings.
    984    */
    985   private function buildTranslationMap(string $string, array $args = []): array {
    986     $translations = [];
    987     $language_manager = \Drupal::languageManager();
    988 
    989     foreach ($language_manager->getLanguages() as $langcode => $language) {
    990       $translation = $this->t($string, $args, [
    991         'langcode' => $langcode,
    992       ]);
    993       $translations[$langcode] = (string) $translation;
    994     }
    995     return $translations;
    996   }
    997 
    998 
    999 }