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