TurnstilePriceCategory.php (12872B)
1 <?php 2 3 /** 4 * @file 5 * Price category structure for the GNU Taler Turnstile module. 6 */ 7 8 namespace Drupal\taler_turnstile\Entity; 9 10 use Drupal\Core\Config\Entity\ConfigEntityBase; 11 use Drupal\Core\Entity\EntityStorageInterface; 12 use Drupal\Core\StringTranslation\StringTranslationTrait; 13 use Drupal\taler_turnstile\Amount; 14 use Drupal\taler_turnstile\TalerBackendResult; 15 16 /** 17 * Defines the Price Category entity. 18 * 19 * @ConfigEntityType( 20 * id = "taler_turnstile_price_category", 21 * label = @Translation("Price Category"), 22 * handlers = { 23 * "list_builder" = "Drupal\taler_turnstile\PriceCategoryListBuilder", 24 * "form" = { 25 * "add" = "Drupal\taler_turnstile\Form\PriceCategoryForm", 26 * "edit" = "Drupal\taler_turnstile\Form\PriceCategoryForm", 27 * "delete" = "Drupal\taler_turnstile\Form\PriceCategoryDeleteForm" 28 * } 29 * }, 30 * config_prefix = "taler_turnstile_price_category", 31 * admin_permission = "administer price categories", 32 * entity_keys = { 33 * "id" = "id", 34 * "label" = "label" 35 * }, 36 * links = { 37 * "collection" = "/admin/structure/taler-turnstile-price-categories", 38 * "edit-form" = "/admin/structure/taler-turnstile-price-categories/{price_category}/edit", 39 * "delete-form" = "/admin/structure/taler-turnstile-price-categories/{price_category}/delete" 40 * }, 41 * config_export = { 42 * "id", 43 * "label", 44 * "description", 45 * "prices" 46 * } 47 * ) 48 */ 49 class TurnstilePriceCategory extends ConfigEntityBase { 50 51 /** 52 * For i18n, gives us the t() function. 53 */ 54 use StringTranslationTrait; 55 56 /** 57 * The price category ID. 58 * 59 * @var string 60 */ 61 protected $id; 62 63 /** 64 * The price category label. 65 * 66 * @var string 67 */ 68 protected $label; 69 70 /** 71 * The price category description. 72 * 73 * @var string 74 */ 75 protected $description; 76 77 /** 78 * The prices array. 79 * 80 * Structure: ['subscription_id' => ['currency_code' => 'price']] 81 * 82 * @var array 83 */ 84 protected $prices = []; 85 86 /** 87 * Result of the most recent merchant-backend sync triggered by 88 * postSave() / postDelete() on this entity instance. NULL if no 89 * sync has been attempted yet. Read by PriceCategoryForm::save() 90 * and PriceCategoryDeleteForm::submitForm() to surface backend 91 * failures that would otherwise be silently logged. 92 * 93 * Not persisted; this property is intentionally not in 94 * config_export and not in the schema. 95 * 96 * @var \Drupal\taler_turnstile\TalerBackendResult|null 97 */ 98 public ?TalerBackendResult $lastSyncResult = NULL; 99 100 /** 101 * Gets the description. 102 * 103 * @return string 104 * The description. 105 */ 106 public function getDescription() { 107 return $this->description; 108 } 109 110 /** 111 * Compute the merchant template ID used to publish the prices 112 * of this category in the merchant backend. Kept short and 113 * reasonably distinctive (per the README, "turnstile-$NAME"). 114 * 115 * @return string 116 * Template ID 117 */ 118 public function getTemplateId(): string { 119 return 'turnstile-' . $this->id(); 120 } 121 122 123 /** 124 * Return the set of "currency:amount" strings that match valid 125 * payment amounts for this category. Used to verify that a paid 126 * contract actually paid for the right thing. 127 * 128 * @param array $subscriptions 129 * Live subscription data, as produced by 130 * TalerMerchantApiService::getSubscriptions(). 131 * @return array<string> 132 */ 133 public function getAcceptableAmounts(array $subscriptions): array { 134 $amounts = []; 135 foreach ($this->getPaymentChoices($subscriptions) as $choice) { 136 if (isset($choice['amount'])) { 137 $amounts[] = $choice['amount']; 138 } 139 } 140 return $amounts; 141 } 142 143 144 /** 145 * Gets a brief hint to display about non-subscriber prices. 146 * 147 * @return string 148 * A hint about the price 149 */ 150 public function getPriceHint() { 151 $prices = $this->getPrices(); 152 $nosub = $prices['%none%']; 153 $rval = NULL; 154 foreach ($nosub as $currency => $price) { 155 if (NULL === $rval) 156 { 157 $rval = "$price $currency"; 158 } 159 else 160 { 161 $rval = "$price $currency, " . $rval; 162 } 163 } 164 return $rval ?? $this->t(/* Need to buy a subscription */ 'Not sold individually'); 165 } 166 167 /** 168 * Gets a brief hint to display about applicable subscriptions. 169 * 170 * @return string 171 * A hint about subscriptions 172 */ 173 public function getSubscriptionHint() { 174 $prices = $this->getPrices(); 175 $rval = NULL; 176 foreach ($prices as $subscription => $details) { 177 if ('%none%' === $subscription) 178 continue; 179 if (NULL === $rval) 180 { 181 $rval = $subscription; 182 } 183 else 184 { 185 $rval = $subscription . ", " . $rval; 186 } 187 } 188 return $rval ?? $this->t(/* No subscriptions */ 'None'); 189 } 190 191 /** 192 * Gets all prices. 193 * 194 * @return array 195 * The prices array. 196 */ 197 public function getPrices() { 198 return $this->prices ?: []; 199 } 200 201 /** 202 * Return the different subscriptions that this price category 203 * has for which the resulting payment amount is zero (thus, 204 * exlude subscriptions that would merely yield a discount). 205 * 206 * @return array 207 * The names (slugs) of the subscriptions 208 * that for this price category would yield a price of zero; 209 * note that empty prices do NOT count as zero but infinity! 210 */ 211 public function getFullSubscriptions(): array { 212 $subscriptions = []; 213 foreach ($this->getPrices() as $tokenFamilySlug => $currencyMap) { 214 foreach ($currencyMap as $currencyCode => $price) { 215 if ( (is_numeric ($price)) && 216 (0.0 == (float) $price) ) { 217 $subscriptions[] = $tokenFamilySlug; 218 break; 219 } 220 } 221 } 222 return $subscriptions; 223 } 224 225 /** 226 * Return the different payment choices in a way suitable 227 * for GNU Taler v1 contracts. 228 * 229 * @param array $expirations 230 * Times when each possible subscription type expires. 231 * @return array 232 * Structure suitable for the choices array in the v1 contract 233 */ 234 public function getPaymentChoices(array $subscriptions): array { 235 $max_cache = time() + 3600; 236 $choices = []; 237 238 foreach ($this->getPrices() as $tokenFamilySlug => $currencyMap) { 239 if ("%none%" !== $tokenFamilySlug) { 240 $subscription = $subscriptions[$tokenFamilySlug] ?? NULL; 241 if (NULL === $subscription) { 242 // Slug not known to the backend right now (backend 243 // unreachable, slug renamed, or token family deleted). 244 // Skip rather than emitting a warning and treating it as 245 // expired. 246 \Drupal::logger('taler_turnstile')->info('Subscription category @slug not advertised by backend, skipping it.', ['@slug' => $tokenFamilySlug]); 247 continue; 248 } 249 $expi = $subscription['valid_before_s'] ?? 0; 250 if ($expi < time()) { 251 \Drupal::logger('taler_turnstile')->info('Subscription category @slug expired at @expire, skipping it.', ['@slug' => $tokenFamilySlug, '@expire' => $expi]); 252 continue; // already expired 253 } 254 $max_cache = min ($max_cache, 255 $expi); 256 } 257 foreach ($currencyMap as $currencyCode => $price) { 258 $inputs = []; 259 // All amounts go through Amount::fromNumeric / Amount::add to 260 // avoid float-precision artefacts (0.1 + 0.2 = 0.30000…04) 261 // and to canonicalize the wire form across all three branches. 262 $pay_amount = Amount::fromNumeric($currencyCode, (string) $price); 263 if ("%none%" !== $tokenFamilySlug) 264 { 265 $inputs[] = [ 266 'type' => 'token', 267 'token_family_slug' => $tokenFamilySlug, 268 'count' => 1, 269 ]; 270 $description = $this->t('Pay in @currency with subscription', [ 271 '@currency' => $currencyCode, 272 ], [ 273 'langcode' => 'en', // force English version here! 274 ]); 275 $description_i18n = $this->buildTranslationMap ( 276 'Pay in @currency with subscription', 277 ['@currency' => $currencyCode] 278 ); 279 $choices[] = [ 280 'amount' => (string) $pay_amount, 281 'description' => 'Pay in ' . $currencyCode . ' with subscription', 282 'description_i18n' => $description_i18n, 283 'inputs' => $inputs, 284 ]; 285 $subscription_price = $this->getSubscriptionPrice ($tokenFamilySlug, $currencyCode); 286 if ($subscription_price !== NULL) { 287 // This subscription can be bought: charge the article 288 // price plus the subscription's purchase price, computed 289 // as a discrete integer addition. 290 $buy_amount = $pay_amount->add( 291 Amount::fromNumeric($currencyCode, (string) $subscription_price)); 292 $outputs = []; 293 $outputs[] = [ 294 'type' => 'token', 295 'token_family_slug' => $tokenFamilySlug, 296 'count' => 1, 297 ]; 298 $description = $this->t('Buy subscription in @currency', [ 299 '@currency' => $currencyCode, 300 ]); 301 $description_i18n = $this->buildTranslationMap ( 302 'Buy subscription in @currency', 303 ['@currency' => $currencyCode] 304 ); 305 $choices[] = [ 306 'amount' => (string) $buy_amount, 307 'description' => $description, 308 'description_i18n' => $description_i18n, 309 'outputs' => $outputs, 310 ]; 311 } 312 } 313 else // case for no subscription 314 { 315 $description = $this->t('Pay in @currency', [ 316 '@currency' => $currencyCode, 317 ]); 318 $description_i18n = $this->buildTranslationMap ( 319 'Pay in @currency', 320 ['@currency' => $currencyCode] 321 ); 322 $choices[] = [ 323 'amount' => (string) $pay_amount, 324 'description' => $description, 325 'description_i18n' => $description_i18n, 326 'inputs' => $inputs, 327 ]; 328 } 329 } // for each possible currency 330 } // for each type of subscription 331 332 return $choices; 333 } 334 335 /** 336 * Sets the prices array. 337 * 338 * @param array $prices 339 * The prices array. 340 * 341 * @return $this 342 */ 343 public function setPrices(array $prices) { 344 $this->prices = $prices; 345 return $this; 346 } 347 348 /** 349 * Determine the price of the given type of subscription 350 * in the given currency. 351 * 352 * @param string $tokenFamilySlug 353 * The slug of the token family 354 * @param string $currencyCode 355 * Currency code in which a price quote is desired 356 * 357 * @return string|null 358 * The subscription price (will map to a float), NULL on error 359 */ 360 private function getSubscriptionPrice (string $tokenFamilySlug, string $currencyCode) { 361 $config = \Drupal::config('taler_turnstile.settings'); 362 $subscriptions_prices = $config->get('subscription_prices') ?? []; 363 $subscription_prices = $subscriptions_prices[$tokenFamilySlug] ?? []; 364 $subscription_price = $subscription_prices[$currencyCode] ?? NULL; 365 return $subscription_price; 366 } 367 368 369 /** 370 * {@inheritdoc} 371 * 372 * Mirror this category as a "paivana"-style template in the 373 * merchant backend on every save (create or update). 374 */ 375 public function postSave(EntityStorageInterface $storage, $update = TRUE) { 376 parent::postSave($storage, $update); 377 /** @var \Drupal\taler_turnstile\TalerMerchantApiService $api */ 378 $api = \Drupal::service('taler_turnstile.api_service'); 379 $this->lastSyncResult = $api->syncTemplate($this); 380 } 381 382 383 /** 384 * {@inheritdoc} 385 * 386 * Remove the matching template from the merchant backend when 387 * this category goes away. 388 */ 389 public static function postDelete(EntityStorageInterface $storage, array $entities) { 390 parent::postDelete($storage, $entities); 391 /** @var \Drupal\taler_turnstile\TalerMerchantApiService $api */ 392 $api = \Drupal::service('taler_turnstile.api_service'); 393 foreach ($entities as $entity) { 394 /** @var TurnstilePriceCategory $entity */ 395 $entity->lastSyncResult = $api->deleteTemplate($entity->getTemplateId()); 396 } 397 } 398 399 400 /** 401 * Build a translation map for all enabled languages. 402 * 403 * @param string $string 404 * The translatable string. 405 * @param array $args 406 * Placeholder replacements. 407 * 408 * @return array 409 * Map of language codes to translated strings. 410 */ 411 private function buildTranslationMap(string $string, array $args = []): array { 412 $translations = []; 413 $language_manager = \Drupal::languageManager(); 414 415 foreach ($language_manager->getLanguages() as $langcode => $language) { 416 $translation = $this->t($string, $args, [ 417 'langcode' => $langcode, 418 ]); 419 $translations[$langcode] = (string) $translation; 420 } 421 return $translations; 422 } 423 424 }