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'); });