turnstile

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

commit a50091f935e1eddea55955e6f02fd2825284d0e1
parent 277884b5790716ad5458043085c99da8bc45aee3
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat, 18 Oct 2025 21:38:37 +0200

hack on subscription support (untested)

Diffstat:
Msrc/Entity/TurnstilePriceCategory.php | 180+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/Form/PriceCategoryForm.php | 3+--
Msrc/Form/TurnstileSettingsForm.php | 5+++--
Msrc/TalerMerchantApiService.php | 249++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
4 files changed, 307 insertions(+), 130 deletions(-)

diff --git a/src/Entity/TurnstilePriceCategory.php b/src/Entity/TurnstilePriceCategory.php @@ -95,14 +95,44 @@ class TurnstilePriceCategory extends ConfigEntityBase { } /** + * Return the different subscriptions that this price category + * has for which the resulting payment amount is zero (thus, + * exlude subscriptions that would merely yield a discount). + * + * @return array + * The names (slugs) of the subscriptions + * that for this price category would yield a price of zero; + * note that empty prices do NOT count as zero but infinity! + */ + public function getFullSubscriptions(): array { + $subscriptions = []; + foreach ($this->getPrices() as $tokenFamilySlug => $currencyMap) { + foreach ($currencyMap as $currencyCode => $price) { + if ( (is_numeric ($price)) && + (0.0 == (float) $price) ) { + $subscriptions[] = $tokenFamilySlug; + break; + } + } + } + return $subscriptions; + } + + /** * Return the different payment choices in a way suitable * for GNU Taler v1 contracts. * - * @return structure suitable for the choices array in the v1 contract + * @return array + * Structure suitable for the choices array in the v1 contract */ - public function getPaymentChoices() { + public function getPaymentChoices(): array { + $cid = 'turnstile:payment_choices:' . $this->id(); + if ($cache = \Drupal::cache()->get($cid)) { + return $cache->data; + } + $choices = []; - foreach ($this->prices as $tokenFamilySlug => $currencyMap) { + foreach ($this->getPrices() as $tokenFamilySlug => $currencyMap) { foreach ($currencyMap as $currencyCode => $price) { $inputs = []; if ("%none%" !== $tokenFamilySlug) @@ -112,42 +142,77 @@ class TurnstilePriceCategory extends ConfigEntityBase { 'token_family_slug' => $tokenFamilySlug, 'count' => 1, ]; - $description = 'Pay in ' . $currencyCode . 'with subscription'; - // FIXME: i18n!? - $description_i18n = NULL; + $description = $this->t('Pay in @currency with subscription', [ + '@currency' => $currencyCode, + ]); + $description_i18n = $this->buildTranslationMap ( + 'Pay in @currency with subscription', + ['@currency' => $currencyCode] + ); + $choices[] = [ + 'amount' => $currencyCode . ':' . $price, + 'description' => 'Pay in ' . $currencyCode . ' with subscription', + // 'description_i18n' => $description_i18n, + 'inputs' => $inputs, + ]; + $subscription_price = $this->getSubscriptionPrice ($tokenFamilySlug, $currencyCode); + if ($subscription_price !== NULL) { + // This subscription can be bought. + $outputs = []; + $outputs[] = [ + 'type' => 'token', + 'token_family_slug' => $tokenFamilySlug, + 'count' => 1, + ]; + $description = $this->t('Buy subscription in @currency', [ + '@currency' => $currencyCode, + ]); + $description_i18n = $this->buildTranslationMap ( + 'Buy subscription in @currency', + ['@currency' => $currencyCode] + ); + $choices[] = [ + 'amount' => $currencyCode . ':' . ((float) $subscription_price + + (float) $price), + 'description' => $description, + 'description_i18n' => $description_i18n, + 'outputs' => $outputs, + ]; + } } - else + else // case for no subscription { - $description = 'Pay in ' . $currencyCode; - // FIXME: i18n!? - $description_i18n = NULL; + $description = $this->t('Pay in @currency', [ + '@currency' => $currencyCode, + ]); + $description_i18n = $this->buildTranslationMap ( + 'Pay in @currency', + ['@currency' => $currencyCode] + ); + $choices[] = [ + 'amount' => $currencyCode . ':' . (float) $price, + 'description' => $description, + 'description_i18n' => $description_i18n, + 'inputs' => $inputs, + ]; } - $choices[] = [ - 'amount' => $currencyCode . ':' . $price, - 'description' => $description, - // 'description_i18n' => $description_i18n, - 'inputs' => $inputs, - ]; - // FIXME: lacks choices which buy subscriptions! - } - } - return $choices; - } + } // for each possible currency + } // for each type of subscription - /** - * Gets the price for a specific subscription and currency. - * FIXME: just an example for now, to be removed! - * - * @param string $subscription_id - * The subscription ID. - * @param string $currency_code - * The currency code. - * - * @return string|null - * The price or NULL if not set. - */ - public function getPrice($subscription_id, $currency_code) { - return $this->prices[$subscription_id][$currency_code] ?? NULL; + + // This should return ['config:turnstile_price_category.' . $this->id()]; + $tags = $this->getCacheTags(); + // Invalidate cache if getSubscriptionPrice() changes + $tags[] = 'config:turnstile.settings'; + // Invalidate cache also when translations change + $tags[] = 'locale'; + \Drupal::cache()->set( + $cid, + $choices, + \Drupal\Core\Cache\Cache::PERMANENT, + $tags + ); + return $choices; } /** @@ -163,4 +228,49 @@ class TurnstilePriceCategory extends ConfigEntityBase { return $this; } + /** + * Determine the price of the given type of subscription + * in the given currency. + * + * @param string $tokenFamilySlug + * The slug of the token family + * @param string $currencyCode + * Currency code in which a price quote is desired + * + * @return string|null + * The subscription price (will map to a float), NULL on error + */ + private function getSubscriptionPrice (string $tokenFamilySlug, string $currencyCode) { + $config = \Drupal::config('turnstile.settings'); + $subscriptions_prices = $config->get('subscription_prices') ?? []; + $subscription_prices = $subscriptions_prices[$tokenFamilySlug] ?? []; + $subscription_price = $subscription_prices[$currencyCode] ?? NULL; + return $subscription_price; + } + + + /** + * Build a translation map for all enabled languages. + * + * @param string $string + * The translatable string. + * @param array $args + * Placeholder replacements. + * + * @return array + * Map of language codes to translated strings. + */ + private function buildTranslationMap(string $string, array $args = []): array { + $translations = []; + $language_manager = \Drupal::languageManager(); + + foreach ($language_manager->getLanguages() as $langcode => $language) { + $translation = $this->t($string, $args, [ + 'langcode' => $langcode, + ]); + $translations[$langcode] = (string) $translation; + } + return $translations; + } + } \ No newline at end of file diff --git a/src/Form/PriceCategoryForm.php b/src/Form/PriceCategoryForm.php @@ -93,8 +93,7 @@ class PriceCategoryForm extends EntityForm { $existing_prices = $price_category->getPrices(); - foreach ($subscriptions as $subscription) { - $subscription_id = $subscription['id'] ?? $subscription['name']; + foreach ($subscriptions as $subscription_id => $subscription) { $subscription_label = $subscription['label'] ?? $subscription['name']; $form['prices'][$subscription_id] = [ diff --git a/src/Form/TurnstileSettingsForm.php b/src/Form/TurnstileSettingsForm.php @@ -147,8 +147,7 @@ class TurnstileSettingsForm extends ConfigFormBase { $existing_prices = $config->get('subscription_prices') ?: []; - foreach ($subscriptions as $subscription) { - $subscription_id = $subscription['id'] ?? $subscription['name']; + foreach ($subscriptions as $subscription_id => $subscription) { $subscription_label = $subscription['label'] ?? $subscription['name']; // Skip the %none% case as you can't buy "no subscription" @@ -248,6 +247,7 @@ class TurnstileSettingsForm extends ConfigFormBase { [ 'allow_redirects' => TRUE, 'http_errors' => FALSE, + 'timeout' => 5, // seconds ]); if ( ($response->getStatusCode() !== 200) || (json_decode($response->getBody(), TRUE)['name'] @@ -276,6 +276,7 @@ class TurnstileSettingsForm extends ConfigFormBase { ], 'allow_redirects' => TRUE, 'http_errors' => FALSE, + 'timeout' => 5, // seconds ] ); switch ($response->getStatusCode()) { diff --git a/src/TalerMerchantApiService.php b/src/TalerMerchantApiService.php @@ -13,7 +13,7 @@ use Drupal\Core\Http\ClientFactory; use Drupal\node\NodeInterface; use Psr\Log\LoggerInterface; use Drupal\turnstile\Entity\TurnstilePriceCategory; - +use GuzzleHttp\Exception\RequestException; /** @@ -34,6 +34,16 @@ enum TalerErrorCode: int { class TalerMerchantApiService { /** + * How long are orders valid by default? 24h. + */ + const ORDER_VALIDITY_SECONDS = 86400; + + /** + * How long do we cache /config and token family data from the backend? + */ + const CACHE_BACKEND_DATA_SECONDS = 60; + + /** * The HTTP client factory. * * @var \Drupal\Core\Http\ClientFactory @@ -65,14 +75,22 @@ class TalerMerchantApiService { * entry for "No reduction" with ID "". * * @return array - * Array of subscriptions each with an 'id' (the slug), 'name' and 'label' (again the slug). + * Array mapping token family IDs to subscription data each with a 'name' and 'label' (usually the slug), 'description' and 'description_i18n'. */ public function getSubscriptions() { + $cid = 'turnstile:subscriptions'; + if ($cache = \Drupal::cache()->get($cid)) { + return $cache->data; + } + // Per default, we always have "no subscription" as an option. - $subscriptions = [ - ["id" => "%none%", - "name" => "none", - "label" => "No reduction" ], + $result = []; + // FIXME: implement i18n here! + $result['%none%'] = [ + 'name' => 'none', + 'label' => 'No reduction', + 'description' => 'No subscription', + 'description_i18n' => [], ]; $config = \Drupal::config('turnstile.settings'); $backend_url = $config->get('payment_backend_url'); @@ -81,65 +99,73 @@ class TalerMerchantApiService { if (empty($backend_url) || empty($access_token)) { $this->logger->debug('No Turnstile backend configured, returning "none" for subscriptions.'); - return $subscriptions; + return $result; } + $jbody = []; try { - $http_client = \Drupal::httpClient(); - $response = $http_client->get($backend_url . 'private/tokenfamilies', [ + $http_client = $this->httpClientFactory->fromOptions([ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ], // Do not throw exceptions on 4xx/5xx status codes 'http_errors' => false, + 'timeout' => 5, // seconds ]); - /* Get JSON result parsed as associative array */ + $response = $http_client->get($backend_url . 'private/tokenfamilies'); + // Get JSON result parsed as associative array $http_status = $response->getStatusCode(); $body = $response->getBody(); - $result = json_decode($body, TRUE); + $jbody = json_decode($body, TRUE); switch ($http_status) { case 200: - if (! isset($result['token_families'])) { + if (! isset($jbody['token_families'])) { $this->logger->error('Failed to obtain token family list: HTTP success response unexpectedly lacks "token_families" field.'); - return $subscriptions; + return $result; } - /* Success, handled below */ + // Success, handled below break; case 204: // empty list - return $subscriptions; + return $result; case 403: $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your Turnstile configuration!'); - return $subscriptions; + return $result; case 404: - $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to fetch token family list: @hint (@ec): @body', ['@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); - return $subscriptions; + $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 = json_encode($result, 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' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); - return $subscriptions; + $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 = $result['token_families']; - $transformed = array_map(function ($family) { - return [ - 'id' => $family['slug'], - 'name' => $family['name'], - 'label' => $family['slug'], - ]; - }, $tokenFamilies); - return array_merge ($subscriptions, $transformed); + $tokenFamilies = $jbody['token_families']; + foreach ($tokenFamilies as $family) { + if ($family['kind'] === 'subscription') { + $slug = $family['slug']; + $result[$slug] = [ + 'name' => $family['name'], + 'label' => $slug, + 'description' => $family['description'], + 'description_i18n' => $family['description_i18n'], + ]; + } + }; + \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS); + return $result; } catch (RequestException $e) { - $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to obtain list of token families: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log ?? 'N/A']); + $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']); } - return $subscriptions; + return $result; } + /** * Gets the list of available currencies. * @@ -148,6 +174,10 @@ class TalerMerchantApiService { * and 'step' (typically 0 for JPY or 0.01 for EUR/USD). */ public function getCurrencies() { + $cid = 'turnstile:currencies'; + if ($cache = \Drupal::cache()->get($cid)) { + return $cache->data; + } $config = \Drupal::config('turnstile.settings'); $payment_backend_url = $config->get('payment_backend_url'); @@ -159,15 +189,15 @@ class TalerMerchantApiService { try { // Fetch backend configuration. - $client = \Drupal::httpClient(); - - $config_url = $payment_backend_url . 'config'; - $response = $client->get($config_url, [ + $http_client = $this->httpClientFactory->fromOptions([ 'allow_redirects' => TRUE, 'http_errors' => FALSE, - 'timeout' => 5, + '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 []; @@ -189,17 +219,19 @@ class TalerMerchantApiService { // Parse and validate each amount in the comma-separated list. $currencies = $backend_config['currencies']; - return array_map(function ($currency) { + $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), + 'step' => pow(0.1, $currency['num_fractional_input_digits'] ?? 2), ]; }, $currencies ); + \Drupal::cache()->set($cid, $result, time() + self::CACHE_BACKEND_DATA_SECONDS); + return $result; } catch (\Exception $e) { // On exception, fall back to grant_access_on_error setting. @@ -232,21 +264,24 @@ class TalerMerchantApiService { } try { - $http_client = \Drupal::httpClient(); - $response = $http_client->get($backend_url . 'private/orders/' . $order_id, [ + $http_client = $this->httpClientFactory->fromOptions([ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, ], // Do not throw exceptions on 4xx/5xx status codes 'http_errors' => false, + 'timeout' => 5, // seconds ]); + $response = $http_client->get($backend_url . 'private/orders/' . $order_id); $http_status = $response->getStatusCode(); $body = $response->getBody(); - $result = json_decode($body, TRUE); + $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: @@ -255,19 +290,19 @@ class TalerMerchantApiService { case 404: // Order unknown or instance unknown /** @var TalerErrorCode $ec */ - $ec = TalerErrorCode::tryFrom ($result['code']) ?? TalerErrorCode::TALER_EC_NONE; + $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 = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Invalid response from merchant backend when trying to obtain order status. Check your Turnstile configuration! @body', ['@body' => $body_log ?? 'N/A']); + $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 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 Turnstile configuration!', ['@detail' => $result['detail'] ?? 'N/A']); + $this->logger->error('Configured instance "@detail" unknown to merchant backend. Check your 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 @@ -275,53 +310,82 @@ class TalerMerchantApiService { $this->logger->warning('Order "@order" disappeared in the backend.', ['@order' => $order_id]); return FALSE; default: - $body_log = json_encode($result, 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' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + $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 = json_encode($result, 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' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + $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; } - $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Got existing contract: @body', ['@body' => $body_log ?? 'N/A']); - $order_status = $result['order_status'] ?? 'unknown'; + $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. - $pay_deadline = $result['pay_deadline']['t_s'] ?? - 60 * 60 * 24 + $result['creation_time']['t_s'] ?? 0; + // 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 = $result['contract_terms']; + $contract_terms = $jbody['contract_terms']; $pay_deadline = $contract_terms['pay_deadline']['t_s'] ?? 0; break; case 'paid': $paid = TRUE; - $contract_terms = $result['contract_terms']; - $contract_version = $result['version'] ?? 0; + $contract_terms = $jbody['contract_terms']; + $contract_version = $jbody['version'] ?? 0; + $now = time(); switch ($contract_version) { case 0: + $this->logger->warning('Got unexpected v0 contract version'); break; case 1: - $choice_index = $result['choice_index'] ?? 0; + $choice_index = $jbody['choice_index'] ?? 0; + $token_families = $contract_terms['token_families']; $contract_choice = $contract_terms['choices'][$choice_index]; $outputs = $contract_choice['outputs']; - // FIXME: add logic to detect subscriptions here and - // update $subscription_expiration if one was found! + $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 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 break; default: + $this->logger->error('Got unsupported contract version "@version"', ['@version' => $contract_version]); break; - } // switch on contract version + } // end switch on contract version break; default: $this->logger->error('Got unexpected order status "@status"', ['@status' => $order_status]); @@ -330,6 +394,7 @@ class TalerMerchantApiService { return [ 'order_id' => $order_id, 'paid' => $paid, + 'subscription_slug' => $subscription_slug, 'subscription_expiration' => $subscription_expiration, 'order_expiration' => $pay_deadline, ]; @@ -376,17 +441,17 @@ class TalerMerchantApiService { } $choices = $price_category->getPaymentChoices(); - if (empty ($choices)) { + if (empty($choices)) { $this->logger->debug('Price list is empty, cannot setup new order'); return FALSE; } $fulfillment_url = $node->toUrl('canonical', ['absolute' => TRUE])->toString(); - /* one day from now */ + // FIXME: after Merchant v1.1 we can use the returned // the expiration time and then rely on the default already set in // the merchant backend instead of hard-coding 1 day here! - $order_expiration = time() + 60 * 60 * 24; + $order_expiration = time() + self::ORDER_VALIDITY_SECONDS; $order_data = [ 'order' => [ 'version' => 1, @@ -397,71 +462,73 @@ class TalerMerchantApiService { 't_s' => $order_expiration, ], ], - 'session_id' => session_id (), + 'session_id' => session_id(), 'create_token' => FALSE, ]; + $jbody = []; try { - $http_client = \Drupal::httpClient(); - $response = $http_client->post($backend_url . 'private/orders', [ - 'json' => $order_data, + $http_client = $this->httpClientFactory->fromOptions ([ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ], // Do not throw exceptions on 4xx/5xx status codes 'http_errors' => false, + 'timeout' => 5, // seconds + ]); + $response = $http_client->post($backend_url . 'private/orders', [ + 'json' => $order_data, ]); - /* Get JSON result parsed as associative array */ + // Get JSON result parsed as associative array $http_status = $response->getStatusCode(); $body = $response->getBody(); - $result = json_decode($body, TRUE); + $jbody = json_decode($body, TRUE); switch ($http_status) { case 200: - case 201: /* 201 is not in-spec, but tolerated for now */ - if (! isset($result['order_id'])) { + if (! isset($jbody['order_id'])) { $this->logger->error('Failed to create order: HTTP success response unexpectedly lacks "order_id" field.'); return FALSE; } - /* Success, handled below */ + // Success, handled below break; case 403: $this->logger->warning('Access denied by the merchant backend. Did your credentials change or expire? Check your Turnstile configuration!'); return FALSE; case 404: // FIXME: go into details on why we could get 404 here... - $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to create order: @hint (@ec): @body', ['@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->error('Failed to create order: @hint (@ec): @body', ['@hint' => $jbody['hint'] ?? 'N/A', '@ec' => $jbody['code'] ?? 'N/A', '@body' => $body_log_fmt ?? 'N/A']); return FALSE; case 409: case 410: - /* 409: We didn't specify an order, so this should be "wrong currency", which again Turnstile tries to prevent. So this shouldn't be possible. */ - /* 410: We didn't specify a product, so out-of-stock should also be impossible for Turnstile */ - $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + // 409: We didn't specify an order, so this should be "wrong currency", which again Turnstile tries to prevent. So this shouldn't be possible. + // 410: We didn't specify a product, so out-of-stock should also be impossible for Turnstile + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->error('Unexpected HTTP status code @status trying to create order: @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; case 451: - /* KYC required, can happen, warn */ + // KYC required, can happen, warn $this->logger->warning('Failed to create order as legitimization is required first. Please check legitimization status in your merchant backend.'); return FALSE; default: - $body_log = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Unexpected HTTP status code @status trying to create order: @hint (@detail, #@ec): @body', ['@status' => $http_status, '@hint' => $result['hint'] ?? 'N/A', '@ec' => $result['code'] ?? 'N/A', '@detail' => $result['detail'] ?? 'N/A', '@body' => $body_log ?? 'N/A']); + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->error('Unexpected HTTP status code @status trying to create order: @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; } // end switch on HTTP status - $order_id = $result['order_id']; + $order_id = $jbody['order_id']; return [ - 'order_id' => $result['order_id'], - 'payment_url' => $backend_url . 'orders/' . $result['order_id'], + 'order_id' => $order_id, + 'payment_url' => $backend_url . 'orders/' . $order_id, 'order_expiration' => $order_expiration, 'paid' => FALSE, ]; } catch (RequestException $e) { - $body_log = json_encode($result ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - $this->logger->error('Failed to create Taler order: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log ?? 'N/A']); + $body_log_fmt = json_encode($jbody, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->logger->error('Failed to create Taler order: @message: @body', ['@message' => $e->getMessage(), '@body' => $body_log_fmt ?? 'N/A']); } return FALSE;