diff --git a/commerce_recurring_metered.info.yml b/commerce_recurring_metered.info.yml new file mode 100644 index 0000000..b870adc --- /dev/null +++ b/commerce_recurring_metered.info.yml @@ -0,0 +1,9 @@ +name: 'Commerce Recurring Metered Billing' +type: module +description: 'Provides metered usage tracking in Commerce Recurring' +core: 8.x +package: 'Commerce (contrib)' +dependencies: + - commerce_recurring:commerce_recurring +test_dependencies: + - commerce_recurring:commerce_recurring_test diff --git a/commerce_recurring_metered.install b/commerce_recurring_metered.install new file mode 100644 index 0000000..9f584d3 --- /dev/null +++ b/commerce_recurring_metered.install @@ -0,0 +1,64 @@ + 'Tracks subscription usage.', + 'fields' => [ + 'usage_id' => [ + 'description' => 'The primary key.', + 'type' => 'serial', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'usage_type' => [ + 'description' => 'The ID of the usage type plugin providing tracking.', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ], + 'subscription_id' => [ + 'description' => 'The subscription ID.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + 'product_variation_id' => [ + 'description' => 'The product variation ID for this record.', + 'type' => 'int', + 'not null' => TRUE, + ], + 'quantity' => [ + 'description' => 'The usage quantity.', + 'type' => 'int', + 'not null' => TRUE, + 'default' => 0, + ], + 'start' => [ + 'description' => 'The Unix timestamp when usage began.', + 'type' => 'int', + 'not null' => FALSE, + ], + 'end' => [ + 'description' => 'The Unix timestamp when usage ended.', + 'type' => 'int', + 'not null' => FALSE, + ], + ], + 'primary key' => ['usage_id'], + 'indexes' => [ + 'combined' => ['usage_type', 'subscription_id'], + 'timing' => ['start', 'end'], + ], + ]; + + return $schema; +} diff --git a/commerce_recurring_metered.plugin_type.yml b/commerce_recurring_metered.plugin_type.yml new file mode 100644 index 0000000..822ac17 --- /dev/null +++ b/commerce_recurring_metered.plugin_type.yml @@ -0,0 +1,5 @@ +commerce_recurring_metered.usage_type: + label: Commerce usage type + provider: commerce_recurring_metered + plugin_manager_service_id: plugin.manager.commerce_usage_type + plugin_definition_decorator_class: Drupal\plugin\PluginDefinition\ArrayPluginDefinitionDecorator diff --git a/commerce_recurring_metered.services.yml b/commerce_recurring_metered.services.yml new file mode 100644 index 0000000..d89a27a --- /dev/null +++ b/commerce_recurring_metered.services.yml @@ -0,0 +1,10 @@ +services: + plugin.manager.commerce_usage_type: + class: Drupal\commerce_recurring_metered\UsageTypeManager + parent: default_plugin_manager + + commerce_recurring_metered.storage.usage_record: + class: Drupal\commerce_recurring_metered\Usage\UsageRecordStorage + arguments: ['@database', '@entity_type.manager'] + tags: + - { name: backend_overridable } diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9edd03a --- /dev/null +++ b/composer.json @@ -0,0 +1,15 @@ +{ + "name": "drupal/commerce_recurring_metered", + "type": "drupal-module", + "description": "Provides recurring billing for Drupal Commerce.", + "homepage": "http://drupal.org/project/commerce_recurring_metered", + "license": "GPL-2.0+", + "require": { + "drupal/commerce_recurring": "^2.12" + }, + "support": { + "issues": "https://www.drupal.org/project/issues/commerce_recurring_metered", + "source": "http://cgit.drupalcode.org/commerce_recurring_metered" + }, + "minimum-stability": "dev" +} diff --git a/src/Annotation/CommerceRecurringUsageType.php b/src/Annotation/CommerceRecurringUsageType.php new file mode 100644 index 0000000..e1ae598 --- /dev/null +++ b/src/Annotation/CommerceRecurringUsageType.php @@ -0,0 +1,34 @@ +usageTypeManager = $usage_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('plugin.manager.commerce_usage_type') + ); + } + + /** + * {@inheritdoc} + * + * Adds usage charges as well. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public function collectCharges(SubscriptionInterface $subscription, BillingPeriod $billing_period) { + $charges = parent::collectCharges($subscription, $billing_period); + + $start_date = $subscription->getStartDate(); + $end_date = $subscription->getEndDate(); + $billing_type = $subscription->getBillingSchedule()->getBillingType(); + + // Postpaid means we're always charging for the current billing period. + // The October recurring order (ending on Nov 1st) charges for October. + $base_start_date = $billing_period->getStartDate(); + $base_end_date = $billing_period->getEndDate(); + if ($billing_period->contains($start_date)) { + // The subscription started after the billing period (E.g: customer + // subscribed on Mar 10th for a Mar 1st - Apr 1st period). + $base_start_date = $start_date; + } + if ($end_date && $billing_period->contains($end_date)) { + // The subscription ended before the end of the billing period. + $base_end_date = $end_date; + } + $usage_billing_period = new BillingPeriod($base_start_date, $base_end_date); + + // Add charges for metered usage. Metered usage charges are always postpaid. + $usage_types = $this->usageTypeManager->getDefinitions(); + foreach ($usage_types as $id => $definition) { + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\UsageType\UsageTypeInterface $usage_type */ + $usage_type = $this->usageTypeManager->createInstance($id, ['subscription' => $subscription]); + $type_charges = $usage_type->collectCharges($usage_billing_period); + foreach ($type_charges as $type_charge) { + $charges[] = $type_charge; + } + } + + return $charges; + } + +} diff --git a/src/Plugin/Commerce/SubscriptionType/UsageHelperTrait.php b/src/Plugin/Commerce/SubscriptionType/UsageHelperTrait.php new file mode 100644 index 0000000..46eb55e --- /dev/null +++ b/src/Plugin/Commerce/SubscriptionType/UsageHelperTrait.php @@ -0,0 +1,110 @@ +loadUsageType($subscription, $usage_type); + $instance->addUsage([ + 'product_variation' => $usage_variation, + 'quantity' => $quantity, + ], $period->getStartDate(), $period->getEndDate()); + } + + /** + * Loads a plugin that can handle usage tracking. + * + * @param \Drupal\commerce_recurring\Entity\SubscriptionInterface $subscription + * The subscription to add the usage to. + * @param string $usage_type + * The ID of the plugin providing usage tracking. + * + * @return \Drupal\commerce_recurring_metered\Plugin\Commerce\UsageType\UsageTypeInterface + * The instantiated plugin. + */ + public function loadUsageType(SubscriptionInterface $subscription, $usage_type = 'counter'): UsageTypeInterface { + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\UsageType\UsageTypeInterface $instance */ + $instance = $this->usageTypeManager->createInstance($usage_type, ['subscription' => $subscription]); + return $instance; + } + + /** + * Retrieve summarized usage for a particular billing period. + * + * @param \Drupal\commerce_recurring\Entity\SubscriptionInterface $subscription + * The subscription to check for usage. + * @param \Drupal\commerce_recurring\BillingPeriod $period + * The billing period to check for usage. + * + * @return array + * A multi-dimensional array containing summarized usage records + * for the given period. The array structure is: + * + * First level: Usage type plugin ID. + * Second level: Product variation ID (representing the cost of the usage). + * Third level: Quantity. + * + * Example: + * + * 'counter' => [ + * 1 => 150, + * 4 => 200, + * ], + * + * In this example, 150 usages of the product variation with ID 1 and 200 + * usages of the product variation with ID 4 have been recorded. They are + * using the 'counter' plugin. + * + * You cannot necessarily rely on this data for invoice totals. If you have + * to calculate or re-calculate billing for some reason, you should use + * plugin-specific methods. + */ + public function getUsageForPeriod(SubscriptionInterface $subscription, BillingPeriod $period) { + $usage_records = []; + $usage_types = $this->usageTypeManager->getDefinitions(); + foreach ($usage_types as $id => $definition) { + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\UsageType\UsageTypeInterface $usage_type */ + $usage_type = $this->usageTypeManager->createInstance($id, ['subscription' => $subscription]); + $usage_records[$id] = $usage_type->usageHistory($period); + } + + return $usage_records; + } + +} diff --git a/src/Plugin/Commerce/UsageType/Counter.php b/src/Plugin/Commerce/UsageType/Counter.php new file mode 100644 index 0000000..7056335 --- /dev/null +++ b/src/Plugin/Commerce/UsageType/Counter.php @@ -0,0 +1,115 @@ +storage->createRecord(); + $record->setUsageType($this->pluginId) + ->setSubscription($this->subscription) + ->setProductVariation($usage['product_variation']) + ->setStartDate($start) + ->setEndDate($start) + ->setQuantity($usage['quantity']); + + // Counter usage is simple. We set up the record and store it. + $this->storage->setRecords([$record]); + } + + /** + * {@inheritdoc} + */ + public function collectCharges(BillingPeriod $period) { + // Add up all of the counter records, grouping by product variation ID in + // case someone decided to get fancy. + $records = $this->usageHistory($period); + + if (empty($records)) { + return []; + } + + $variations = []; + /** @var \Drupal\commerce_recurring_metered\Usage\UsageRecordInterface $record */ + foreach ($records as $record) { + $variationId = $record->getProductVariation()->id(); + $variations[$variationId] = $record->getProductVariation(); + } + $variationIds = array_unique(array_keys($variations)); + + $quantities = array_fill_keys($variationIds, 0); + foreach ($records as $record) { + $id = $record->getProductVariation()->id(); + $quantities[$id] += $record->getQuantity(); + } + + // Now we have a set of quantities keyed by product. Use the subscription to + // get a free quantity for each. + foreach ($quantities as $variationId => $quantity) { + /** @var \Drupal\commerce_recurring_metered\Usage\SubscriptionFreeUsageInterface $subscription_type */ + $subscription_type = $this->subscription->getType(); + $free_quantity = $subscription_type->getFreeQuantity($variations[$variationId], $this->subscription); + $quantities[$variationId] = max(0, $quantity - $free_quantity); + } + + // Now we generate charges. + $charges = []; + + // @todo: Override the prorater here once https://www.drupal.org/project/commerce_recurring/issues/3037629 is resolved + foreach ($quantities as $variationId => $quantity) { + $charges[] = $this->generateCharge($quantity, $variations[$variationId], $period); + } + + return $charges; + } + +} diff --git a/src/Plugin/Commerce/UsageType/Gauge.php b/src/Plugin/Commerce/UsageType/Gauge.php new file mode 100644 index 0000000..c3da328 --- /dev/null +++ b/src/Plugin/Commerce/UsageType/Gauge.php @@ -0,0 +1,173 @@ +usageHistory($period); + + // Get the length of the cycle in seconds. + $interval = $period->getEndDate() + ->diff($period->getStartDate()); + $period_length = (int) $interval->format('s') + 1; + + // Add up the length of each record. Note that we use usageHistory() here + // (instead of the storage fetch method) because we want the records which + // have already been massaged to start and end with the billing cycle start + // and end timestamps. Otherwise nothing would ever add up. + $record_length = 0; + /** @var \Drupal\commerce_recurring_metered\Usage\UsageRecordInterface $record */ + foreach ($records as $record) { + $record_length += $record->getStart() - $record->getEnd() + 1; + } + + return $period_length === $record_length; + } + + /** + * Figure out charges. + * + * @param \Drupal\commerce_recurring\BillingPeriod $period + * The billing period. + * + * @return array + * The gauge charges. + */ + public function collectCharges(BillingPeriod $period) { + $charges = []; + + $records = $this->usageHistory($period); + /** @var \Drupal\commerce_recurring\Plugin\Commerce\SubscriptionType\SubscriptionTypeInterface|\Drupal\commerce_recurring_metered\Usage\SubscriptionFreeUsageInterface $subscription_type */ + $subscription_type = $this->subscription->getType(); + + // Remove any usage that is free according to the active plan. + foreach ($records as $index => &$record) { + $variation = $record->getProductVariation(); + $free_quantity = $subscription_type->getFreeQuantity($variation, $this->subscription); + $original_quantity = $record->getQuantity(); + if ($original_quantity <= $free_quantity) { + unset($records[$index]); + } + else { + $record->setQuantity($original_quantity - $free_quantity); + + $charges[] = $this->generateCharge( + $record->getQuantity(), + $variation, + new BillingPeriod($record->getStartDate(), $record->getEndDate()) + ); + } + } + + return $charges; + } + + /** + * Add gauge usage. + * + * Gauge usage needs to make sure to move any overlapping records out of the + * way so that even bad code cannot deliberately violate the completeness + * rules. + * + * {@inheritdoc} + */ + public function addUsage($usage, DrupalDateTime $start, DrupalDateTime $end = NULL) { + if (!isset($usage['product_variation'])) { + throw new \InvalidArgumentException("You must specify the + product variation representing this usage (the 'product_variation' key + in the \$usage array."); + } + + if (!isset($usage['quantity'])) { + $usage['quantity'] = 1; + } + + // Find the current billing period. + $order = $this->recurringOrderManager->ensureOrder($this->subscription); + /** @var \Drupal\commerce_recurring_metered\Plugin\Field\FieldType\BillingPeriodItem $field_billing_period */ + $field_billing_period = $order->get('billing_period')->first(); + $period = $field_billing_period->toBillingPeriod(); + + // Get all the raw records for this group and subscription. + $records = $this->storage->fetchPeriodRecords($this->subscription, $period, $this->pluginId, $usage['product_variation']); + + $new_record = $this->storage->createRecord() + ->setUsageType($this->pluginId) + ->setSubscription($this->subscription) + ->setProductVariation($usage['product_variation']) + ->setStartDate($start) + ->setQuantity($usage['quantity']); + + if ($end) { + $new_record->setEndDate($end); + } + + $start_time = $start->getTimestamp(); + $end_time = $end->getTimestamp(); + + // We store arrays of records to update and delete. + $updates = [$new_record]; + $deletions = []; + + foreach ($records as $record) { + // The first thing to do is find all records which overlap the new record + // somehow. + if (($record->getEnd() >= $start_time || $record->getEnd() === NULL) && $record->getStart() < $start_time) { + $record->setEnd($start_time); + $updates[] = $record; + } + + if ($record->getStart() >= $start_time) { + // What else we do to preserve sanity depends on whether this is a + // completed record or not. + if ($end_time === NULL) { + // The new record has no end. That means we merely to need to delete + // all other records which come after it, if any. + $deletions[] = $record; + } + elseif ($record->getEnd() <= $end_time) { + // The new record has a start and end already. We delete records that + // are inside of it. + $deletions[] = $record; + } + elseif ($record->getEnd() > $end_time) { + $record->setStart($end_time + 1); + $updates[] = $record; + } + } + } + + $this->storage->setRecords($updates); + $this->storage->deleteRecords($deletions); + } + +} diff --git a/src/Plugin/Commerce/UsageType/UsageTypeBase.php b/src/Plugin/Commerce/UsageType/UsageTypeBase.php new file mode 100644 index 0000000..0d7f057 --- /dev/null +++ b/src/Plugin/Commerce/UsageType/UsageTypeBase.php @@ -0,0 +1,187 @@ +stringTranslation = $string_translation; + $this->storage = $storage; + $this->recurringOrderManager = $recurring_order_manager; + + if (!isset($configuration['subscription'])) { + throw new \InvalidArgumentException($this->t("You must pass the subscription as part of the plugin configuration (the 'subscription' array key).")); + } + $this->subscription = $configuration['subscription']; + + // We have to make sure that the subscription implements the necessary + // interfaces to work with these usage groups. + foreach ($this->requiredSubscriptionTypeInterfaces() as $interface) { + if (!($this->subscription->getType() instanceof $interface)) { + throw new \LogicException('Usage groups of type ' . static::class . ' can only be attached to subscription types which implement ' . $interface); + } + } + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('string_translation'), + $container->get('commerce_recurring_metered.storage.usage_record'), + $container->get('commerce_recurring.order_manager') + ); + } + + /** + * Get the subscription of this usage type. + * + * @return \Drupal\commerce_recurring\Entity\SubscriptionInterface + * The subscription against which we're operating. + */ + public function getSubscription() { + return $this->subscription; + } + + /** + * The default behavior is for usage groups to not enforce change scheduling. + */ + public function enforceChangeScheduling($property, $oldValue, $newValue) { + return FALSE; + } + + /** + * Whether we have enough usage info. + * + * The default behavior is to regard usage as complete. Usage types with + * remote storage or record completeness requirements override this method. + * + * {@inheritdoc} + */ + public function isComplete(BillingPeriod $period) { + return TRUE; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\commerce_recurring_metered\Usage\UsageRecordInterface[] + * The usage records. + */ + public function usageHistory(BillingPeriod $period) { + // Here we fetch the records from storage, and then massage them to line + // up with the start and end of the billing cycle. + $records = $this->storage->fetchPeriodRecords($this->subscription, $period, $this->getPluginId()); + $periodStart = $period->getStartDate()->getTimestamp(); + $periodEnd = $period->getEndDate()->getTimestamp(); + + foreach ($records as $record) { + if ($record->getStart() < $periodStart) { + $record->setStart($periodStart); + } + $end = $record->getEnd(); + if ($end === NULL || $end > $periodEnd) { + $record->setEnd($periodEnd); + } + } + + return $records; + } + + /** + * {@inheritdoc} + */ + public function onSubscriptionChange(Subscription $subscription) { + + } + + /** + * Helper function to generate a charge object. + * + * This method is intended to be overridden if you need to change things like + * the title of the charge. + * + * @param int $quantity + * How many to charge for. + * @param \Drupal\commerce_product\Entity\ProductVariationInterface $variation + * What to charge for (the product variation used for charging for usage). + * @param \Drupal\commerce_recurring\BillingPeriod $period + * The billing period to which the charge applies. + * + * @return \Drupal\commerce_recurring\Charge + * A \Drupal\commerce_recurring\Charge representing this specific + * variation's usage during this billing period. + */ + protected function generateCharge($quantity, ProductVariationInterface $variation, BillingPeriod $period) { + return new Charge([ + 'title' => $variation->getTitle(), + 'unit_price' => $variation->getPrice(), + 'quantity' => $quantity, + 'billing_period' => $period, + 'purchased_entity' => $variation, + ]); + } + +} diff --git a/src/Plugin/Commerce/UsageType/UsageTypeInterface.php b/src/Plugin/Commerce/UsageType/UsageTypeInterface.php new file mode 100644 index 0000000..a238f22 --- /dev/null +++ b/src/Plugin/Commerce/UsageType/UsageTypeInterface.php @@ -0,0 +1,109 @@ +typeManager = $typeManager; + + $object_map = [ + 'usage_id' => 'usageId', + 'usage_type' => 'usageType', + 'subscription_id' => 'subscriptionId', + 'product_variation_id' => 'productVariationId', + ]; + + if (!isset($values)) { + return; + } + + foreach ($values as $key => $value) { + $target = $key; + + if (isset($object_map[$key])) { + $target = $object_map[$key]; + } + + $this->$target = $value; + } + } + + /** + * Get an array of values for easy insertion via the Drupal database layer. + * + * @return array + * An array of values this record contains. + */ + public function getDatabaseValues() { + return [ + 'usage_id' => $this->usageId, + 'usage_type' => $this->usageType, + 'subscription_id' => $this->subscriptionId, + 'product_variation_id' => $this->productVariationId, + 'quantity' => $this->quantity, + 'start' => $this->start, + 'end' => $this->end, + ]; + } + + /** + * Get the ID of this record in storage. + * + * Used to figure out inserts vs merges in the default database storage + * implementation. + * + * Note: There is no setter for the ID, this can only be set by the storage + * engine. + * + * @return int + * The ID of the usage record. + */ + public function getId() { + return $this->usageId; + } + + /** + * {@inheritdoc} + */ + public function getUsageType() { + return $this->usageType; + } + + /** + * {@inheritdoc} + */ + public function setUsageType($usageType) { + $this->usageType = $usageType; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getSubscription() { + $storage = $this->typeManager->getStorage('commerce_subscription'); + + return $storage->load($this->subscriptionId); + } + + /** + * {@inheritdoc} + */ + public function setSubscription(SubscriptionInterface $subscription) { + $this->subscriptionId = $subscription->id(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getProductVariation() { + $storage = $this->typeManager->getStorage('commerce_product_variation'); + + return $storage->load($this->productVariationId); + } + + /** + * {@inheritdoc} + */ + public function setProductVariation(ProductVariationInterface $variation) { + $this->productVariationId = $variation->id(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getQuantity() { + return (int) $this->quantity; + } + + /** + * {@inheritdoc} + */ + public function setQuantity($quantity) { + $this->quantity = (int) $quantity; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getStartDate() { + return is_null($this->start) ? NULL : new DrupalDateTime("@{$this->start}"); + } + + /** + * {@inheritdoc} + */ + public function setStartDate(DrupalDateTime $start) { + $this->start = $start->getTimestamp(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getStart() { + return $this->start; + } + + /** + * {@inheritdoc} + */ + public function setStart($start) { + $this->start = $start; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getEndDate() { + return $this->end === NULL ? NULL : new DrupalDateTime("@{$this->end}"); + } + + /** + * {@inheritdoc} + */ + public function setEndDate(DrupalDateTime $end) { + $this->end = $end->getTimestamp(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getEnd() { + return $this->end; + } + + /** + * {@inheritdoc} + */ + public function setEnd($end) { + $this->end = $end; + + return $this; + } + +} diff --git a/src/Usage/UsageRecordInterface.php b/src/Usage/UsageRecordInterface.php new file mode 100644 index 0000000..e2d4638 --- /dev/null +++ b/src/Usage/UsageRecordInterface.php @@ -0,0 +1,163 @@ +connection = $connection; + $this->typeManager = $type_manager; + if (isset($recordClass)) { + $this->recordClass = $recordClass; + } + } + + /** + * {@inheritdoc} + */ + public function fetchPeriodRecords(SubscriptionInterface $subscription, BillingPeriod $period, $usage_type = 'counter', ProductVariationInterface $variation = NULL) { + // Usage type needs to be set. Complain if they've passed in a non-string. + if (!is_string($usage_type)) { + throw new \InvalidArgumentException('$usage_type must be a string.'); + } + + $query = $this->connection->select($this->tableName); + $query->fields($this->tableName); + $query->condition('usage_type', $usage_type); + if (isset($variation)) { + $query->condition('product_variation_id', $variation->id()); + } + if ($subscription !== NULL) { + $query->condition('subscription_id', $subscription->id()); + } + if ($period !== NULL) { + // To accurately get all records, we need to find any that overlap with + // the time period of the billing period. + $start = $period->getStartDate(); + $end = $period->getEndDate(); + + // Since some usage records have no end, we need to search for any which + // end later than the period's start date or have no end. + $ends = $query->orConditionGroup() + ->condition('end', $start->format('U'), '>') + ->isNull('end'); + + // Combine that with a condition to find those which start earlier than + // the period's end date and we have everything we need. + $timing = $query->andConditionGroup() + ->condition('start', $end->format('U'), '<') + ->condition($ends); + + // Et voila. + $query->condition($timing); + } + + $query->addTag('commerce_recurring_usage'); + + $results = $query->execute(); + + if ($results !== NULL) { + return $this->createFromStorage($results); + } + + return []; + } + + /** + * Factory method for turning raw records (from the database) into objects. + * + * @param \Drupal\Core\Database\StatementInterface $results + * The statement that will fetch the records. + * + * @return \Drupal\commerce_recurring_metered\Usage\UsageRecordInterface[] + * The usage record objects. + */ + public function createFromStorage(StatementInterface $results) { + $records = []; + foreach ($results as $result) { + $records[$result->usage_id] = new $this->recordClass($this->typeManager, $result); + } + + return $records; + } + + /** + * Create a new usage record object shell. + * + * This injects the type manager service so the record can use it to fetch + * stuff. + */ + public function createRecord() { + $recordClass = $this->recordClass; + // Syntax is a pain. + return new $recordClass($this->typeManager); + } + + /** + * Insert a usage record. + * + * @param UsageRecordInterface[] $records + * An array of records to insert or update. + * + * @throws \Exception + */ + public function setRecords(array $records) { + $txn = $this->connection->startTransaction(); + + $inserts = []; + $updates = []; + foreach ($records as $record) { + if ($record->getId()) { + // Records which already have an ID must be updated. + $updates[] = $record->getDatabaseValues(); + } + else { + $inserts[] = $record->getDatabaseValues(); + } + } + + try { + if (!empty($updates)) { + foreach ($updates as $update) { + $count = $this->connection->update($this->tableName) + ->fields($update) + ->condition('usage_id', $update['usage_id']) + ->execute(); + + // The number of rows matched had damn well better be 1. + if ($count !== 1) { + throw new \LogicException("Failed to update usage record $update[usage_id]."); + } + } + } + + if (!empty($inserts)) { + $query = $this->connection->insert($this->tableName); + foreach ($inserts as $insert) { + $query->fields($insert); + } + + $query->execute(); + } + } + catch (\Exception $e) { + // Roll this back. + $txn->rollBack(); + throw $e; + } + } + + /** + * Delete one or more usage records. + * + * @param \Drupal\commerce_recurring_metered\Usage\UsageRecordInterface[] $records + * The usage records to be deleted. + * + * @throws \Exception + */ + public function deleteRecords(array $records) { + $txn = $this->connection->startTransaction(); + + try { + // Delete each record. + foreach ($records as $record) { + if ($record->getId()) { + $this->connection->delete($this->tableName) + ->condition('usage_id', $record->getId()) + ->execute(); + } + } + } + catch (\Exception $e) { + $txn->rollback(); + throw $e; + } + + // We're done. Yay. + } + +} diff --git a/src/Usage/UsageRecordStorageInterface.php b/src/Usage/UsageRecordStorageInterface.php new file mode 100644 index 0000000..73eeceb --- /dev/null +++ b/src/Usage/UsageRecordStorageInterface.php @@ -0,0 +1,58 @@ +alterInfo('commerce_recurring_metered_usage_type_info'); + $this->setCacheBackend($cache_backend, 'commerce_recurring_metered_usage_type_plugins'); + } + + /** + * {@inheritdoc} + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public function processDefinition(&$definition, $plugin_id) { + parent::processDefinition($definition, $plugin_id); + + foreach (['id', 'label'] as $required_property) { + if (empty($definition[$required_property])) { + throw new PluginException(sprintf('The recurring usage type %s must define the %s property.', $plugin_id, $required_property)); + } + } + } + +} diff --git a/tests/modules/commerce_recurring_metered_test/commerce_recurring_metered_test.info.yml b/tests/modules/commerce_recurring_metered_test/commerce_recurring_metered_test.info.yml new file mode 100644 index 0000000..b3c0d3a --- /dev/null +++ b/tests/modules/commerce_recurring_metered_test/commerce_recurring_metered_test.info.yml @@ -0,0 +1,7 @@ +name: 'Commerce Recurring Metered Billing Test' +type: module +description: Provides fixtures for testing metered billing. +package: Testing +core: 8.x +dependencies: + - commerce_recurring_metered:commerce_recurring_metered diff --git a/tests/modules/commerce_recurring_metered_test/src/Plugin/Commerce/SubscriptionType/UsageTestProductVariation.php b/tests/modules/commerce_recurring_metered_test/src/Plugin/Commerce/SubscriptionType/UsageTestProductVariation.php new file mode 100644 index 0000000..6c531e1 --- /dev/null +++ b/tests/modules/commerce_recurring_metered_test/src/Plugin/Commerce/SubscriptionType/UsageTestProductVariation.php @@ -0,0 +1,39 @@ +getSku() === 'variation_300_free') { + return 300; + } + + if ($variation->getSku() === 'variation_5_free') { + return 5; + } + + return 0; + } + +} diff --git a/tests/src/Kernel/UsageTrackingTest.php b/tests/src/Kernel/UsageTrackingTest.php new file mode 100644 index 0000000..5ec2c67 --- /dev/null +++ b/tests/src/Kernel/UsageTrackingTest.php @@ -0,0 +1,705 @@ + 'default', + 'title' => $this->randomMachineName(), + 'sku' => strtolower($this->randomMachineName()), + 'price' => [ + 'number' => '0.05', + 'currency_code' => 'USD', + ], + ]); + $counter_variation->save(); + $this->counterVariation = $this->reloadEntity($counter_variation); + + $cv_free_quantity = ProductVariation::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'sku' => 'variation_300_free', + 'price' => [ + 'number' => '0.05', + 'currency_code' => 'USD', + ], + ]); + $cv_free_quantity->save(); + + // + // + // POSTPAID + // + // . + $added_subscription = OrderItem::create([ + 'type' => 'default', + 'purchased_entity' => $this->usageSubscriptionVariation, + 'unit_price' => [ + 'number' => '0.00', + 'currency_code' => 'USD', + ], + 'quantity' => '1', + ]); + $added_subscription->save(); + $initial_order = Order::create([ + 'type' => 'default', + 'store_id' => $this->store, + 'uid' => $this->user, + 'order_items' => [$added_subscription], + 'state' => 'draft', + 'payment_method' => $this->paymentMethod, + ]); + $initial_order->save(); + + $workflow = $initial_order->getState()->getWorkflow(); + $initial_order->getState() + ->applyTransition($workflow->getTransition('place')); + $initial_order->save(); + + $subscriptions = Subscription::loadMultiple(); + + /** @var \Drupal\commerce_recurring\Entity\SubscriptionInterface $subscription */ + $subscription = reset($subscriptions); + + // Increase counter usage by 1. + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\SubscriptionType\UsageHelperTrait $subscription_type */ + $subscription_type = $subscription->getType(); + $subscription_type->addUsage($subscription, $counter_variation); + + /** @var \Drupal\commerce_order\Entity\Order $order */ + $order = $this->recurringOrderManager->ensureOrder($subscription); + + /** @var \Drupal\commerce_recurring\Plugin\Field\FieldType\BillingPeriodItem $field_billing_period */ + $field_billing_period = $order->get('billing_period')->first(); + $usage = $subscription_type->getUsageForPeriod($subscription, $field_billing_period->toBillingPeriod()); + $latest_usage = end($usage['counter']); + self::assertEquals($latest_usage->getQuantity(), 1, 'One counter usage was recorded.'); + + $items = $order->getItems(); + self::assertCount(2, $items, 'Order has two items (subscription and added usage).'); + + // Make it 300 total. + $subscription_type->addUsage($subscription, $counter_variation, 299); + $this->recurringOrderManager->refreshOrder($order); + + $items = $order->getItems(); + self::assertCount(2, $items, 'Order still has two items (subscription and combined usage).'); + + // @codingStandardsIgnoreStart + // @todo: Uncomment these assertions when https://www.drupal.org/project/commerce_recurring/issues/3037629 is resolved and we can guarantee the correct value. For now, we only work with rolling billing schedule intervals. + // /** @var \Drupal\commerce_order\Entity\OrderItem $usage_item */ + // $usage_item = end($items); + // self::assertEquals(new Price('15', 'USD'), $usage_item->getTotalPrice(), 'Usage total is 15 USD.'); + // self::assertEquals(new Price('15', 'USD'), $order->getTotalPrice(), 'Order total is 15 USD.'); + // + // // Add 301 of the counter variation with 300 free. This should increase the + // // price by 0.05. + // $subscription_type->addUsage($subscription, $cv_free_quantity, 301); + // $this->recurringOrderManager->refreshOrder($order); + // self::assertEquals(new Price('15.05', 'USD'), $order->getTotalPrice(), 'Counter usage type: Free quantity works.'); + // @codingStandardsIgnoreEnd + + // + // + // POSTPAID (ROLLING) + // + // . + $added_subscription = OrderItem::create([ + 'type' => 'default', + 'purchased_entity' => $this->rollingUsageSubscriptionVariation, + 'unit_price' => [ + 'number' => '0.00', + 'currency_code' => 'USD', + ], + 'quantity' => '1', + ]); + $added_subscription->save(); + $initial_order = Order::create([ + 'type' => 'default', + 'store_id' => $this->store, + 'uid' => $this->user, + 'order_items' => [$added_subscription], + 'state' => 'draft', + 'payment_method' => $this->paymentMethod, + ]); + $initial_order->save(); + + $workflow = $initial_order->getState()->getWorkflow(); + $initial_order->getState() + ->applyTransition($workflow->getTransition('place')); + $initial_order->save(); + + $subscriptions = Subscription::loadMultiple(); + + /** @var \Drupal\commerce_recurring\Entity\SubscriptionInterface $subscription */ + $subscription = end($subscriptions); + + // Increase counter usage by 1. + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\SubscriptionType\UsageHelperTrait $subscription_type */ + $subscription_type = $subscription->getType(); + $subscription_type->addUsage($subscription, $counter_variation); + + /** @var \Drupal\commerce_order\Entity\Order $order */ + $order = $this->recurringOrderManager->ensureOrder($subscription); + + /** @var \Drupal\commerce_recurring\Plugin\Field\FieldType\BillingPeriodItem $field_billing_period */ + $field_billing_period = $order->get('billing_period')->first(); + $usage = $subscription_type->getUsageForPeriod($subscription, $field_billing_period->toBillingPeriod()); + $latest_usage = end($usage['counter']); + self::assertEquals($latest_usage->getQuantity(), 1, 'Postpaid rolling: One counter usage was recorded.'); + + $items = $order->getItems(); + self::assertCount(2, $items, 'Postpaid rolling: Order has two items (subscription and added usage).'); + + // Make it 300 total. + $subscription_type->addUsage($subscription, $counter_variation, 299); + $this->recurringOrderManager->refreshOrder($order); + + $items = $order->getItems(); + self::assertCount(2, $items, 'Postpaid rolling: Order still has two items (subscription and combined usage).'); + + /** @var \Drupal\commerce_order\Entity\OrderItem $usage_item */ + $usage_item = end($items); + self::assertEquals(new Price('15', 'USD'), $usage_item->getTotalPrice(), 'Usage total is 15 USD.'); + self::assertEquals(new Price('15', 'USD'), $order->getTotalPrice(), 'Order total is 15 USD.'); + + // Add 301 of the counter variation with 300 free. This should increase the + // price by 0.05. + $subscription_type->addUsage($subscription, $cv_free_quantity, 301); + $this->recurringOrderManager->refreshOrder($order); + self::assertEquals(new Price('15.05', 'USD'), $order->getTotalPrice(), 'Postpaid rolling: Counter usage type: Free quantity works.'); + + // + // + // PREPAID + // + // . + $added_prepaid_subscription = OrderItem::create([ + 'type' => 'default', + 'purchased_entity' => $this->prepaidUsageSubscription, + 'unit_price' => [ + 'number' => '5.00', + 'currency_code' => 'USD', + ], + 'quantity' => '1', + ]); + $added_prepaid_subscription->save(); + $prepaid_order = Order::create([ + 'type' => 'default', + 'store_id' => $this->store, + 'uid' => $this->user, + 'order_items' => [$added_prepaid_subscription], + 'state' => 'draft', + 'payment_method' => $this->paymentMethod, + ]); + $prepaid_order->save(); + + $prepaid_workflow = $prepaid_order->getState()->getWorkflow(); + $prepaid_order->getState() + ->applyTransition($prepaid_workflow->getTransition('place')); + $prepaid_order->save(); + + $subscriptions = Subscription::loadMultiple(); + + /** @var \Drupal\commerce_recurring\Entity\SubscriptionInterface $prepaid_subscription */ + $prepaid_subscription = end($subscriptions); + + // Increase counter usage by 1. + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\SubscriptionType\UsageHelperTrait $prepaid_subscription_type */ + $prepaid_subscription_type = $prepaid_subscription->getType(); + $prepaid_subscription_type->addUsage($prepaid_subscription, $counter_variation); + + /** @var \Drupal\commerce_order\Entity\Order $porder */ + $porder = $this->recurringOrderManager->ensureOrder($prepaid_subscription); + + /** @var \Drupal\commerce_recurring\Plugin\Field\FieldType\BillingPeriodItem $pfield_billing_period */ + $pfield_billing_period = $porder->get('billing_period')->first(); + $pusage = $prepaid_subscription_type->getUsageForPeriod($prepaid_subscription, $pfield_billing_period->toBillingPeriod()); + $platest_usage = end($pusage['counter']); + self::assertEquals($platest_usage->getQuantity(), 1, 'One counter usage was recorded.'); + + $items = $porder->getItems(); + self::assertCount(2, $items, 'Order has two items (subscription and added usage).'); + + // Make it 300 total. + $prepaid_subscription_type->addUsage($prepaid_subscription, $counter_variation, 299); + $this->recurringOrderManager->refreshOrder($porder); + + $items = $porder->getItems(); + self::assertCount(2, $items, 'Order still has two items (subscription and combined usage).'); + + /** @var \Drupal\commerce_order\Entity\OrderItem $usage_item */ + $usage_item = end($items); + self::assertEquals($usage_item->getTotalPrice(), new Price('15', 'USD'), 'Usage total is 15 USD.'); + self::assertEquals(new Price('20', 'USD'), $porder->getTotalPrice(), 'Order total is 20 USD.'); + } + + /** + * Test usage tracking with the 'gauge' plugin. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * @throws \Drupal\Component\Plugin\Exception\PluginException + * @throws \Exception + */ + public function testGaugeUsageTracking() { + // + // + // COMMON + // + // . + $gauge_variation = ProductVariation::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'sku' => strtolower($this->randomMachineName()), + 'price' => [ + 'number' => '5', + 'currency_code' => 'USD', + ], + ]); + $gauge_variation->save(); + $this->gaugeVariation = $this->reloadEntity($gauge_variation); + + $gv_free_quantity = ProductVariation::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'sku' => 'variation_5_free', + 'price' => [ + 'number' => '5', + 'currency_code' => 'USD', + ], + ]); + $gv_free_quantity->save(); + + // + // + // POSTPAID + // + // . + // For sanity, mock the current time as the start of the hour. + $fake_now = new \DateTime(); + $fake_now->setTime($fake_now->format('G'), 0); + $this->rewindTime($fake_now->format('U')); + $added_subscription = OrderItem::create([ + 'type' => 'default', + 'purchased_entity' => $this->usageSubscriptionVariation, + 'unit_price' => [ + 'number' => '0.00', + 'currency_code' => 'USD', + ], + 'quantity' => '1', + ]); + $added_subscription->save(); + $initial_order = Order::create([ + 'type' => 'default', + 'store_id' => $this->store, + 'uid' => $this->user, + 'order_items' => [$added_subscription], + 'state' => 'draft', + 'payment_method' => $this->paymentMethod, + ]); + $initial_order->save(); + + $workflow = $initial_order->getState()->getWorkflow(); + $initial_order->getState() + ->applyTransition($workflow->getTransition('place')); + $initial_order->save(); + + $subscriptions = Subscription::loadMultiple(); + + /** @var \Drupal\commerce_recurring\Entity\SubscriptionInterface $subscription */ + $subscription = reset($subscriptions); + /** @var \Drupal\commerce_order\Entity\Order $order */ + $order = $this->recurringOrderManager->ensureOrder($subscription); + $field_billing_period = $order->get('billing_period')->first(); + /** @var \Drupal\commerce_recurring\Plugin\Field\FieldType\BillingPeriodItem $field_billing_period */ + $order_period = $field_billing_period->toBillingPeriod(); + + // Increase gauge usage by 4. Add it for the entire billing period. + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\SubscriptionType\UsageHelperTrait $subscription_type */ + $subscription_type = $subscription->getType(); + $subscription_type->addUsage($subscription, $gauge_variation, 4, $order_period, 'gauge'); + + $usage = $subscription_type->getUsageForPeriod($subscription, $order_period); + $latest_usage = end($usage['gauge']); + self::assertEquals($latest_usage->getQuantity(), 4, 'Four gauge usages were recorded.'); + + $this->recurringOrderManager->refreshOrder($order); + $items = $order->getItems(); + self::assertCount(2, $items, 'Order has two items (subscription and added usage).'); + + // Check that overlaps of the same variation get resolved. + // \DateTimePlus::add() was not adding, so converting back and forth. + $halfway = $order_period->getStartDate() + ->getPhpDateTime() + ->add(new \DateInterval('PT30M')); + $half_period = new BillingPeriod($order_period->getStartDate(), DrupalDateTime::createFromDateTime($halfway)); + $subscription_type->addUsage($subscription, $gauge_variation, 4, $half_period, 'gauge'); + $this->recurringOrderManager->refreshOrder($order); + + $items = $order->getItems(); + self::assertCount(3, $items, 'Order has three items (subscription and new usage).'); + $usage = $subscription_type->getUsageForPeriod($subscription, $order_period); + $latest_usage = end($usage['gauge']); + self::assertEquals($latest_usage->getQuantity(), 4, 'Four gauge usages were recorded.'); + + /** @var \Drupal\commerce_order\Entity\OrderItem $usage_item */ + self::assertEquals(new Price('20', 'USD'), $order->getTotalPrice(), 'Order total is 20 USD.'); + + // Test free quantity for gauge usage type. + $subscription_type->addUsage($subscription, $gv_free_quantity, 7, $order_period, 'gauge'); + $this->recurringOrderManager->refreshOrder($order); + // With free quantity applied, the second usage records comes out to two + // usages at $5/period; $20 + $10 = $30. + self::assertEquals(new Price('30', 'USD'), $order->getTotalPrice(), 'Gauge usage type: Free quantity works.'); + + // + // + // POSTPAID (HALF BILLING PERIOD) + // + // . + // For sanity, mock the current time as the middle of the previous hour. + $fake_now = new \DateTime(); + $fake_now->setTime((int) $fake_now->format('G') - 1, 30); + $this->rewindTime($fake_now->format('U')); + $added_subscription = OrderItem::create([ + 'type' => 'default', + 'purchased_entity' => $this->usageSubscriptionVariation, + 'unit_price' => [ + 'number' => '0.00', + 'currency_code' => 'USD', + ], + 'quantity' => '1', + ]); + $added_subscription->save(); + $initial_order = Order::create([ + 'type' => 'default', + 'store_id' => $this->store, + 'uid' => $this->user, + 'order_items' => [$added_subscription], + 'state' => 'draft', + 'payment_method' => $this->paymentMethod, + ]); + $initial_order->save(); + + $workflow = $initial_order->getState()->getWorkflow(); + $initial_order->getState() + ->applyTransition($workflow->getTransition('place')); + $initial_order->save(); + + $subscriptions = Subscription::loadMultiple(); + + /** @var \Drupal\commerce_recurring\Entity\SubscriptionInterface $subscription */ + $subscription = end($subscriptions); + /** @var \Drupal\commerce_order\Entity\Order $order */ + $order = $this->recurringOrderManager->ensureOrder($subscription); + $field_billing_period = $order->get('billing_period')->first(); + /** @var \Drupal\commerce_recurring\Plugin\Field\FieldType\BillingPeriodItem $field_billing_period */ + $order_period = $field_billing_period->toBillingPeriod(); + + // Increase gauge usage by 4. Add it for the entire billing period. + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\SubscriptionType\UsageHelperTrait $subscription_type */ + $subscription_type = $subscription->getType(); + $subscription_type->addUsage($subscription, $gauge_variation, 4, $order_period, 'gauge'); + + $usage = $subscription_type->getUsageForPeriod($subscription, $order_period); + $latest_usage = end($usage['gauge']); + self::assertEquals($latest_usage->getQuantity(), 4, 'Partial billing period: Four gauge usages were recorded.'); + + $this->recurringOrderManager->refreshOrder($order); + $items = $order->getItems(); + self::assertCount(2, $items, 'Partial billing period: Order has two items (subscription and added usage).'); + + // Check that overlaps of the same variation get resolved. + // \DateTimePlus::add() was not adding, so converting back and forth. + $halfway = $order_period->getStartDate() + ->getPhpDateTime() + ->add(new \DateInterval('PT30M')); + $half_period = new BillingPeriod($order_period->getStartDate(), DrupalDateTime::createFromDateTime($halfway)); + $subscription_type->addUsage($subscription, $gauge_variation, 4, $half_period, 'gauge'); + $this->recurringOrderManager->refreshOrder($order); + + $items = $order->getItems(); + // The partial usage was merged because the first billing period _is_ only + // 30 minutes. + self::assertCount(2, $items, 'Partial billing period: Order has three items (subscription and normalized usage).'); + $usage = $subscription_type->getUsageForPeriod($subscription, $order_period); + $latest_usage = end($usage['gauge']); + self::assertEquals($latest_usage->getQuantity(), 4, 'Partial billing period: Four gauge usages were recorded.'); + + /** @var \Drupal\commerce_order\Entity\OrderItem $usage_item */ + self::assertEquals(new Price('10', 'USD'), $order->getTotalPrice(), 'Partial billing period: Order total is 20 USD.'); + + // Test free quantity for gauge usage type. + $subscription_type->addUsage($subscription, $gv_free_quantity, 7, $order_period, 'gauge'); + $this->recurringOrderManager->refreshOrder($order); + // With free quantity applied, the second usage record comes out to two + // usages at $5/period; $20 + $10 = $30. + self::assertEquals(new Price('15', 'USD'), $order->getTotalPrice(), 'Partial billing period: Gauge usage type: Free quantity works.'); + + // + // + // PREPAID + // + // . + $added_prepaid_subscription = OrderItem::create([ + 'type' => 'default', + 'purchased_entity' => $this->prepaidUsageSubscription, + 'unit_price' => [ + 'number' => '5.00', + 'currency_code' => 'USD', + ], + 'quantity' => '1', + ]); + $added_prepaid_subscription->save(); + $prepaid_order = Order::create([ + 'type' => 'default', + 'store_id' => $this->store, + 'uid' => $this->user, + 'order_items' => [$added_prepaid_subscription], + 'state' => 'draft', + 'payment_method' => $this->paymentMethod, + ]); + $prepaid_order->save(); + + $prepaid_workflow = $prepaid_order->getState()->getWorkflow(); + $prepaid_order->getState() + ->applyTransition($prepaid_workflow->getTransition('place')); + $prepaid_order->save(); + + $subscriptions = Subscription::loadMultiple(); + + /** @var \Drupal\commerce_recurring\Entity\SubscriptionInterface $prepaid_subscription */ + $prepaid_subscription = end($subscriptions); + /** @var \Drupal\commerce_order\Entity\Order $porder */ + $porder = $this->recurringOrderManager->ensureOrder($prepaid_subscription); + /** @var \Drupal\commerce_recurring\Plugin\Field\FieldType\BillingPeriodItem $pfield_billing_period */ + $pfield_billing_period = $porder->get('billing_period')->first(); + $porder_period = $pfield_billing_period->toBillingPeriod(); + + // Increase gauge usage by 4. Add it for the entire billing period. + /** @var \Drupal\commerce_recurring_metered\Plugin\Commerce\SubscriptionType\UsageHelperTrait $prepaid_subscription_type */ + $prepaid_subscription_type = $prepaid_subscription->getType(); + $prepaid_subscription_type->addUsage($prepaid_subscription, $gauge_variation, 4, $porder_period, 'gauge'); + + $pusage = $prepaid_subscription_type->getUsageForPeriod($prepaid_subscription, $porder_period); + $platest_usage = end($pusage['gauge']); + self::assertEquals($platest_usage->getQuantity(), 4, 'Four gauge usages were recorded.'); + + $this->recurringOrderManager->refreshOrder($porder); + $items = $porder->getItems(); + self::assertCount(2, $items, 'Order has two items (subscription and added usage).'); + + // Check that overlaps of the same variation get resolved. + // \DateTimePlus::add() was not adding, so converting back and forth. + $halfway = $porder_period->getStartDate() + ->getPhpDateTime() + ->add(new \DateInterval('PT30M')); + $half_period = new BillingPeriod($porder_period->getStartDate(), DrupalDateTime::createFromDateTime($halfway)); + $prepaid_subscription_type->addUsage($prepaid_subscription, $gauge_variation, 4, $half_period, 'gauge'); + $this->recurringOrderManager->refreshOrder($porder); + + $items = $porder->getItems(); + self::assertCount(3, $items, 'Order has three items (subscription, first usage, and extra added usage).'); + $usage = $prepaid_subscription_type->getUsageForPeriod($prepaid_subscription, $porder_period); + $latest_usage = end($usage['gauge']); + self::assertEquals($latest_usage->getQuantity(), 4, 'Four gauge usages were recorded.'); + + /** @var \Drupal\commerce_order\Entity\OrderItem $usage_item */ + self::assertEquals(new Price('25', 'USD'), $porder->getTotalPrice(), 'Order total is 25 USD.'); + + // Test free quantity for gauge usage type. + $prepaid_subscription_type->addUsage($prepaid_subscription, $gv_free_quantity, 7, $porder_period, 'gauge'); + $this->recurringOrderManager->refreshOrder($porder); + // With free quantity applied, the second usage records comes out to two + // usages at $5/period; $20 + $10 = $30. + self::assertEquals(new Price('35', 'USD'), $porder->getTotalPrice(), 'Gauge usage type: Free quantity works.'); + } + + /** + * {@inheritdoc} + * + * @throws \Exception + */ + protected function setUp() { + parent::setUp(); + $this->installSchema('commerce_recurring_metered', 'commerce_recurring_usage'); + + $this->recurringOrderManager = $this->container->get('commerce_recurring.order_manager'); + + /** @var \Drupal\commerce_recurring\Entity\BillingScheduleInterface $billing_schedule */ + $postpaid_rolling_billing_schedule = BillingSchedule::create([ + 'id' => 'rolling_test_id', + 'label' => 'Hourly schedule', + 'displayLabel' => 'Hourly schedule', + 'billingType' => BillingSchedule::BILLING_TYPE_POSTPAID, + 'plugin' => 'rolling', + 'configuration' => [ + 'interval' => [ + 'number' => '1', + 'unit' => 'hour', + ], + ], + ]); + $postpaid_rolling_billing_schedule->save(); + $this->postpaidRollingBillingSchedule = $this->reloadEntity($postpaid_rolling_billing_schedule); + + /** @var \Drupal\commerce_recurring\Entity\BillingScheduleInterface $prepaid_billing_schedule */ + $prepaid_billing_schedule = BillingSchedule::create([ + 'id' => 'prepaid_test_id', + 'label' => 'Hourly schedule', + 'displayLabel' => 'Hourly schedule', + 'billingType' => BillingSchedule::BILLING_TYPE_PREPAID, + 'plugin' => 'rolling', + 'configuration' => [ + 'interval' => [ + 'number' => '1', + 'unit' => 'hour', + ], + ], + ]); + $prepaid_billing_schedule->save(); + $this->prepaidBillingSchedule = $this->reloadEntity($prepaid_billing_schedule); + + // Postpaid subscription variation. + $usage_subscription_variation = ProductVariation::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'sku' => strtolower($this->randomMachineName()), + 'price' => [ + 'number' => '0.00', + 'currency_code' => 'USD', + ], + 'billing_schedule' => $this->billingSchedule, + 'subscription_type' => [ + 'target_plugin_id' => 'usage_test_product_variation', + ], + ]); + $usage_subscription_variation->save(); + $this->usageSubscriptionVariation = $this->reloadEntity($usage_subscription_variation); + + // Postpaid (rolling) subscription variation. + $postpaid_usage_subscription_variation = ProductVariation::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'sku' => strtolower($this->randomMachineName()), + 'price' => [ + 'number' => '0.00', + 'currency_code' => 'USD', + ], + 'billing_schedule' => $this->postpaidRollingBillingSchedule, + 'subscription_type' => [ + 'target_plugin_id' => 'usage_test_product_variation', + ], + ]); + $postpaid_usage_subscription_variation->save(); + $this->rollingUsageSubscriptionVariation = $this->reloadEntity($postpaid_usage_subscription_variation); + + // Prepaid subscription variation. + $prepaid_subscription = ProductVariation::create([ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'sku' => strtolower($this->randomMachineName()), + 'price' => [ + 'number' => '5.00', + 'currency_code' => 'USD', + ], + 'billing_schedule' => $this->prepaidBillingSchedule, + 'subscription_type' => [ + 'target_plugin_id' => 'usage_test_product_variation', + ], + ]); + $prepaid_subscription->save(); + $this->prepaidUsageSubscription = $this->reloadEntity($prepaid_subscription); + } + +}