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); });