CluPilotCloud/tests/Feature/Billing/PaidOrderFulfilmentTest.php

149 lines
6.0 KiB
PHP

<?php
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Billing\AddonCatalogue;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Whatever marks an order paid delivers what the order bought.
*
* The seam existed for exactly one type. OrderObserver fired only for `upgrade`,
* so a module, a storage pack or a traffic pack marked paid — by an operator, by
* a seeder, by the second checkout when it is built — reached nothing at all:
* there was no path from a paid module order to a SubscriptionAddon anywhere in
* the application, which left AddonPrices, SyncStripeAddonItems and
* clupilot:end-cancelled-addons with no reachable caller behind a payment.
*
* **Nothing in this application marks a cart order paid yet.** That is why the
* trigger hangs on the fact rather than on a call site: these tests do by hand
* what the missing checkout will do, and the delivery is written once, where it
* cannot be forgotten by whichever thing eventually pays.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
});
/** A customer on a live contract, with a machine. */
function paidOrderCustomer(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;
$subscription->update(['instance_id' => $instance->id, 'stripe_subscription_id' => 'sub_'.$customer->id]);
return [$customer, $user, $subscription->fresh()];
}
it('books the module a paid order bought', function () {
Queue::fake();
[, $user, $subscription] = paidOrderCustomer();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'collabora_pro');
$order = Order::query()->where('type', 'addon')->sole();
expect(SubscriptionAddon::query()->count())->toBe(0);
$order->update(['status' => 'paid']);
$addon = SubscriptionAddon::query()->sole();
expect($addon->addon_key)->toBe('collabora_pro')
->and($addon->order_id)->toBe($order->id)
// Frozen at the price the customer was shown, and entered in the register.
->and($addon->price_cents)->toBe(1900)
->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_ADDON_BOOKED)->count())->toBe(1)
// And it is on the Stripe subscription, which is what makes it recur.
->and($this->stripe->subscriptionItems)->toHaveCount(1);
// Delivered once, however often the row is touched again.
$order->update(['status' => 'paid', 'updated_at' => now()->addMinute()]);
expect(SubscriptionAddon::query()->count())->toBe(1);
});
it('books the storage pack a paid order bought, one per order', function () {
Queue::fake();
[, $user, $subscription] = paidOrderCustomer();
// Two packs is two orders, because that is how the shop writes them.
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'storage', null, 2);
Order::query()->where('type', 'storage')->get()->each->update(['status' => 'paid']);
$packs = SubscriptionAddon::query()->where('addon_key', AddonCatalogue::STORAGE)->get();
expect($packs)->toHaveCount(2)
->and($packs->pluck('order_id')->unique())->toHaveCount(2);
});
it('delivers nothing for a downgrade, which is never paid for', function () {
Queue::fake();
[, $user, $subscription] = paidOrderCustomer();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'downgrade', 'team');
// A downgrade lands at the end of the term from the date stamped on the
// contract. Marked paid by hand it must not move the package today.
Order::query()->where('type', 'downgrade')->sole()->update(['status' => 'paid']);
expect($subscription->fresh()->plan)->toBe('business')
->and($subscription->fresh()->pending_plan)->toBe('team');
});
it('says out loud that a paid traffic pack has nowhere to go', function () {
Queue::fake();
[, $user] = paidOrderCustomer();
Log::spy();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'traffic');
Order::query()->where('type', 'traffic')->sole()->update(['status' => 'paid']);
// The metered allowance is a standing count of packs on the instance, while a
// top-up is sold as a one-off for the month running out — so raising it would
// hand over the extra thousand gigabytes every month for ever off one payment,
// and doing nothing leaves the customer paying for nothing. Which of the two
// is meant is a commercial decision, and until it is made the order has to be
// impossible to miss.
Log::shouldHaveReceived('error')
->withArgs(fn (string $message) => str_contains($message, 'no fulfilment path'))
->once();
});
it('never lets a failed delivery undo the record that money changed hands', function () {
Queue::fake();
[$customer, $user] = paidOrderCustomer();
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'collabora_pro');
$order = Order::query()->where('type', 'addon')->sole();
// The contract goes before the order is settled. Booking will refuse, and the
// order must still be paid: that write is the evidence of the payment, and
// rolling it back would erase the payment and leave nothing to retry from.
Subscription::query()->where('customer_id', $customer->id)->delete();
$order->update(['status' => 'paid']);
expect($order->fresh()->status)->toBe('paid')
->and(SubscriptionAddon::query()->count())->toBe(0);
});