Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for new Event Meters #1698

Merged
merged 21 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/Subscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Laravel\Cashier\Exceptions\IncompletePayment;
use Laravel\Cashier\Exceptions\SubscriptionUpdateFailure;
use LogicException;
use Stripe\Billing\MeterEvent;
use Stripe\Exception\ApiErrorException;
use Stripe\Subscription as StripeSubscription;

/**
Expand Down Expand Up @@ -552,6 +554,25 @@ public function reportUsage($quantity = 1, $timestamp = null, $price = null)
return $this->findItemOrFail($price ?? $this->stripe_price)->reportUsage($quantity, $timestamp);
}

/**
* Report usage for a metered product using Event Meters API.
*
* @param string $meter
* @param int $quantity
* @param string|null $price
* @return MeterEvent
*
* @throws ApiErrorException
driesvints marked this conversation as resolved.
Show resolved Hide resolved
*/
public function reportEventUsage(string $meter, int $quantity = 1, ?string $price = null): MeterEvent
{
if (! $price) {
$this->guardAgainstMultiplePrices();
}

return $this->findItemOrFail($price ?? $this->stripe_price)->reportEventUsage($meter, $quantity);
}

/**
* Report usage for specific price of a metered product.
*
Expand All @@ -565,6 +586,21 @@ public function reportUsageFor($price, $quantity = 1, $timestamp = null)
return $this->reportUsage($quantity, $timestamp, $price);
}

/**
* Report usage for specific price of a metered product.
*
* @param string $price
* @param int $quantity
* @param string $eventName
* @return MeterEvent
*
* @throws ApiErrorException
driesvints marked this conversation as resolved.
Show resolved Hide resolved
*/
public function reportUsageForEvent(string $eventName, string $price, int $quantity = 1): MeterEvent
{
return $this->reportEventUsage($eventName, $quantity, $price);
}

/**
* Get the usage records for a metered product.
*
Expand All @@ -581,6 +617,25 @@ public function usageRecords(array $options = [], $price = null)
return $this->findItemOrFail($price ?? $this->stripe_price)->usageRecords($options);
}

/**
* Get the usage records for a meter using its ID (not name).
*
* @param string $meterId
* @param array $options
* @param null $price
* @return Collection
*
* @throws ApiErrorException
driesvints marked this conversation as resolved.
Show resolved Hide resolved
*/
public function meterUsageRecords(string $meterId, array $options = [], $price = null): Collection
{
if (! $price) {
$this->guardAgainstMultiplePrices();
}

return $this->findItemOrFail($price ?? $this->stripe_price)->eventUsageRecord($meterId, $options);
}

/**
* Get the usage records for a specific price of a metered product.
*
Expand Down
65 changes: 65 additions & 0 deletions src/SubscriptionItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Laravel\Cashier\Concerns\InteractsWithPaymentBehavior;
use Laravel\Cashier\Concerns\Prorates;
use Laravel\Cashier\Database\Factories\SubscriptionItemFactory;
use Stripe\Billing\MeterEvent;
use Stripe\Exception\ApiErrorException;

/**
* @property \Laravel\Cashier\Subscription|null $subscription
Expand Down Expand Up @@ -220,6 +222,26 @@ public function reportUsage($quantity = 1, $timestamp = null)
]);
}

/**
* Report usage for a metered product using the new Meter Event API.
*
* @param string $meter
* @param int $quantity
* @return MeterEvent
*
* @throws ApiErrorException
driesvints marked this conversation as resolved.
Show resolved Hide resolved
*/
public function reportEventUsage(string $meter, int $quantity = 1): MeterEvent
{
return $this->subscription->owner->stripe()->billing->meterEvents->create([
'event_name' => $meter,
'payload' => [
'value' => $quantity,
'stripe_customer_id' => $this->subscription->owner->stripe_id,
],
]);
}

/**
* Get the usage records for a metered product.
*
Expand All @@ -233,6 +255,49 @@ public function usageRecords($options = [])
)->data);
}

/**
* List all the metered prices for the subscription item.
*
* @see https://stripe.com/docs/api/prices/list
*
* @param array|null $params
* @param array|null $opts
* @return Collection
*
* @throws ApiErrorException
driesvints marked this conversation as resolved.
Show resolved Hide resolved
*/
public function listMeters(?array $params = [], ?array $opts = []): Collection
{
return new Collection($this->subscription->owner->stripe()->billing->meters->all($params, $opts)->data);
}

/**
* @param string $meterId
* @param array|null $params
* @param array|null $opts
* @return Collection
*
* @throws ApiErrorException
driesvints marked this conversation as resolved.
Show resolved Hide resolved
*/
public function eventUsageRecord(string $meterId, ?array $params = [], ?array $opts = []): Collection
{
$startTime = $params['start_time'] ?? $this->subscription->created_at->timestamp;
$endTime = $params['end_time'] ?? time();

unset($params['start_time'], $params['end_time']);

$params = [
'customer' => $this->subscription->owner->stripeId(),
'start_time' => $startTime,
'end_time' => $endTime,
...$params,
];

return new Collection($this->subscription->owner->stripe()->billing->meters->allEventSummaries(
$meterId, $params, $opts
)->data);
}

/**
* Update the underlying Stripe subscription item information for the model.
*
Expand Down
157 changes: 157 additions & 0 deletions tests/Feature/MeteredBillingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,36 @@ class MeteredBillingTest extends FeatureTestCase
*/
protected static $otherMeteredPrice;

/**
* @var string
*/
protected static $meterId;

/**
* @var string
*/
protected static $otherMeterId;

