256 lines
11 KiB
PHP
256 lines
11 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]);
|
|
|
|
// These tests call Billing::purchase() to book the downgrade/upgrade,
|
|
// which since Task 5 refuses outright without a Stripe key for the
|
|
// active mode.
|
|
withStripeSecret();
|
|
|
|
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('keeps a booked downgrade until the upgrade the customer chose instead is actually paid', function () {
|
|
fakeServices();
|
|
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');
|
|
|
|
// The click used to unbook it there and then, and nothing in this
|
|
// application marks a cart order paid — so the customer gave up a downgrade
|
|
// they had genuinely booked in exchange for a cart line that was never
|
|
// fulfilled. The booking stands while the upgrade is only wanted.
|
|
expect($subscription->fresh()->pending_plan)->toBe('start')
|
|
->and($subscription->fresh()->pending_effective_at->eq($bookedFor))->toBeTrue();
|
|
|
|
// Paid. Now the two genuinely contradict each other, and the booking goes:
|
|
// left standing it would come due months later and quietly undo the bigger
|
|
// package the customer has just paid for.
|
|
Order::query()->where('type', 'upgrade')->sole()->update(['status' => 'paid']);
|
|
|
|
expect($subscription->fresh()->plan)->toBe('business')
|
|
->and($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('business');
|
|
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
it('carries out the downgrade the customer kept when the upgrade is never paid', function () {
|
|
Queue::fake();
|
|
[, $user, $subscription] = pendingChangeCustomer('team');
|
|
|
|
$bookedFor = $subscription->current_period_end;
|
|
|
|
$component = Livewire::actingAs($user)->test(Billing::class);
|
|
$component->call('purchase', 'downgrade', 'start');
|
|
$component->call('purchase', 'upgrade', 'business');
|
|
|
|
// Nothing pays the upgrade, which is the state every cart order is in today.
|
|
// What the customer booked is what happens.
|
|
Carbon::setTestNow($bookedFor->copy()->addHour());
|
|
$this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful();
|
|
|
|
expect($subscription->fresh()->plan)->toBe('start')
|
|
->and($subscription->fresh()->pending_plan)->toBeNull();
|
|
|
|
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);
|
|
});
|