CluPilotCloud/tests/Feature/Billing/YearlyTermTest.php

181 lines
7.9 KiB
PHP
Raw 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\Livewire\Admin\PlanVersions;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\PlanCatalogue;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* Paying for a year, and what that is worth saying out loud.
*
* Both prices have existed since the catalogue did — one `plan_prices` row per
* term, each with its own Stripe Price — but only the monthly one was ever shown.
* A customer could not choose the term, the console could only type the yearly
* TOTAL as a second free-form figure, and nothing in the system knew why 588
* belonged to a package costing 49 a month. So no page could say "two months
* free" without a person working it out again by hand.
*
* `free_months` on the version is that missing fact. The total is derived from
* it, which is the only arrangement in which the sentence a customer reads and
* the amount Stripe charges cannot drift apart.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
// A DIFFERENT id per term, unlike the other checkout suites: this one is
// about which of the two is picked, and one id shared by both would make
// every assertion here pass whatever the code did. Two statements rather
// than one concat(): the suite runs on SQLite, which spells that ||.
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
DB::table('plan_prices')->where('term', $term)->update(['stripe_price_id' => 'price_'.$term]);
}
});
function termBuyer(): User
{
return User::factory()->create(['email_verified_at' => now()]);
}
it('derives the year from the month and the free months', function () {
// The arithmetic, in the one place both the form preview and the draft use.
expect(PlanVersions::yearlyCents(4900, 0))->toBe(58800)
->and(PlanVersions::yearlyCents(4900, 2))->toBe(49000)
->and(PlanVersions::yearlyCents(17900, 1))->toBe(196900)
// Out of range is clamped rather than turned into a negative price.
->and(PlanVersions::yearlyCents(4900, 99))->toBe(0);
});
it('offers both terms in the shop, with the figure behind the saving', function () {
$plan = app(PlanCatalogue::class)->sellable()['team'];
expect($plan)->toHaveKeys(['price_cents', 'yearly_price_cents', 'free_months'])
->and($plan['price_cents'])->toBe(17900)
// Twelve for twelve on this installation until somebody sets otherwise —
// what matters is that the figure is READ rather than assumed.
->and($plan['yearly_price_cents'])->toBe(17900 * (12 - $plan['free_months']));
});
it('freezes the free months when the version is published', function () {
// It is part of the terms a customer bought, not presentation: the yearly
// amount was derived from it, and changing it afterwards would rewrite what
// a running contract says it agreed to.
$version = PlanVersion::query()->whereNotNull('published_at')->first();
expect(fn () => $version->update(['free_months' => 3]))
->toThrow(RuntimeException::class);
});
it('lets a customer pick the term on the order page', function () {
$page = $this->actingAs(termBuyer())->get(route('order'))->assertOk()->getContent();
// Both figures rendered, one shown: switching costs no round trip.
expect($page)->toContain("term = 'yearly'")
->and($page)->toContain(__('order.term_yearly'))
// And the choice travels to the checkout page in the address, so a
// reload there still shows what was chosen.
->and($page)->toContain("?term=' + term");
});
it('carries the chosen term into the checkout page and its form', function () {
$page = $this->actingAs(termBuyer())
->get(route('checkout', ['plan' => 'team', 'term' => 'yearly']))
->assertOk()
->getContent();
expect($page)->toContain('name="term" value="yearly"')
// And the yearly figure is the one on the summary: 179,00 × 12 gross.
->and($page)->toContain('2.577,60');
});
it('refuses a term nobody sells even in the address bar', function () {
// A URL is a string somebody can retype. An unknown term falls back to the
// month rather than showing a page priced from nothing.
$page = $this->actingAs(termBuyer())
->get(route('checkout', ['plan' => 'team', 'term' => 'weekly']))
->assertOk()
->getContent();
expect($page)->toContain('name="term" value="monthly"');
});
it('opens the checkout on the yearly Stripe Price when yearly was chosen', function () {
// The one that matters: a term shown but not carried through would charge a
// month at the yearly price or the other way round.
$version = app(PlanCatalogue::class)->currentVersion('team');
$yearly = $version->priceFor(Subscription::TERM_YEARLY);
$this->actingAs(termBuyer())->post(route('checkout.start'), [
'plan' => 'team',
'term' => Subscription::TERM_YEARLY,
'terms_accepted' => '1',
]);
expect($this->stripe->checkouts)->toHaveCount(1)
->and($this->stripe->checkouts[0]['price'])->toBe($yearly->stripe_price_id);
});
it('defaults to the month when no term was sent at all', function () {
// A form with JavaScript off posts an empty term, and "empty" must not mean
// "whichever row comes first".
$version = app(PlanCatalogue::class)->currentVersion('team');
$this->actingAs(termBuyer())->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']);
expect($this->stripe->checkouts[0]['price'])
->toBe($version->priceFor(Subscription::TERM_MONTHLY)->stripe_price_id);
});
it('refuses a term nobody sells', function () {
$this->actingAs(termBuyer())
->post(route('checkout.start'), ['plan' => 'team', 'term' => 'weekly', 'terms_accepted' => '1'])
->assertSessionHasErrors('term');
expect($this->stripe->checkouts)->toBeEmpty();
});
it('shows the yearly price on the public sheet as well', function () {
// "auf der website sowie im userpanel ersichtlich" — the sheet quotes the
// same two figures, from the same catalogue, so the switch a visitor used
// outside and the one in the panel cannot disagree.
$page = $this->get('/')->assertOk()->getContent();
expect($page)->toContain("term = 'yearly'")
// The yearly PRICE as the headline, in the unit it is charged in — the
// monthly equivalent as the headline did not move at all on a package
// with no free months, so the sheet looked like it ignored the click.
->and($page)->toContain('/Jahr')
->and($page)->toContain('entspricht');
});
it('states the term on the billing card, not just a monthly figure', function () {
// A yearly contract is charged once a year while the card says "per month".
// Without this line the customer read a number that never appears on a
// statement.
expect(File::get(resource_path('views/livewire/billing.blade.php')))
->toContain("__('billing.term_yearly_note'")
->and(File::get(app_path('Livewire/Billing.php')))
->toContain("'term' => (string) \$subscription->term");
});
// ---- The console ----
it('asks for free months rather than a second price', function () {
// Typing both totals let them disagree, and nothing then knew which was
// meant. The form takes the monthly price and the months that are free.
$component = File::get(app_path('Livewire/Admin/PlanVersions.php'));
expect($component)->toContain('public int $freeMonths')
->and($component)->not->toContain('public string $yearlyPrice')
->and($component)->toContain("'freeMonths' => 'required|integer|min:0|max:6'");
expect(File::get(resource_path('views/livewire/admin/plan-versions.blade.php')))
->toContain("__('plans.free_months')")
->toContain('yearlyPreview');
});