207 lines
9.1 KiB
PHP
207 lines
9.1 KiB
PHP
<?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'));
|
||
});
|
||
|
||
it('links to the checkout with one query string, not two question marks', function () {
|
||
// Both parameters are query parameters, so route() + '?term=' built
|
||
// `?plan=team?term=monthly` and the package arrived as the whole string
|
||
// "team?term=monthly". Every click landed on "this package is not on sale",
|
||
// and nothing in the page or the log said why.
|
||
$page = $this->actingAs(termBuyer())->get(route('order'))->assertOk()->getContent();
|
||
|
||
preg_match_all('/href="([^"]*checkout[^"]*)"/', $page, $plain);
|
||
preg_match_all('/x-bind:href="\x27([^\x27]*)\x27/', $page, $bound);
|
||
|
||
expect($plain[1])->not->toBeEmpty()
|
||
->and($bound[1])->not->toBeEmpty();
|
||
|
||
foreach (array_merge($plain[1], $bound[1]) as $href) {
|
||
expect(substr_count($href, '?'))->toBe(1, $href);
|
||
}
|
||
});
|
||
|
||
it('keeps the saving above the switch, where it cannot move the buttons', function () {
|
||
// It hung underneath and was hidden on the yearly view, so choosing a term
|
||
// moved the very buttons that choose it.
|
||
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php'));
|
||
|
||
expect(strpos($page, "order.free_months_hint"))->toBeLessThan(strpos($page, "order.term_'.\$option"))
|
||
// And it is always there: one line, two wordings, no appearing and
|
||
// disappearing.
|
||
->and($page)->toContain('order.free_months_taken');
|
||
});
|
||
|
||
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');
|
||
});
|