CluPilotCloud/tests/Feature/Billing/PendingPlanChangeTest.php

218 lines
8.9 KiB
PHP

<?php
use App\Actions\ApplyStripeBillingEvent;
use App\Livewire\Admin\Customers;
use App\Livewire\Billing;
use App\Livewire\ConfirmRemoveOrder;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* A downgrade that was booked lands on the day it was booked for.
*
* It used to land whenever `current_period_end` said so, and Stripe moves that
* column forward on every renewal — so a downgrade booked in March was deferred
* to April by April's renewal and to May by May's, while the customer went on
* being billed for the package they had asked to leave. The due date is now
* decided once, at the moment the customer decides, and stamped on the contract.
*
* What is proved here is the customer's experience of that: the date does not
* move, a second decision replaces the first rather than queueing behind it,
* changing their mind the other way cancels it, and everybody who reads the
* contract can see it coming.
*/
/** A customer on Business, with a machine and a live contract. */
function pendingChangeCustomer(string $plan = 'business'): array
{
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
$order = Order::factory()->withSubscription()->for($customer)->create(['plan' => $plan]);
$instance = Instance::factory()->for($customer)->create([
'order_id' => $order->id,
'plan' => $plan,
'status' => 'active',
]);
$subscription = $order->subscription;
// The contract Stripe would be billing: the renewal that used to move the
// due date arrives against this id, so there has to be one.
$subscription->update(['instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_'.$customer->id]);
return [$customer, $user, $subscription->fresh(), $instance];
}
it('applies a booked downgrade on its own date, even after a renewal has moved the period', function () {
Queue::fake();
[, $user, $subscription] = pendingChangeCustomer();
$bookedFor = $subscription->current_period_end;
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
expect($subscription->fresh()->pending_plan)->toBe('team')
->and($subscription->fresh()->pending_effective_at->eq($bookedFor))->toBeTrue();
// The renewal the defect turned on: Stripe bills the term and pushes the
// period out by a month. Nothing about the booking may move with it.
app(ApplyStripeBillingEvent::class)->invoicePaid([
'id' => 'in_renewal',
'subscription' => $subscription->stripe_subscription_id,
'billing_reason' => 'subscription_cycle',
'period_start' => $bookedFor->getTimestamp(),
'period_end' => $bookedFor->copy()->addMonth()->getTimestamp(),
'amount_paid' => 39900,
]);
$renewed = $subscription->fresh();
expect($renewed->current_period_end->gt($bookedFor))->toBeTrue()
->and($renewed->pending_effective_at->eq($bookedFor))->toBeTrue();
// The moment it was booked for, which is now in the middle of the term the
// renewal opened. Before, the customer would have kept Business — and paid
// for it — for another whole month.
Carbon::setTestNow($bookedFor->copy()->addHour());
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
$after = $subscription->fresh();
expect($after->plan)->toBe('team')
->and($after->pending_plan)->toBeNull()
->and($after->pending_effective_at)->toBeNull()
->and(Order::query()->where('type', 'downgrade')->value('status'))->toBe('applied');
Carbon::setTestNow();
});
it('replaces a booked downgrade when the customer picks a different package', function () {
[, $user, $subscription] = pendingChangeCustomer();
$component = Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'downgrade', 'team');
$component->call('purchase', 'downgrade', 'start');
// One booking and one cart entry, not two of either: the second decision is
// the customer's decision, and queueing it behind the first would move them
// down twice.
expect($subscription->fresh()->pending_plan)->toBe('start')
->and(Order::query()->where('type', 'downgrade')->count())->toBe(1)
->and(Order::query()->where('type', 'downgrade')->value('plan'))->toBe('start');
});
it('cancels a booked downgrade when the customer upgrades instead', function () {
Queue::fake();
[, $user, $subscription] = pendingChangeCustomer('team');
$bookedFor = $subscription->current_period_end;
$component = Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'downgrade', 'start');
expect($subscription->fresh()->pending_plan)->toBe('start');
$component->call('purchase', 'upgrade', 'business');
// Left booked, it would have come due months later and undone the bigger
// package the customer had just paid for.
expect($subscription->fresh()->pending_plan)->toBeNull()
->and($subscription->fresh()->pending_effective_at)->toBeNull();
Carbon::setTestNow($bookedFor->copy()->addHour());
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
expect($subscription->fresh()->plan)->toBe('team');
Carbon::setTestNow();
});
it('unbooks the downgrade when the customer takes it back out of the cart', function () {
[, $user, $subscription] = pendingChangeCustomer();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
$order = Order::query()->where('type', 'downgrade')->sole();
Livewire::actingAs($user)
->test(ConfirmRemoveOrder::class, ['uuid' => $order->uuid])
->call('remove');
expect($subscription->fresh()->pending_plan)->toBeNull()
->and($subscription->fresh()->pending_effective_at)->toBeNull();
Carbon::setTestNow($subscription->current_period_end->copy()->addHour());
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
expect($subscription->fresh()->plan)->toBe('business');
Carbon::setTestNow();
});
it('shows the booked downgrade to the customer and to the operator', function () {
[, $user, $subscription] = pendingChangeCustomer();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
$when = $subscription->fresh()->pending_effective_at->local()->isoFormat('LL');
// The customer, on the card that states what they have — the cart entry
// disappears when it is paid for, and the booking does not.
Livewire::actingAs($user)->test(Billing::class)
->assertSee(__('billing.pending_change_note', ['plan' => __('billing.plan.team'), 'date' => $when]));
// And the operator, on the row that answers "what is this customer on",
// because that is where the question gets asked.
Livewire::actingAs(admin(), 'operator')->test(Customers::class)
->assertSee(__('admin.pending_change', ['plan' => __('billing.plan.team'), 'date' => $when]));
});
it('never applies a booked downgrade to a contract that has been cancelled', function () {
Queue::fake();
[, $user, $subscription] = pendingChangeCustomer();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
$bookedFor = $subscription->fresh()->pending_effective_at;
// The customer leaves before the date arrives. Stripe ends the subscription
// and the booking goes with the contract it was made on.
app(ApplyStripeBillingEvent::class)->subscriptionDeleted([
'id' => $subscription->stripe_subscription_id,
'status' => 'canceled',
'ended_at' => now()->getTimestamp(),
]);
Carbon::setTestNow($bookedFor->copy()->addHour());
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
$after = $subscription->fresh();
expect($after->status)->toBe('cancelled')
// Not moved onto a smaller package on the way out, and not left
// announcing a change that can never happen.
->and($after->plan)->toBe('business')
->and($after->pending_plan)->toBeNull()
->and($after->pending_effective_at)->toBeNull();
Carbon::setTestNow();
});
it('refuses to book a downgrade for someone who has no contract to book it on', function () {
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email]);
Instance::factory()->for($customer)->create(['plan' => 'business', 'status' => 'active']);
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
// Accepted before, and it could never be carried out: there was nothing to
// stamp the date on and nothing for the scheduler to find.
expect(Order::query()->where('type', 'downgrade')->exists())->toBeFalse()
->and(Subscription::query()->count())->toBe(0);
});