335 lines
14 KiB
PHP
335 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\PlanPrice;
|
|
use App\Models\Subscription;
|
|
use App\Models\User;
|
|
use App\Services\Stripe\FakeStripeClient;
|
|
use App\Services\Stripe\StripeClient;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* A customer buys a package themselves.
|
|
*
|
|
* Every path on the site used to end in "anfragen" — a mailto: link and an
|
|
* afternoon of somebody's time per customer, for a product whose whole point is
|
|
* that the machine does the work. Now: sign in, pick, pay, and provisioning
|
|
* starts on the webhook. The one case that still needs a person is no capacity,
|
|
* and it needs them to buy a server, not to answer a mail.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->stripe = new FakeStripeClient;
|
|
app()->instance(StripeClient::class, $this->stripe);
|
|
|
|
// Every sellable price needs a Stripe id, or there is nothing to check out
|
|
// against. The catalogue sync fills these in production.
|
|
DB::table('plan_prices')->update(['stripe_price_id' => 'price_test']);
|
|
});
|
|
|
|
/**
|
|
* A checkout post, consent and all.
|
|
*
|
|
* The express request that we begin inside the withdrawal window is required
|
|
* (FAGG §16): without it on record a withdrawal on day thirteen takes back the
|
|
* WHOLE amount however long the machine ran, because a seller who cannot prove
|
|
* the consent cannot charge for the days.
|
|
*/
|
|
function buying(string $plan): array
|
|
{
|
|
return ['plan' => $plan, 'immediate_start' => '1'];
|
|
}
|
|
|
|
/** A signed-in, verified portal user. */
|
|
function buyer(): User
|
|
{
|
|
return User::factory()->create(['email_verified_at' => now()]);
|
|
}
|
|
|
|
it('opens a Stripe checkout for the package that was chosen', function () {
|
|
$this->actingAs(buyer())
|
|
->post(route('checkout.start'), buying('start'))
|
|
->assertRedirect();
|
|
|
|
expect($this->stripe->checkouts)->toHaveCount(1)
|
|
->and($this->stripe->checkouts[0]['price'])->toBe('price_test');
|
|
});
|
|
|
|
it('carries exactly the metadata the webhook reads back', function () {
|
|
// plan, plan_version_id and datacenter are the contract between the
|
|
// checkout and StripeWebhookController. A missing key does not fail — it
|
|
// silently builds the wrong package.
|
|
$this->actingAs(buyer())->post(route('checkout.start'), buying('team'));
|
|
|
|
$meta = $this->stripe->checkouts[0]['metadata'];
|
|
|
|
expect($meta)->toHaveKeys(['plan', 'plan_version_id', 'datacenter'])
|
|
->and($meta['plan'])->toBe('team')
|
|
->and($meta['plan_version_id'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('sends the customer to Stripe with the address their credentials will go to', function () {
|
|
$user = buyer();
|
|
|
|
$this->actingAs($user)->post(route('checkout.start'), buying('start'));
|
|
|
|
expect($this->stripe->checkouts[0]['email'])->toBe($user->email);
|
|
});
|
|
|
|
it('never lets a request name its own price', function () {
|
|
// The plan key comes from a form, and a form field is a string somebody can
|
|
// retype. What is charged is the catalogue's figure for the version on sale
|
|
// — nothing in the request may decide it.
|
|
$this->actingAs(buyer())->post(route('checkout.start'), buying('start') + [
|
|
'amount_cents' => 1,
|
|
'price' => 'price_of_my_choosing',
|
|
]);
|
|
|
|
expect($this->stripe->checkouts[0]['price'])->toBe('price_test');
|
|
});
|
|
|
|
it('refuses a plan that is not on sale', function () {
|
|
$this->actingAs(buyer())
|
|
->post(route('checkout.start'), buying('does-not-exist'))
|
|
->assertSessionHasErrors('plan');
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('refuses a plan that was never synced to Stripe', function () {
|
|
// A price with no Stripe id would open a checkout for nothing.
|
|
PlanPrice::query()->update(['stripe_price_id' => null]);
|
|
|
|
$this->actingAs(buyer())
|
|
->post(route('checkout.start'), buying('start'))
|
|
->assertSessionHasErrors('plan');
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('says so rather than redirecting into a broken checkout when Stripe is not connected', function () {
|
|
$this->stripe->configured = false;
|
|
|
|
$this->actingAs(buyer())
|
|
->post(route('checkout.start'), buying('start'))
|
|
->assertSessionHasErrors('plan');
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('sends an existing customer to the plan change instead of selling a second cloud', function () {
|
|
// A second checkout would build a second, empty cloud and bill for both.
|
|
// Moving up is a plan change: it prorates and the data stays where it is.
|
|
$user = buyer();
|
|
$customer = Customer::factory()->create(['email' => $user->email]);
|
|
Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']);
|
|
|
|
$this->actingAs($user)
|
|
->post(route('checkout.start'), buying('team'))
|
|
->assertRedirect(route('billing'));
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('lets nobody buy anything without signing in first', function () {
|
|
// The credentials for the finished cloud are mailed to the account's
|
|
// address, so the account has to exist and be confirmed before the money
|
|
// moves — not afterwards.
|
|
$this->post(route('checkout.start'), buying('start'))->assertRedirect(route('login'));
|
|
$this->get(route('order'))->assertRedirect(route('login'));
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('does not take an unverified address through checkout', function () {
|
|
$unverified = User::factory()->create(['email_verified_at' => null]);
|
|
|
|
$this->actingAs($unverified)->post(route('checkout.start'), buying('start'))
|
|
->assertRedirect(route('verification.notice'));
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('shows the packages with the same gross prices the public sheet quotes', function () {
|
|
// A customer quoted 58,80 € out there must not meet 49 € on the page with
|
|
// the button on it.
|
|
$this->actingAs(buyer())
|
|
->get(route('order'))
|
|
->assertOk()
|
|
->assertSee('58,80')
|
|
->assertSee(__('order.buy'));
|
|
});
|
|
|
|
it('promises delivery on the booking page from the estate, like everywhere else', function () {
|
|
$this->actingAs(buyer())
|
|
->get(route('order'))
|
|
->assertOk()
|
|
// No host is onboarded in this test, so nothing can be immediate.
|
|
->assertSee(__('delivery.scheduled'))
|
|
->assertDontSee(__('delivery.immediate'));
|
|
});
|
|
|
|
it('points the website buttons at the sign-in rather than an inbox', function () {
|
|
// Booking happens in the panel, where everything already works. The website
|
|
// does not need a second checkout — it needs to get somebody to the door.
|
|
$content = $this->get('/')->assertOk()->getContent();
|
|
|
|
expect($content)->toContain(route('login'))
|
|
// Not straight at /order: that is behind auth, so an anonymous visitor
|
|
// would be bounced by a middleware redirect from a page they never
|
|
// asked for.
|
|
->and($content)->not->toContain('href="'.route('order').'"')
|
|
// And not back to the old shape: every card ending in a jump to a
|
|
// mailto: block.
|
|
->and($content)->not->toContain('anfragen</x-ui.button>');
|
|
});
|
|
|
|
it('does not promise a migration it has not looked at', function () {
|
|
// We do not know where a visitor's data is, what shape it is in, or whether
|
|
// it can be moved at all. "danach wissen Sie … wie Ihre Daten sicher
|
|
// umziehen" was a promise made before seeing the thing it is about.
|
|
expect($this->get('/')->getContent())
|
|
->not->toContain('wie Ihre Daten sicher umziehen');
|
|
});
|
|
|
|
it('refuses to open a checkout without the express request to begin at once', function () {
|
|
// The gap this closes: the webhook has always read `immediate_start` off the
|
|
// session metadata, and the checkout never put it there. So every contract
|
|
// was concluded WITHOUT recorded consent, and every withdrawal — even on day
|
|
// thirteen of a running cloud — had to refund the full amount.
|
|
$this->actingAs(buyer())
|
|
->post(route('checkout.start'), ['plan' => 'start'])
|
|
->assertSessionHasErrors('immediate_start');
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('does not take a box that was merely present as a yes', function () {
|
|
// "accepted", not "present": an empty field is what an unticked checkbox
|
|
// sends, and a consent that can be produced by leaving something blank is
|
|
// not a consent.
|
|
foreach (['', '0', 'nein'] as $value) {
|
|
$this->actingAs(buyer())
|
|
->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => $value])
|
|
->assertSessionHasErrors('immediate_start');
|
|
}
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('carries the consent to Stripe as exactly the string the webhook compares against', function () {
|
|
// StripeWebhookController tests `=== '1'` on purpose: a consent that can be
|
|
// produced by a typo is not a consent. Anything else here silently becomes
|
|
// "no consent" months later, at the one moment it matters.
|
|
$this->actingAs(buyer())->post(route('checkout.start'), buying('start'));
|
|
|
|
expect($this->stripe->checkouts[0]['metadata']['immediate_start'])->toBe('1');
|
|
});
|
|
|
|
it('puts the consent in front of the customer, in the law own words', function () {
|
|
// On the page, once, next to the buttons — not buried in terms nobody
|
|
// opens. The sentence is the statutory one, and the buy buttons stay
|
|
// disabled until it is ticked.
|
|
$this->actingAs(buyer())
|
|
->get(route('order'))
|
|
->assertOk()
|
|
->assertSee(__('checkout.immediate_start'))
|
|
->assertSee('immediate_start', false);
|
|
});
|
|
|
|
it('lands as a timestamp on the order, which is what a withdrawal is measured against', function () {
|
|
// The whole chain, end to end: the checkout puts `immediate_start` on the
|
|
// session, Stripe hands it back on the webhook, and the order carries the
|
|
// moment the consumer asked us to begin. Without that timestamp
|
|
// WithdrawalRight refunds everything — the two ends were wired to each
|
|
// other through a field nobody was sending.
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_consent',
|
|
'type' => 'checkout.session.completed',
|
|
'data' => ['object' => [
|
|
'id' => 'cs_evt_consent',
|
|
'payment_status' => 'paid',
|
|
'customer_details' => ['email' => 'kunde@example.test', 'name' => 'Kanzlei Berger'],
|
|
'amount_total' => 4900,
|
|
'currency' => 'eur',
|
|
// Exactly what CheckoutController::start() sends.
|
|
'metadata' => ['plan' => 'start', 'datacenter' => 'fsn', 'immediate_start' => '1'],
|
|
]],
|
|
])->assertOk();
|
|
|
|
$order = App\Models\Order::query()->where('stripe_event_id', 'cs_evt_consent')->first();
|
|
|
|
expect($order)->not->toBeNull()
|
|
->and($order->immediate_start_consent_at)->not->toBeNull();
|
|
});
|
|
|
|
it('records no consent for a session that never carried one', function () {
|
|
// The other direction, and the one that must stay safe: an old session, or
|
|
// a checkout opened outside this application, records nothing — and a
|
|
// withdrawal against it refunds in full.
|
|
Illuminate\Support\Facades\Queue::fake();
|
|
|
|
$this->postJson(route('webhooks.stripe'), [
|
|
'id' => 'evt_no_consent',
|
|
'type' => 'checkout.session.completed',
|
|
'data' => ['object' => [
|
|
'id' => 'cs_evt_no_consent',
|
|
'payment_status' => 'paid',
|
|
'customer_details' => ['email' => 'zweiter@example.test', 'name' => 'Zweite Kanzlei'],
|
|
'amount_total' => 4900,
|
|
'currency' => 'eur',
|
|
'metadata' => ['plan' => 'start', 'datacenter' => 'fsn'],
|
|
]],
|
|
])->assertOk();
|
|
|
|
expect(App\Models\Order::query()->where('stripe_event_id', 'cs_evt_no_consent')->first()?->immediate_start_consent_at)
|
|
->toBeNull();
|
|
});
|
|
|
|
it('asks for the consent before the packages, not after them', function () {
|
|
// The consent used to sit at the bottom with every buy button disabled until
|
|
// it was ticked, so the page arrived looking broken. Read in the order it
|
|
// applies: the condition first, the choice second — and the button stays
|
|
// enabled, with the reason it will not go through said next to it.
|
|
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php'));
|
|
|
|
$consentAt = strpos($page, "__('checkout.immediate_start')");
|
|
$gridAt = strpos($page, 'lg:grid-cols-3');
|
|
|
|
expect($consentAt)->toBeLessThan($gridAt)
|
|
// No disabled button: a disabled control explains nothing.
|
|
->and($page)->not->toContain('x-bind:disabled="!immediateStart"')
|
|
->and($page)->toContain("__('order.consent_first')");
|
|
});
|
|
|
|
it('lays five packages out as three and two', function () {
|
|
// A fifth card alone on a row of four reads as a mistake.
|
|
expect(Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php')))
|
|
->toContain('lg:grid-cols-3')
|
|
->not->toContain('lg:grid-cols-4');
|
|
});
|
|
|
|
it('still refuses the checkout when the box was not ticked', function () {
|
|
// The layout changed; the rule did not. The server is the gate.
|
|
$this->actingAs(buyer())
|
|
->post(route('checkout.start'), ['plan' => 'start'])
|
|
->assertSessionHasErrors('immediate_start');
|
|
|
|
expect($this->stripe->checkouts)->toBeEmpty();
|
|
});
|
|
|
|
it('renders inside the app shell, not the bare sign-in one', function () {
|
|
// layouts.portal is what the sign-in pages use: no navigation, no page
|
|
// padding. In it, this page sat flush against the top and bottom of the
|
|
// window with no way back to anything.
|
|
expect(Illuminate\Support\Facades\File::get(app_path('Livewire/Order.php')))
|
|
->toContain("#[Layout('layouts.portal-app')]");
|
|
|
|
// And the shell is actually there: the navigation proves it.
|
|
$this->actingAs(buyer())
|
|
->get(route('order'))
|
|
->assertOk()
|
|
->assertSee(__('dashboard.nav.overview'));
|
|
});
|