154 lines
6.7 KiB
PHP
154 lines
6.7 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();
|
|
|
|
expect($page)->toContain("term = 'yearly'")
|
|
// Both figures rendered, one shown: switching costs no round trip, and
|
|
// the form posts the word the customer was looking at.
|
|
->and($page)->toContain('name="term"')
|
|
->and($page)->toContain(__('order.term_yearly'));
|
|
});
|
|
|
|
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'")
|
|
->and($page)->toContain('jährlich');
|
|
});
|
|
|
|
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');
|
|
});
|