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:
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;