turnstile

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

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 }