533 lines
23 KiB
PHP
533 lines
23 KiB
PHP
<?php
|
|
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanPrice;
|
|
use App\Models\PlanVersion;
|
|
use App\Models\StripeAddonPrice;
|
|
use App\Models\StripePendingEvent;
|
|
use App\Models\Subscription;
|
|
use App\Models\SubscriptionRecord;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use App\Services\Stripe\FakeStripeClient;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
/**
|
|
* Stripe owns the billing cycle from here: retries, dunning, off-session SCA
|
|
* and invoice numbering are theirs, and we learn about all of it by webhook.
|
|
* Our side owns capability, because Stripe does not know how big the VM is.
|
|
*/
|
|
function fakeStripe(): FakeStripeClient
|
|
{
|
|
$fake = new FakeStripeClient;
|
|
app()->instance(StripeClient::class, $fake);
|
|
|
|
return $fake;
|
|
}
|
|
|
|
/** A contract Stripe will send events about. */
|
|
function stripeContract(string $stripeId = 'sub_1'): Subscription
|
|
{
|
|
return Subscription::factory()->plan('team')->create([
|
|
'stripe_subscription_id' => $stripeId,
|
|
'current_period_start' => now()->subMonth(),
|
|
'current_period_end' => now(),
|
|
]);
|
|
}
|
|
|
|
/** The plan half of the mirror — modules live in the same lists. */
|
|
function planPrices(FakeStripeClient $stripe): Collection
|
|
{
|
|
return collect($stripe->prices)->filter(fn ($price) => isset($price['metadata']['plan_family']));
|
|
}
|
|
|
|
function planProducts(FakeStripeClient $stripe): Collection
|
|
{
|
|
return collect($stripe->products)->filter(fn ($product) => isset($product['metadata']['plan_family']));
|
|
}
|
|
|
|
/** The module half: a Price per module and term, so a booking has one to bill on. */
|
|
function modulePrices(FakeStripeClient $stripe): Collection
|
|
{
|
|
return collect($stripe->prices)->filter(fn ($price) => isset($price['metadata']['addon']));
|
|
}
|
|
|
|
it('mirrors the catalogue into Stripe, once', function () {
|
|
$stripe = fakeStripe();
|
|
|
|
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
|
|
|
// A product per family, a price per priced row of a published version.
|
|
expect(planProducts($stripe))->toHaveCount(4)
|
|
->and(planPrices($stripe))->toHaveCount(8)
|
|
->and(PlanFamily::query()->whereNull('stripe_product_id')->count())->toBe(0)
|
|
->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0);
|
|
|
|
// And every module we sell, on both terms: a Stripe Price carries its own
|
|
// interval, so a module on a yearly contract needs a yearly one. Without
|
|
// these a booked module could never become an item on the subscription,
|
|
// which is why it was charged in the month it was booked and never again.
|
|
$modules = array_merge(array_keys((array) config('provisioning.addons')), ['storage']);
|
|
|
|
expect(modulePrices($stripe))->toHaveCount(count($modules) * 2)
|
|
->and(StripeAddonPrice::query()->where('addon_key', 'storage')->where('interval', 'year')->value('amount_cents'))
|
|
->toBe(12 * (int) config('provisioning.storage_addon.price_cents'));
|
|
|
|
// A Stripe Price cannot be edited, so a second run that minted duplicates
|
|
// would leave two live prices for one plan and no way to tell them apart.
|
|
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
|
|
|
expect(planPrices($stripe))->toHaveCount(8)
|
|
->and(modulePrices($stripe))->toHaveCount(count($modules) * 2)
|
|
->and($stripe->products)->toHaveCount(4 + count($modules));
|
|
});
|
|
|
|
it('gives each price its own recurring interval', function () {
|
|
$stripe = fakeStripe();
|
|
$this->artisan('stripe:sync-catalogue');
|
|
|
|
$intervals = planPrices($stripe)->groupBy('interval')->map->count();
|
|
|
|
// Monthly and yearly cannot share a Price, because the interval belongs to
|
|
// the Price itself.
|
|
expect($intervals['month'])->toBe(4)
|
|
->and($intervals['year'])->toBe(4);
|
|
|
|
$team = planPrices($stripe)->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month');
|
|
expect($team['amount'])->toBe(17900)
|
|
->and($team['metadata']['plan_version'])->toBe('1');
|
|
});
|
|
|
|
it('does not put an unpublished draft in the price list', function () {
|
|
$stripe = fakeStripe();
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
|
|
app(PlanCatalogue::class)->draft($family, [
|
|
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
|
|
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
|
|
'features' => [],
|
|
], ['monthly' => 19900, 'yearly' => 238800]);
|
|
|
|
$this->artisan('stripe:sync-catalogue');
|
|
|
|
// A draft has promised nothing; a Price for it would be a price list entry
|
|
// for something that may never exist.
|
|
expect(planPrices($stripe))->toHaveCount(8)
|
|
->and(planPrices($stripe)->pluck('amount'))->not->toContain(19900);
|
|
});
|
|
|
|
it('does not mint a second object when a run is interrupted before the id is stored', function () {
|
|
$stripe = fakeStripe();
|
|
|
|
$this->artisan('stripe:sync-catalogue');
|
|
|
|
// A crash between Stripe creating the objects and us recording their ids.
|
|
PlanFamily::query()->update(['stripe_product_id' => null]);
|
|
PlanPrice::query()->update(['stripe_price_id' => null]);
|
|
|
|
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
|
|
|
|
// Stripe replays the original answer for a repeated idempotency key, so the
|
|
// catalogue reconnects to what is already there instead of duplicating it —
|
|
// and a Price cannot be deleted afterwards to tidy up.
|
|
expect(planProducts($stripe))->toHaveCount(4)
|
|
->and(planPrices($stripe))->toHaveCount(8)
|
|
->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0);
|
|
});
|
|
|
|
it('does not revive a contract Stripe already ended', function () {
|
|
$subscription = stripeContract();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_end', 'type' => 'customer.subscription.deleted',
|
|
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
|
|
])->assertOk();
|
|
|
|
// Stripe does not guarantee delivery order: the final invoice can land
|
|
// after the deletion.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_late', 'type' => 'invoice.paid',
|
|
'data' => ['object' => [
|
|
'id' => 'in_late', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
])->assertOk();
|
|
|
|
$subscription->refresh();
|
|
|
|
// The payment is on record; the departed customer does not get their
|
|
// service back, and the cancellation is not recorded a second time.
|
|
expect($subscription->status)->toBe('cancelled')
|
|
->and($subscription->cancelled_at)->not->toBeNull()
|
|
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1)
|
|
->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
|
|
});
|
|
|
|
it('does not put a plan that has never been published in Stripe at all', function () {
|
|
$stripe = fakeStripe();
|
|
$family = PlanFamily::query()->create(['key' => 'agency', 'name' => 'Agentur', 'tier' => 5]);
|
|
app(PlanCatalogue::class)->draft($family, [
|
|
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
|
|
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
|
|
'features' => [],
|
|
], ['monthly' => 24900, 'yearly' => 298800]);
|
|
|
|
$this->artisan('stripe:sync-catalogue');
|
|
|
|
// Not even the Product: it would be a price-list entry for something that
|
|
// has promised nothing to anyone.
|
|
expect(planProducts($stripe))->toHaveCount(4)
|
|
->and($family->fresh()->stripe_product_id)->toBeNull();
|
|
});
|
|
|
|
it('does not record the checkout\'s own invoice as a renewal', function () {
|
|
stripeContract();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_first', 'type' => 'invoice.paid',
|
|
'data' => ['object' => [
|
|
'id' => 'in_first', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
// Stripe sends this for the invoice created during checkout.
|
|
'billing_reason' => 'subscription_create',
|
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
])->assertOk()->assertJson(['applied' => false]);
|
|
|
|
// The purchase is already in the register; counting it again here would
|
|
// double every customer's first payment.
|
|
expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0);
|
|
});
|
|
|
|
it('does not move the term on for a proration or a manual charge', function () {
|
|
$subscription = stripeContract();
|
|
$originalEnd = $subscription->current_period_end;
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_prorata', 'type' => 'invoice.paid',
|
|
'data' => ['object' => [
|
|
'id' => 'in_prorata', 'subscription' => 'sub_1', 'amount_paid' => 4200,
|
|
'billing_reason' => 'subscription_update',
|
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
])->assertOk();
|
|
|
|
// Money received, so it is on record — but a top-up does not start a new
|
|
// month, and pushing the period forward on one would give it away.
|
|
$record = SubscriptionRecord::query()->where('event', 'invoice_paid')->sole();
|
|
|
|
expect($record->gross_cents)->toBe(4200)
|
|
->and($record->snapshot['billing_reason'])->toBe('subscription_update')
|
|
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0)
|
|
->and($subscription->fresh()->current_period_end->timestamp)->toBe($originalEnd->timestamp);
|
|
});
|
|
|
|
it('refuses an older event delivered behind a newer one', function () {
|
|
$subscription = stripeContract();
|
|
$secondTermEnd = now()->addMonths(2);
|
|
|
|
// Renewal N+1 arrives first.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_n2', 'type' => 'invoice.paid', 'created' => now()->timestamp,
|
|
'data' => ['object' => [
|
|
'id' => 'in_n2', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => now()->addMonth()->timestamp, 'period_end' => $secondTermEnd->timestamp,
|
|
]],
|
|
])->assertOk();
|
|
|
|
// Renewal N turns up late, and a failed attempt from before that.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_n1', 'type' => 'invoice.paid', 'created' => now()->subHour()->timestamp,
|
|
'data' => ['object' => [
|
|
'id' => 'in_n1', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
])->assertOk();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_old_fail', 'type' => 'invoice.payment_failed', 'created' => now()->subHours(2)->timestamp,
|
|
'data' => ['object' => ['id' => 'in_old', 'subscription' => 'sub_1', 'attempt_count' => 1]],
|
|
])->assertOk();
|
|
|
|
$subscription->refresh();
|
|
|
|
// The term is not shortened, and a customer who has since paid is not
|
|
// flipped back to overdue.
|
|
expect($subscription->current_period_end->timestamp)->toBe($secondTermEnd->timestamp)
|
|
->and($subscription->stripe_status)->toBe('active')
|
|
// Both payments are still on record: a late arrival is recorded, it
|
|
// just does not get to rewrite the running picture.
|
|
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(2)
|
|
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
|
|
});
|
|
|
|
it('lets a payment that went through outrank a failure from the same second', function () {
|
|
$subscription = stripeContract();
|
|
$second = now();
|
|
|
|
// Stripe timestamps have one-second resolution, so a failed attempt and
|
|
// the retry that succeeded routinely share one.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_ok', 'type' => 'invoice.paid', 'created' => $second->timestamp,
|
|
'data' => ['object' => [
|
|
'id' => 'in_ok', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => $second->timestamp, 'period_end' => $second->copy()->addMonth()->timestamp,
|
|
]],
|
|
])->assertOk();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_nok', 'type' => 'invoice.payment_failed', 'created' => $second->timestamp,
|
|
'data' => ['object' => ['id' => 'in_ok', 'subscription' => 'sub_1', 'attempt_count' => 1]],
|
|
])->assertOk();
|
|
|
|
// The customer has paid; a same-second failure must not say otherwise.
|
|
expect($subscription->fresh()->stripe_status)->toBe('active')
|
|
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
|
|
});
|
|
|
|
it('holds a billing event that arrives before the contract exists, and applies it after', function () {
|
|
Queue::fake();
|
|
|
|
// The cancellation overtakes the checkout — Stripe does not deliver in
|
|
// order, and a checkout takes a moment to become a contract.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_early', 'type' => 'customer.subscription.deleted', 'created' => now()->timestamp,
|
|
'data' => ['object' => ['id' => 'sub_early', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
|
|
])->assertOk()->assertJson(['applied' => false]);
|
|
|
|
expect(StripePendingEvent::query()->count())->toBe(1);
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_checkout', 'type' => 'checkout.session.completed',
|
|
'data' => ['object' => [
|
|
'id' => 'cs_early', 'payment_status' => 'paid', 'subscription' => 'sub_early',
|
|
'customer_details' => ['email' => 'schnell@example.com'],
|
|
'amount_total' => 21480, 'currency' => 'eur',
|
|
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
|
|
]],
|
|
])->assertOk();
|
|
|
|
// Answering 2xx and forgetting it would have left us serving someone who
|
|
// had already left.
|
|
$subscription = Subscription::query()->sole();
|
|
|
|
expect($subscription->status)->toBe('cancelled')
|
|
->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1)
|
|
// The holding area is a holding area, not a second event log.
|
|
->and(StripePendingEvent::query()->count())->toBe(0);
|
|
});
|
|
|
|
it('holds nothing for a contract that is right there', function () {
|
|
stripeContract();
|
|
|
|
// Deliberately skipped: the checkout's own invoice.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_c', 'type' => 'invoice.paid', 'created' => now()->timestamp,
|
|
'data' => ['object' => [
|
|
'id' => 'in_c', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_create',
|
|
]],
|
|
])->assertOk();
|
|
|
|
// Already recorded: a redelivered renewal.
|
|
$renewal = [
|
|
'id' => 'evt_r', 'type' => 'invoice.paid', 'created' => now()->timestamp,
|
|
'data' => ['object' => [
|
|
'id' => 'in_r', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
];
|
|
$this->postJson(route('webhooks.stripe'), $renewal)->assertOk();
|
|
$this->postJson(route('webhooks.stripe'), $renewal)->assertOk();
|
|
|
|
// The holding area waits for contracts that do not exist yet. Ordinary
|
|
// traffic must not silt it up with rows nothing will ever come back for.
|
|
expect(StripePendingEvent::query()->count())->toBe(0)
|
|
->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1);
|
|
});
|
|
|
|
it('holds an event only once however often Stripe redelivers it', function () {
|
|
$event = [
|
|
'id' => 'evt_repeat', 'type' => 'invoice.paid', 'created' => now()->timestamp,
|
|
'data' => ['object' => [
|
|
'id' => 'in_repeat', 'subscription' => 'sub_nothere', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
]],
|
|
];
|
|
|
|
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
|
|
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
|
|
|
|
expect(StripePendingEvent::query()->count())->toBe(1);
|
|
});
|
|
|
|
it('creates nothing when Stripe is not connected', function () {
|
|
$stripe = fakeStripe();
|
|
$stripe->configured = false;
|
|
|
|
$this->artisan('stripe:sync-catalogue')->assertFailed();
|
|
|
|
expect($stripe->products)->toBeEmpty()
|
|
->and(PlanFamily::query()->whereNotNull('stripe_product_id')->count())->toBe(0);
|
|
});
|
|
|
|
it('moves the period on when Stripe says a renewal was paid', function () {
|
|
$subscription = stripeContract();
|
|
$end = now()->addMonth();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_paid', 'type' => 'invoice.paid',
|
|
'data' => ['object' => [
|
|
'id' => 'in_100', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => now()->timestamp, 'period_end' => $end->timestamp,
|
|
]],
|
|
])->assertOk()->assertJson(['applied' => true]);
|
|
|
|
$subscription->refresh();
|
|
|
|
expect($subscription->current_period_end->timestamp)->toBe($end->timestamp)
|
|
->and($subscription->stripe_status)->toBe('active');
|
|
|
|
$record = SubscriptionRecord::query()->where('event', 'renewal')->sole();
|
|
|
|
// The invoice is the authority for the amount; the contract for the terms.
|
|
expect($record->gross_cents)->toBe(21480)
|
|
->and($record->stripe_invoice_id)->toBe('in_100')
|
|
->and($record->plan_key)->toBe('team');
|
|
});
|
|
|
|
it('records one renewal however many times Stripe delivers the invoice', function () {
|
|
stripeContract();
|
|
|
|
$event = [
|
|
'id' => 'evt_dup', 'type' => 'invoice.paid',
|
|
'data' => ['object' => [
|
|
'id' => 'in_dup', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
];
|
|
|
|
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
|
|
$this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['applied' => false]);
|
|
|
|
expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1);
|
|
});
|
|
|
|
it('records a failed payment without cutting the customer off', function () {
|
|
$subscription = stripeContract();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_fail', 'type' => 'invoice.payment_failed',
|
|
'data' => ['object' => ['id' => 'in_fail', 'subscription' => 'sub_1', 'attempt_count' => 1]],
|
|
])->assertOk();
|
|
|
|
$subscription->refresh();
|
|
|
|
// Stripe runs the dunning schedule. Cutting someone off on the first
|
|
// failed attempt would punish an expired card as a refusal to pay.
|
|
expect($subscription->stripe_status)->toBe('past_due')
|
|
->and($subscription->status)->toBe('active')
|
|
->and(SubscriptionRecord::query()->where('event', 'payment_failed')->sole()->stripe_invoice_id)
|
|
->toBe('in_fail');
|
|
});
|
|
|
|
it('ends the contract, and records it, when Stripe ends the subscription', function () {
|
|
$subscription = stripeContract();
|
|
$endedAt = now()->addDay();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
|
|
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
|
|
])->assertOk()->assertJson(['applied' => true]);
|
|
|
|
$subscription->refresh();
|
|
|
|
expect($subscription->status)->toBe('cancelled')
|
|
->and($subscription->cancelled_at->timestamp)->toBe($endedAt->timestamp);
|
|
|
|
$record = SubscriptionRecord::query()->where('event', 'cancellation')->sole();
|
|
expect($record->plan_key)->toBe('team');
|
|
|
|
// A retry must not record the ending twice.
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
|
|
'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
|
|
])->assertOk()->assertJson(['applied' => false]);
|
|
|
|
expect(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
|
|
});
|
|
|
|
it('copies Stripe\'s status without handing it the keys', function () {
|
|
$subscription = stripeContract();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_upd', 'type' => 'customer.subscription.updated',
|
|
'data' => ['object' => [
|
|
'id' => 'sub_1', 'status' => 'past_due',
|
|
'current_period_start' => now()->timestamp,
|
|
'current_period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
])->assertOk();
|
|
|
|
$subscription->refresh();
|
|
|
|
// They are the authority on whether the money arrived; we stay the
|
|
// authority on what the customer may use.
|
|
expect($subscription->stripe_status)->toBe('past_due')
|
|
->and($subscription->status)->toBe('active');
|
|
});
|
|
|
|
it('carries the Stripe subscription from the checkout onto the contract', function () {
|
|
Queue::fake();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_new', 'type' => 'checkout.session.completed',
|
|
'data' => ['object' => [
|
|
'id' => 'cs_new', 'payment_status' => 'paid', 'subscription' => 'sub_new',
|
|
'customer_details' => ['email' => 'neu@example.com'],
|
|
'amount_total' => 21480, 'currency' => 'eur',
|
|
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
|
|
]],
|
|
])->assertOk();
|
|
|
|
// Without this, no later billing event could be matched to the contract.
|
|
expect(Subscription::query()->sole()->stripe_subscription_id)->toBe('sub_new');
|
|
});
|
|
|
|
it('never rewrites the snapshot when a renewal arrives', function () {
|
|
$subscription = stripeContract();
|
|
|
|
// The catalogue moves on between terms.
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$current = $catalogue->currentVersion('team');
|
|
$catalogue->schedule($current, $current->available_from, now());
|
|
|
|
$dearer = PlanVersion::query()->create([
|
|
...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
|
|
'version' => 2, 'features' => $current->features, 'available_from' => now(),
|
|
]);
|
|
$dearer->prices()->create(['term' => 'monthly', 'amount_cents' => 29900, 'currency' => 'EUR']);
|
|
$dearer->prices()->create(['term' => 'yearly', 'amount_cents' => 358800, 'currency' => 'EUR']);
|
|
$catalogue->publish($dearer, now());
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_ren', 'type' => 'invoice.paid',
|
|
'data' => ['object' => [
|
|
'id' => 'in_ren', 'subscription' => 'sub_1', 'amount_paid' => 21480,
|
|
'billing_reason' => 'subscription_cycle',
|
|
'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
|
|
]],
|
|
])->assertOk();
|
|
|
|
// A renewal moves the month, not the terms.
|
|
expect($subscription->fresh()->price_cents)->toBe(17900)
|
|
->and($subscription->fresh()->plan_version_id)->toBe($current->id);
|
|
});
|