diff --git a/.idea/blade.xml b/.idea/blade.xml index 37ac5989..4d7141cd 100644 --- a/.idea/blade.xml +++ b/.idea/blade.xml @@ -57,6 +57,7 @@ + @@ -81,6 +82,7 @@ + @@ -93,11 +95,13 @@ + + @@ -111,6 +115,7 @@ + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..576d84cd --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/php-test-framework.xml b/.idea/php-test-framework.xml new file mode 100644 index 00000000..530f96a3 --- /dev/null +++ b/.idea/php-test-framework.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml index b210776b..271d04c2 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -147,7 +147,13 @@ + + + + + + diff --git a/src/Subscription.php b/src/Subscription.php index 416d4fdc..b9e252c7 100644 --- a/src/Subscription.php +++ b/src/Subscription.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use InvalidArgumentException; use Laravel\Cashier\Concerns\AllowsCoupons; use Laravel\Cashier\Concerns\HandlesPaymentFailures; @@ -563,9 +564,12 @@ public function reportUsage($quantity = 1, $timestamp = null, $price = null) * @return MeterEvent * @throws ApiErrorException */ - public function reportMeterUsage(string $meter, int $quantity = 1, ?string $price = null): MeterEvent + public function reportEventUsage(string $meter, int $quantity = 1, ?string $price = null): MeterEvent { - return $this->findItemOrFail($price ?? $this->stripe_price)->reportMeterUsage($meter, $quantity); + if (! $price) { + $this->guardAgainstMultiplePrices(); + } + return $this->findItemOrFail($price ?? $this->stripe_price)->reportEventUsage($meter, $quantity); } /** @@ -591,9 +595,9 @@ public function reportUsageFor($price, $quantity = 1, $timestamp = null) * @return MeterEvent * @throws ApiErrorException */ - public function reportUsageForMeter(string $meter, string $price, int $quantity = 1): MeterEvent + public function reportUsageForEvent(string $eventName, string $price, int $quantity = 1): MeterEvent { - return $this->reportMeterUsage($meter, $quantity, $price); + return $this->reportEventUsage($eventName, $quantity, $price); } /** @@ -612,6 +616,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 + */ + 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. * diff --git a/src/SubscriptionItem.php b/src/SubscriptionItem.php index 3b906e27..148db80c 100644 --- a/src/SubscriptionItem.php +++ b/src/SubscriptionItem.php @@ -231,7 +231,7 @@ public function reportUsage($quantity = 1, $timestamp = null) * @return MeterEvent * @throws ApiErrorException */ - public function reportMeterUsage(string $meter, int $quantity = 1): MeterEvent + public function reportEventUsage(string $meter, int $quantity = 1): MeterEvent { return $this->subscription->owner->stripe()->billing->meterEvents->create([ 'event_name' => $meter, @@ -255,6 +255,47 @@ 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 + */ + 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 + */ + 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. * diff --git a/tests/Feature/MeteredBillingTest.php b/tests/Feature/MeteredBillingTest.php index 249ffb96..9d79f87d 100644 --- a/tests/Feature/MeteredBillingTest.php +++ b/tests/Feature/MeteredBillingTest.php @@ -4,6 +4,7 @@ use Exception; use InvalidArgumentException; +use Stripe\Exception\ApiErrorException; use Stripe\Exception\InvalidRequestException; class MeteredBillingTest extends FeatureTestCase @@ -23,6 +24,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 */ @@ -30,7 +61,7 @@ class MeteredBillingTest extends FeatureTestCase public static function setUpBeforeClass(): void { - if (! getenv('STRIPE_SECRET')) { + if (!getenv('STRIPE_SECRET')) { return; } @@ -63,6 +94,68 @@ 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', @@ -93,6 +186,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'); @@ -106,6 +215,21 @@ 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'); @@ -140,6 +264,47 @@ 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');