turnstile

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

TalerMerchantApiService.php (37225B)


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