CluPilotCloud/tests/Feature/Billing/StripeAddonBillingTest.php

525 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Actions\BookAddon;
use App\Actions\GrantSubscription;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\CompanyProfile;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
/**
* A booked module, billed every month, on one document with the package.
*
* Two faults, one cause. Modules were not items on the Stripe subscription at
* all, so a customer who booked storage in March paid for it in March and never
* again — and because the renewal document was written from our own contract
* snapshot, it would not have said so even if they had been charged. Both are
* closed here: the module becomes an item, and the document is built from the
* lines Stripe actually charged.
*
* The owner's rule, in their own example: two months of package, storage booked
* in the middle of month three, and that is four invoices with four numbers —
* three for the package, one for the module the day it was booked — after which
* ONE invoice a month carries both.
*/
beforeEach(function () {
// Fixed, because a proration is a number of days and a document states a
// service period. Both are the point of these tests.
Carbon::setTestNow('2026-03-01 09:00:00');
CompanyProfile::put([
'name' => 'CluPilot Cloud e.U.',
'address' => 'Dreherstraße 66/1/8',
'postcode' => '1110',
'city' => 'Wien',
'vat_id' => 'ATU00000000',
]);
});
afterEach(function () {
Carbon::setTestNow();
});
/** A Stripe that answers, with the catalogue — packages and modules — mirrored into it. */
function addonStripe(): FakeStripeClient
{
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
app(Kernel::class)->call('stripe:sync-catalogue');
return $fake;
}
/** A paying contract with somebody on it to send a document to. */
function addonContract(string $plan = 'team'): Subscription
{
$customer = Customer::factory()->create([
'name' => 'Berger GmbH',
'email' => 'kundin@example.com',
]);
return Subscription::factory()->plan($plan)->create([
'customer_id' => $customer->id,
'stripe_subscription_id' => 'sub_mod',
'stripe_item_id' => 'si_plan',
'current_period_start' => now(),
'current_period_end' => now()->addMonth(),
]);
}
/** The Stripe Price a package is sold at on this term. */
function addonPlanPrice(Subscription $contract): string
{
return (string) PlanPrice::query()
->where('plan_version_id', $contract->plan_version_id)
->where('term', $contract->term)
->value('stripe_price_id');
}
/**
* The Stripe Price a module is sold at, monthly, at today's catalogue figure —
* to a customer who is charged VAT.
*
* There are two Prices per module and interval now: this one and the bare net a
* verified business in another member state is charged. The contracts in this
* file are domestic, so the domestic treatment is the one their items bill on.
*/
function addonModulePrice(string $key, int $monthlyCents): string
{
return (string) app(AddonPrices::class)->liveFor(
$key,
$monthlyCents,
'EUR',
Subscription::TERM_MONTHLY,
TaxTreatment::domestic(),
);
}
/**
* What Stripe actually takes for a net catalogue figure from an ordinary,
* domestic customer.
*
* Their Price carries the GROSS, so every amount on a Stripe invoice line for them
* already contains the VAT. The fixtures below state the net figure a reader
* recognises from the price sheet and put it through the same call the sync does,
* rather than writing 21480 and leaving the next reader to work out where it came
* from. A reverse-charge business is charged the bare net instead, and that is
* tested where it belongs, in ReverseChargePriceTest.
*/
function atTheTill(int $netCents): int
{
return TaxTreatment::advertisedCents($netCents);
}
/**
* One line as Stripe puts it on an invoice.
*
* @return array<string, mixed>
*/
function stripeLine(string $priceId, int $amount, Carbon $from, Carbon $to, int $quantity = 1, bool $proration = false): array
{
return [
'id' => 'il_'.substr(sha1($priceId.$amount.$from->timestamp), 0, 8),
'amount' => $amount,
'quantity' => $quantity,
'proration' => $proration,
'period' => ['start' => $from->timestamp, 'end' => $to->timestamp],
'price' => ['id' => $priceId, 'metadata' => []],
'description' => 'Stripes own wording, which no customer should have to read',
];
}
/**
* The `invoice.paid` event, carrying the lines Stripe charged for.
*
* @param array<int, array<string, mixed>> $lines
* @return array<string, mixed>
*/
function paidInvoice(string $id, array $lines, string $reason, Carbon $from, Carbon $to): array
{
// What Stripe took IS the sum of its lines. The catalogue is pushed gross,
// so there is nothing to add on top — and the document has to total to this
// figure exactly, which is what these tests are checking.
$charged = array_sum(array_column($lines, 'amount'));
return [
'id' => 'evt_'.$id, 'type' => 'invoice.paid',
'data' => ['object' => [
'id' => $id,
'subscription' => 'sub_mod',
'billing_reason' => $reason,
'currency' => 'eur',
'amount_paid' => $charged,
'total' => $charged,
'period_start' => $from->timestamp,
'period_end' => $to->timestamp,
'lines' => ['data' => $lines, 'has_more' => false],
]],
];
}
it('gives the owners example four invoices, and puts package and module on the fourth', function () {
Mail::fake();
Queue::fake();
$stripe = addonStripe();
$contract = addonContract();
$planPrice = addonPlanPrice($contract);
$month1 = now()->copy();
$month2 = $month1->copy()->addMonth();
$month3 = $month2->copy()->addMonth();
$month4 = $month3->copy()->addMonth();
// Month one and month two: the package on its own, one document each.
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m1', [stripeLine($planPrice, atTheTill(17900), $month1, $month2)], 'subscription_cycle', $month1, $month2,
))->assertOk();
Carbon::setTestNow($month2);
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m2', [stripeLine($planPrice, atTheTill(17900), $month2, $month3)], 'subscription_cycle', $month2, $month3,
))->assertOk();
expect(Invoice::query()->count())->toBe(2);
// The middle of month three: the customer books a storage pack. The item
// goes onto the subscription and Stripe settles it there and then —
// always_invoice, which prorates by days AND charges immediately, so the
// booking produces its own invoice rather than a credit line a month later.
Carbon::setTestNow($month3->copy()->addDays(14));
Subscription::query()->whereKey($contract->id)->update([
'current_period_start' => $month3, 'current_period_end' => $month4,
]);
app(BookAddon::class)($contract->fresh(), 'storage', 1);
expect($stripe->itemCalls)->toHaveCount(1)
->and($stripe->itemCalls[0]['call'])->toBe('add')
->and($stripe->itemCalls[0]['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY)
->and($stripe->itemCalls[0]['price'])->toBe(addonModulePrice('storage', 1000));
// Which is the invoice Stripe then raises: half a month of the module, on
// its own, in the middle of a term. The owner's fourth document.
$storagePrice = addonModulePrice('storage', 1000);
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m3_storage',
[stripeLine($storagePrice, atTheTill(484), now(), $month4, proration: true)],
'subscription_update',
now(),
$month4,
))->assertOk();
// From month four the package and the module are charged together, so they
// are ONE document — which is exactly what the owner asked for, and what
// separate invoices per line would not have been.
Carbon::setTestNow($month4);
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_m4',
[
stripeLine($planPrice, atTheTill(17900), $month4, $month4->copy()->addMonth()),
stripeLine($storagePrice, atTheTill(1000), $month4, $month4->copy()->addMonth()),
],
'subscription_cycle',
$month4,
$month4->copy()->addMonth(),
))->assertOk();
$invoices = Invoice::query()->orderBy('id')->get();
// Four documents, four numbers, in one unbroken series.
expect($invoices)->toHaveCount(4)
->and($invoices->pluck('number')->all())->toBe([
'RE-2026-0001', 'RE-2026-0002', 'RE-2026-0003', 'RE-2026-0004',
]);
// The first three carry one line each: package, package, module.
expect($invoices[0]->snapshot['lines'])->toHaveCount(1)
->and($invoices[1]->snapshot['lines'])->toHaveCount(1)
->and($invoices[2]->snapshot['lines'])->toHaveCount(1)
->and($invoices[2]->snapshot['lines'][0]['description'])->toBe(__('billing.storage_title'))
// Prorated by days, and it says so — a figure that is not the monthly
// price the customer knows has to explain itself.
->and($invoices[2]->net_cents)->toBe(484)
->and($invoices[2]->snapshot['lines'][0]['details'])->toContain(__('invoice.line_prorated'));
// And the fourth carries both, on one document with one number.
$fourth = $invoices[3];
expect($fourth->snapshot['lines'])->toHaveCount(2)
->and(array_column($fourth->snapshot['lines'], 'description'))
->toBe([__('billing.cart.plan', ['plan' => 'Team']), __('billing.storage_title')])
// Stripe took 226,80 € and the document says 226,80 €, split into the
// net and the VAT that were inside it. The catalogue is pushed gross, so
// the total is not something the document adds up to — it is the figure
// it starts from and divides.
->and($fourth->gross_cents)->toBe(atTheTill(17900) + atTheTill(1000))
->and($fourth->gross_cents)->toBe(22680)
->and($fourth->net_cents)->toBe(18900)
->and($fourth->tax_cents)->toBe(3780);
// One mail per document, never two for one.
Mail::assertQueuedCount(4);
});
it('issues one document and consumes one number however often Stripe redelivers a module invoice', function () {
Mail::fake();
Queue::fake();
addonStripe();
$contract = addonContract();
$event = paidInvoice(
'in_twice',
[stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())],
'subscription_cycle',
now(),
now()->addMonth(),
);
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
// An invoice number comes out of a gapless series and can never be handed
// out twice — so the second delivery must not take one, not even one it
// then throws away.
expect(Invoice::query()->count())->toBe(1)
->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(2);
Mail::assertQueuedCount(1);
});
it('keeps a cancelled module until the period end and leaves it off the next cycle invoice', function () {
Mail::fake();
Queue::fake();
$stripe = addonStripe();
$contract = addonContract();
$addon = app(BookAddon::class)($contract, 'storage', 1);
$item = $addon->fresh()->stripe_item_id;
expect($item)->not->toBeNull()
->and($stripe->itemsOn('sub_mod'))->toHaveCount(1);
// Cancelled in the middle of the term the customer has already paid for.
Carbon::setTestNow(now()->addDays(10));
app(BookAddon::class)->cancelAtPeriodEnd($addon->fresh());
$addon->refresh();
// They keep it: the booking is still running, with an appointment for the
// end of the term. Nothing was credited — the item simply came off, so the
// NEXT cycle is smaller and this one is untouched.
expect($addon->cancelled_at)->toBeNull()
->and($addon->cancels_at->timestamp)->toBe($contract->current_period_end->timestamp)
->and($stripe->itemsOn('sub_mod'))->toHaveCount(0)
->and(collect($stripe->itemCalls)->last())
->toBe(['call' => 'remove', 'item' => $item, 'proration' => StripeClient::PRORATE_NONE]);
// The cycle turns. Stripe bills the package alone, and the appointment is
// kept: the module stops being held as well as being billed.
Carbon::setTestNow($contract->current_period_end->copy()->addMinute());
$this->artisan('clupilot:end-cancelled-addons')->assertSuccessful();
expect($addon->fresh()->cancelled_at)->not->toBeNull();
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_after_cancel',
[stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth())],
'subscription_cycle',
now(),
now()->addMonth(),
))->assertOk();
$invoice = Invoice::query()->latest('id')->sole();
expect($invoice->snapshot['lines'])->toHaveCount(1)
->and($invoice->net_cents)->toBe(17900);
});
it('books a second storage pack as a quantity rather than a second item', function () {
Mail::fake();
Queue::fake();
$stripe = addonStripe();
$contract = addonContract();
app(BookAddon::class)($contract, 'storage', 1);
app(BookAddon::class)($contract->fresh(), 'storage', 1);
// Two packs are twice as much storage on one item, not two items at the
// same price — Stripe allows the latter and nothing could ever tell the two
// apart afterwards.
$items = $stripe->itemsOn('sub_mod');
expect($items)->toHaveCount(1)
->and(reset($items)['quantity'])->toBe(2)
->and(collect($stripe->itemCalls)->last()['call'])->toBe('quantity')
// More of a pack is had now and paid for the days that are left.
->and(collect($stripe->itemCalls)->last()['proration'])->toBe(StripeClient::PRORATE_IMMEDIATELY);
// Both bookings point at the one item, so a third would join it too.
expect(SubscriptionAddon::query()->pluck('stripe_item_id')->unique())->toHaveCount(1);
// And the document says two, at the unit price, so the reader can multiply.
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_packs',
[
stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()),
stripeLine(addonModulePrice('storage', 1000), atTheTill(2000), now(), now()->addMonth(), quantity: 2),
],
'subscription_cycle',
now(),
now()->addMonth(),
))->assertOk();
$storage = collect(Invoice::query()->sole()->snapshot['lines'])
->firstWhere('description', __('billing.storage_title'));
expect($storage['quantity_milli'])->toBe(2000)
->and($storage['unit_net_cents'])->toBe(1000);
});
it('books a module onto a granted contract without asking Stripe anything', function () {
fakeServices();
Queue::fake();
$stripe = addonStripe();
// Any call at all would throw and be parked as a failure, so a clean
// contract afterwards is proof that none was made.
$stripe->failWith = 'Stripe must not be called for a granted contract.';
$customer = Customer::factory()->create();
$granted = app(GrantSubscription::class)($customer, admin(), 'team', 'monthly', 'fsn', 0);
$addon = app(BookAddon::class)($granted->fresh(), 'storage', 1);
expect($addon->exists)->toBeTrue()
->and($addon->fresh()->stripe_item_id)->toBeNull()
->and($stripe->itemCalls)->toBeEmpty()
->and($granted->fresh()->stripe_addon_sync)->toBeNull();
// And the sweep leaves it alone too: a contract Stripe never sold is not
// out of step with Stripe.
$this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful();
expect($stripe->itemCalls)->toBeEmpty();
});
it('does not undo a booking Stripe could not be told about, and finishes it on the sweep', function () {
Mail::fake();
Queue::fake();
$stripe = addonStripe();
$contract = addonContract();
$stripe->failWith = 'Connection timed out';
$addon = app(BookAddon::class)($contract, 'storage', 1);
// The pack is booked and, on a running machine, already delivered. Rolling
// that back because an API was away would take storage off a customer who
// is holding it.
expect($addon->fresh()->isActive())->toBeTrue()
->and($addon->fresh()->stripe_item_id)->toBeNull();
// And what did not happen is on the contract, not only in a log line.
expect($contract->fresh()->stripe_addon_sync['error'])->toContain('Connection timed out')
->and($contract->fresh()->stripe_addon_sync['attempts'])->toBe(1);
// The hourly sweep finishes it once Stripe answers again — the same one
// that retries a plan change that never landed.
$stripe->failWith = null;
$this->artisan('clupilot:sync-stripe-subscriptions')->assertSuccessful();
expect($stripe->itemsOn('sub_mod'))->toHaveCount(1)
->and($addon->fresh()->stripe_item_id)->not->toBeNull()
->and($contract->fresh()->stripe_addon_sync)->toBeNull();
});
it('gives an upgrades proration a document for the amount Stripe actually charged', function () {
Mail::fake();
Queue::fake();
addonStripe();
$contract = addonContract();
$business = (string) PlanPrice::query()
->where('plan_version_id', Subscription::snapshotFrom('business')['plan_version_id'])
->where('term', 'monthly')
->value('stripe_price_id');
// Stripe worked this out against ITS boundaries: the days left on the small
// package credited, the days left on the big one charged. A document written
// from our contract snapshot would state a sum nobody was charged, which is
// why this used to get no document at all — and why that was wrong.
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_upgrade',
[
stripeLine(addonPlanPrice($contract), atTheTill(-8950), now(), now()->addDays(15), proration: true),
stripeLine($business, atTheTill(17450), now(), now()->addDays(15), proration: true),
],
'subscription_update',
now(),
now()->addDays(15),
))->assertOk();
$invoice = Invoice::query()->sole();
expect($invoice->snapshot['lines'])->toHaveCount(2)
->and($invoice->net_cents)->toBe(8500)
->and($invoice->tax_cents)->toBe(1700)
->and($invoice->gross_cents)->toBe(10200)
->and(array_column($invoice->snapshot['lines'], 'description'))->toBe([
__('billing.cart.plan', ['plan' => 'Team']),
__('billing.cart.plan', ['plan' => 'Business']),
]);
Mail::assertQueuedCount(1);
});
it('keeps a line it cannot name rather than dropping it from the document', function () {
Mail::fake();
Queue::fake();
addonStripe();
$contract = addonContract();
// A Price created by hand in Stripe's dashboard: money was taken for
// something this catalogue has never heard of. Leaving it off would give the
// customer a document whose total they cannot account for.
$this->postJson(route('webhooks.stripe'), paidInvoice(
'in_unknown',
[
stripeLine(addonPlanPrice($contract), atTheTill(17900), now(), now()->addMonth()),
stripeLine('price_typed_by_hand', atTheTill(5000), now(), now()->addMonth()),
],
'subscription_cycle',
now(),
now()->addMonth(),
))->assertOk();
$invoice = Invoice::query()->sole();
expect($invoice->snapshot['lines'])->toHaveCount(2)
->and($invoice->net_cents)->toBe(22900)
// Under Stripe's own description, which is at least true, rather than
// under a guess or not at all.
->and($invoice->snapshot['lines'][1]['description'])
->toBe('Stripes own wording, which no customer should have to read');
});