Skip to content
This repository has been archived by the owner on Nov 9, 2021. It is now read-only.

Commit

Permalink
[5.x] Metered prices within Feature (#32)
Browse files Browse the repository at this point in the history
Co-authored-by: rennokki <[email protected]>
  • Loading branch information
rennokki and rennokki authored May 12, 2021
1 parent 7c5e75a commit 58d7f57
Show file tree
Hide file tree
Showing 10 changed files with 584 additions and 251 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
PADDLE_VENDOR_AUTH_CODE: ${{ secrets.PADDLE_VENDOR_AUTH_CODE }}
PADDLE_TEST_PLAN: ${{ secrets.PADDLE_TEST_PLAN }}
PADDLE_TEST_FREE_PLAN: ${{ secrets.PADDLE_TEST_FREE_PLAN }}
PADDLE_YEARLY_TEST_PLAN: ${{ secrets.PADDLE_YEARLY_TEST_PLAN }}

- uses: codecov/codecov-action@v1
with:
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ It helps you define static, project-level plans, and attach them features that c
- [Preparing the plans](#preparing-the-plans)
- [Feature Usage Tracking](#feature-usage-tracking)
- [Checking for overflow](#checking-for-overflow)
- [Metered billing when overflowing](#metered-billing-when-overflowing)
- [Resetting tracked values](#resetting-tracked-values)
- [Unlimited amounts](#unlimited-amounts)
- [Checking for overexceeded quotas](#checking-for-overexceeded-quotas)
- [Metered features](#metered-features)
- [Additional data](#additional-data)
- [Setting the plan as popular](#setting-the-plan-as-popular)
- [Inherit features from other plans](#inherit-features-from-other-plans)
Expand Down Expand Up @@ -259,6 +261,37 @@ $subscription->swap($freePlan); // has no build minutes
$subscription->featureOverQuota('build.minutes');
```

Naturally, `recordFeatureUsage()` has a callback method that gets called whenever the amount of consumption gets over the allocated total quota.

For example, users can have 1000 build minutes each month, but at some point if they have 10 left and they consume 15, the feature usage will be saturated/depleted completely, and the extra amount will be passed to the callback:

```php
$subscription->recordFeatureUsage('build.minutes', 15, true, function ($feature, $valueOverQuota, $subscription) {
// Bill the user with on-demand pricing, per se.
$this->billOnDemandFor($valueOverQuota, $subscription);
});
```

### Metered billing when overflowing

When exceeding the allocated quota for a specific feature, [Metered Billing for Stripe](#metered-features) can come in and bill for metered usage, but only if it's a Metered Feature and the quota is exceeded and the feature is defined as [Metered Feature](#metered-features).

```php
Saas::plan('Gold Plan', 'gold-plan')->features([
Saas::meteredFeature('Build Minutes', 'build.minutes', 3000), // included: 3000
->meteredPrice('price_identifier', 0.01, 'minute'), // on-demand: $0.01/minute
]);

$subscription->recordFeatureUsage('build.minutes', 4000, true, function ($feature, $valueOverQuota, $subscription) {
// From the used 4000 minutes, 3000 were included already by the plan feature.
// Extra 1000 (to reach a total usage of 4000) is over the quota, so because
// the feature is metered and we defined a ->meteredPrice(), the package
// wil automatically record the usage to Stripe via Cashier.

// Here you can run custom logic to handle overflow.
});
```

### Resetting tracked values

By default, each created feature is resettable - each time the billing cycle ends, you can call `resetQuotas` to reset them (they will become 3000 in the previous example).
Expand Down Expand Up @@ -342,6 +375,30 @@ foreach ($overQuotaFeatures as $feature) {

**Please keep in mind that this works only for non-resettable features, like Teams, Members, etc. due to the fact that features, when swapping between plans, should be handled manually, either wanting to keep them as-is or resetting them using `resetQuotas()`**

### Metered features

Metered features are opened for Stripe only and this will open up custom metering for exceeding quotas on features.

You might want to give your customers a specific amount of a feature, let's say `Build Minutes`, but for exceeding amount of minutes you might invoice at the end of the month a price of `$0.01` per minute:

```php
Saas::plan('Gold Plan', 'gold-plan')->features([
Saas::meteredFeature('Build Minutes', 'build.minutes', 3000), // included: 3000
->meteredPrice('price_identifier', 0.01, 'minute'), // on-demand: $0.01/minute
]);
```

If you simply want just the on-demand price of the metered feature, just omit the amount:

```php
Saas::plan('Gold Plan', 'gold-plan')->features([
Saas::meteredFeature('Build Minutes', 'build.minutes'), // included: 0
->meteredPrice('price_identifier', 0.01, 'minute'), // on-demand: $0.01/minute
]);
```

**The third parameter is just a conventional name for the unit. `0.01` is the price per unit (PPU). In this case, it's `minute` and `$0.01`, assuming the plan's price is in USD.**

### Additional data

Items, plans and features implement a `->data()` method that allows you to attach custom data for each item:
Expand Down
26 changes: 21 additions & 5 deletions src/Concerns/HasFeatures.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace RenokiCo\CashierRegister\Concerns;

use RenokiCo\CashierRegister\Feature;
use RenokiCo\CashierRegister\MeteredFeature;
use RenokiCo\CashierRegister\Plan;

trait HasFeatures
Expand Down Expand Up @@ -37,9 +38,12 @@ public function features(array $features)
*/
public function inheritFeaturesFromPlan(Plan $plan, array $features = [])
{
$this->features = collect($features)->merge($plan->getFeatures())->merge($this->getFeatures())->unique(function (Feature $feature) {
return $feature->getId();
});
$this->features = collect($features)
->merge($plan->getFeatures())
->merge($this->getFeatures())
->unique(function (Feature $feature) {
return $feature->getId();
});

return $this;
}
Expand All @@ -54,6 +58,18 @@ public function getFeatures()
return collect($this->features);
}

/**
* Get the metered features.
*
* @return \Illuminate\Support\Collection
*/
public function getMeteredFeatures()
{
return $this->getFeatures()->filter(function ($feature) {
return $feature instanceof MeteredFeature;
});
}

/**
* Get a specific feature by id.
*
Expand All @@ -62,8 +78,8 @@ public function getFeatures()
*/
public function getFeature($id)
{
return $this->getFeatures()->filter(function (Feature $feature) use ($id) {
return $this->getFeatures()->first(function (Feature $feature) use ($id) {
return $feature->getId() == $id;
})->first();
});
}
}
93 changes: 85 additions & 8 deletions src/Concerns/HasQuotas.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace RenokiCo\CashierRegister\Concerns;

use Closure;
use RenokiCo\CashierRegister\Feature;
use RenokiCo\CashierRegister\MeteredFeature;
use RenokiCo\CashierRegister\Saas;

trait HasQuotas
Expand All @@ -25,9 +27,10 @@ public function usage()
* @param string|int $id
* @param int $value
* @param bool $incremental
* @param Closure|null $exceedHandler
* @return \RenokiCo\CashierRegister\Models\Usage|null
*/
public function recordFeatureUsage($id, int $value = 1, bool $incremental = true)
public function recordFeatureUsage($id, int $value = 1, bool $incremental = true, Closure $exceedHandler = null)
{
$feature = $this->getPlan()->getFeature($id);

Expand All @@ -44,6 +47,44 @@ public function recordFeatureUsage($id, int $value = 1, bool $incremental = true
'used' => $incremental ? $usage->used + $value : $value,
]);

$planId = $this->getPlanIdentifier();
$featureOverQuota = $this->featureOverQuotaFor($id, $usage, $planId);

if (! $feature->isUnlimited() && $featureOverQuota) {
$remainingQuota = $this->getRemainingQuotaFor($id, $usage, $planId);

$valueOverQuota = value(function () use ($value, $remainingQuota) {
return $remainingQuota < 0
? -1 * $remainingQuota
: $value;
});

if ($feature instanceof MeteredFeature && method_exists($this, 'reportUsageFor')) {
/** @var MeteredFeature $feature */

// If the user has for example 5 minutes left and the pipeline
// ended and 10 minutes were consumed, update the feature usage to
// feature value (meaning everything got consumed) and report
// the usage based on the difference for the remaining difference,
// but with positive value.
$this->reportUsageFor($feature->getMeteredId(), $valueOverQuota);
}

/** @var Feature $feature */

// Fill the usage later since the getRemaininQuotaFor() uses the $usage
// object that was updated with the current requested feature usage recording.
// This way, the next time the customer uses again the feature, it will jump straight up
// to billing using metering instead of calculating the difference.
$usage->fill([
'used' => $this->getFeatureQuota($id, $planId),
]);

if ($exceedHandler) {
$exceedHandler($feature, $valueOverQuota, $this);
}
}

return tap($usage)->save();
}

Expand Down Expand Up @@ -131,6 +172,25 @@ public function getRemainingQuota($id, $planId = null): int
return $featureValue - $this->getUsedQuota($id);
}

/**
* Get the feature quota remaining.
*
* @param string|int $id
* @param \Illuminate\Database\Eloquent\Model $usage
* @param string|null $planId
* @return int
*/
public function getRemainingQuotaFor($id, $usage, $planId = null): int
{
$featureValue = $this->getFeatureQuota($id, $planId);

if ($featureValue < 0) {
return -1;
}

return $featureValue - $usage->used;
}

/**
* Get the feature quota.
*
Expand Down Expand Up @@ -171,6 +231,27 @@ public function featureOverQuota($id, $planId = null): bool
return $this->getRemainingQuota($id, $planId) < 0;
}

/**
* Check if the feature is over the assigned quota.
*
* @param string|int $id
* @param \Illuminate\Database\Eloquent\Model $usage
* @param string|null $planId
* @return bool
*/
public function featureOverQuotaFor($id, $usage, $planId = null): bool
{
$plan = $planId ? Saas::getPlan($planId) : $this->getPlan();

$feature = $plan->getFeature($id);

if ($feature->isUnlimited()) {
return false;
}

return $this->getRemainingQuotaFor($id, $usage, $planId) < 0;
}

/**
* Check if there are features over quota
* if the current subscription would be swapped
Expand All @@ -184,18 +265,14 @@ public function featuresOverQuotaWhenSwapping(string $planId)
$plan = Saas::getPlan($planId);

return $plan->getFeatures()
->reject(function ($feature) {
return $feature->isResettable();
})
->reject(function ($feature) {
return $feature->isUnlimited();
})
->reject->isResettable()
->reject->isUnlimited()
->filter(function (Feature $feature) use ($plan) {
$remainingQuota = $this->getRemainingQuota(
$feature->getId(), $plan->getId()
);

return $remainingQuota <= 0;
return $remainingQuota < 0;
});
}

Expand Down
90 changes: 90 additions & 0 deletions src/MeteredFeature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace RenokiCo\CashierRegister;

class MeteredFeature extends Feature
{
/**
* The metered price ID, in case
* the feature has a quota for metered price.
*
* @var string|int|null
*/
protected $meteredId;

/**
* The metered price per unit, in case
* the feature has a quota for metered price.
*
* @var float
*/
protected $meteredPrice = 0.00;

/**
* The metered unit name.
*
* @var string
*/
protected $meteredUnitName;

/**
* Set the metered price.
*
* @param string|int $id
* @param float $price
* @param string|null $unitName
* @return self
*/
public function meteredPrice($id, float $price, string $unitName = null)
{
$this->meteredId = $id;
$this->meteredPrice = $price;
$this->meteredUnitName = $unitName;

return $this;
}

/**
* Get the metered price ID.
*
* @return string|int|null
*/
public function getMeteredId()
{
return $this->meteredId;
}

/**
* Get the metered price.
*
* @return float
*/
public function getMeteredPrice(): float
{
return $this->meteredPrice;
}

/**
* Get the metered unit name.
*
* @return string|null
*/
public function getMeteredUnitName()
{
return $this->meteredUnitName;
}

/**
* Get the instance as an array.
*
* @return array
*/
public function toArray()
{
return array_merge(parent::toArray(), [
'metered_id' => $this->getMeteredId(),
'metered_price' => $this->getMeteredPrice(),
'metered_unit_name' => $this->getMeteredUnitName(),
]);
}
}
Loading

0 comments on commit 58d7f57

Please sign in to comment.