/**
* @var string
*/
protected static $meteredEventPrice;

/**
* @var string
*/
protected static $otherMeteredEventPrice;

/**
* @var string
*/
protected static $meterEventName;

/**
* @var string
*/
protected static $otherMeterEventName;

/**
* @var string
*/
Expand Down Expand Up @@ -63,6 +93,66 @@ public static function setUpBeforeClass(): void
'unit_amount' => 200,
])->id;

self::$meterEventName = 'test-meter-1';
self::$otherMeterEventName = 'test-meter-2';

$meters = self::stripe()->billing->meters->all();

foreach ($meters as $meter) {
if ($meter->event_name === self::$meterEventName && $meter->status === 'active') {
self::stripe()->billing->meters->deactivate($meter->id);
}
if ($meter->event_name === self::$otherMeterEventName && $meter->status === 'active') {
self::stripe()->billing->meters->deactivate($meter->id);
}
}

static::$meterId = self::stripe()->billing->meters->create([
'display_name' => 'example meter 1',
'event_name' => self::$meterEventName,
'default_aggregation' => ['formula' => 'sum'],
'customer_mapping' => [
'type' => 'by_id',
'event_payload_key' => 'stripe_customer_id',
],
])->id;

static::$otherMeterId = self::stripe()->billing->meters->create([
'display_name' => 'example meter 2',
'event_name' => self::$otherMeterEventName,
'default_aggregation' => ['formula' => 'sum'],
'customer_mapping' => [
'type' => 'by_id',
'event_payload_key' => 'stripe_customer_id',
],
])->id;

static::$meteredEventPrice = self::stripe()->prices->create([
'product' => static::$productId,
'nickname' => 'Monthly Metered Event $1 per unit',
'currency' => 'USD',
'recurring' => [
'interval' => 'month',
'usage_type' => 'metered',
'meter' => static::$meterId,
],
'billing_scheme' => 'per_unit',
'unit_amount' => 100,
])->id;

static::$otherMeteredEventPrice = self::stripe()->prices->create([
'product' => static::$productId,
'nickname' => 'Monthly Metered Event $2 per unit',
'currency' => 'USD',
'recurring' => [
'interval' => 'month',
'usage_type' => 'metered',
'meter' => static::$otherMeterId,
],
'billing_scheme' => 'per_unit',
'unit_amount' => 200,
])->id;

static::$licensedPrice = self::stripe()->prices->create([
'product' => static::$productId,
'nickname' => 'Monthly $10 Licensed',
Expand Down Expand Up @@ -93,6 +183,22 @@ public function test_report_usage_for_metered_price()
$this->assertSame($summary->total_usage, 15);
}

public function test_report_usage_for_meter()
{
$user = $this->createCustomer('test_report_usage_for_meter');

$subscription = $user->newSubscription('main')
->meteredPrice(static::$meteredEventPrice)
->create('pm_card_visa');

sleep(1);
$subscription->reportUsageForEvent(static::$meterEventName, static::$meteredEventPrice, 10);

$summary = $subscription->meterUsageRecords(static::$meterId)->first();

$this->assertSame($summary->aggregated_value, 10.0);
}

public function test_reporting_usage_for_licensed_price_throws_exception()
{
$user = $this->createCustomer('reporting_usage_for_licensed_price_throws_exception');
Expand All @@ -106,6 +212,19 @@ public function test_reporting_usage_for_licensed_price_throws_exception()
}
}

public function test_reporting_usage_for_legacy_metered_price_throws_exception()
{
$user = $this->createCustomer('reporting_usage_for_licensed_price_throws_exception');

$subscription = $user->newSubscription('main')->meteredPrice(static::$meteredEventPrice)->create('pm_card_visa');

try {
$subscription->reportUsage();
} catch (Exception $e) {
$this->assertInstanceOf(InvalidRequestException::class, $e);
}
}

public function test_reporting_usage_for_subscriptions_with_multiple_prices()
{
$user = $this->createCustomer('reporting_usage_for_subscriptions_with_multiple_prices');
Expand Down Expand Up @@ -140,6 +259,44 @@ public function test_reporting_usage_for_subscriptions_with_multiple_prices()
}
}

public function test_reporting_event_usage_for_subscriptions_with_multiple_prices()
{
$user = $this->createCustomer('reporting_usage_for_subscriptions_with_multiple_prices');

$subscription = $user->newSubscription('main', [static::$licensedPrice])
->meteredPrice(static::$meteredEventPrice)
->meteredPrice(static::$otherMeteredEventPrice)
->create('pm_card_visa');

$this->assertSame($subscription->items->count(), 3);

try {
$subscription->reportEventUsage(static::$meterEventName);
} catch (Exception $e) {
$this->assertInstanceOf(InvalidArgumentException::class, $e);

$this->assertSame(
'This method requires a price argument since the subscription has multiple prices.', $e->getMessage()
);
}

$subscription->reportEventUsage(static::$otherMeterEventName, 20, static::$otherMeteredEventPrice);

try {
$subscription->meterUsageRecords(static::$otherMeterId)->first();
} catch (Exception $e) {
$this->assertInstanceOf(InvalidArgumentException::class, $e);

$this->assertSame(
'This method requires a price argument since the subscription has multiple prices.', $e->getMessage()
);
}

$summary = $subscription->meterUsageRecords(static::$otherMeterId, price: static::$otherMeteredEventPrice)->first();

$this->assertSame($summary->aggregated_value, 20.0);
}

public function test_swap_metered_price_to_different_price()
{
$user = $this->createCustomer('swap_metered_price_to_different_price');
Expand Down