510 lines
20 KiB
PHP
510 lines
20 KiB
PHP
<?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. */
|
||
function addonModulePrice(string $key, int $monthlyCents): string
|
||
{
|
||
return (string) app(AddonPrices::class)->liveFor($key, $monthlyCents, 'EUR', Subscription::TERM_MONTHLY);
|
||
}
|
||
|
||
/**
|
||
* What Stripe actually takes for a net catalogue figure.
|
||
*
|
||
* The catalogue is pushed to Stripe GROSS, so every amount on a Stripe invoice
|
||
* line 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.
|
||
*/
|
||
function atTheTill(int $netCents): int
|
||
{
|
||
return TaxTreatment::chargedCents($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' => 'Stripe’s 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 owner’s 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 upgrade’s 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('Stripe’s own wording, which no customer should have to read');
|
||
});
|