149 lines
6.0 KiB
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);
|
|
});
|