CluPilotCloud/tests/Feature/Billing/SetupFeeTest.php

157 lines
6.7 KiB
PHP

<?php
use App\Livewire\Order as OrderPage;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* The setup fee is charged, because it is advertised.
*
* It sat on the public price sheet ("Einrichtung kostet einmalig 118,80 €") and on
* the booking page ("zzgl. Einrichtung einmalig") for months, the operator set the
* figure in the console, and nothing anywhere ever charged it: the checkout posted
* one recurring line item and there was no invoice item, no one-off line and no
* reader of the figure outside the two sentences that quoted it.
*
* It rides along as a second, non-recurring line on the same Stripe session, which
* Stripe puts on the initial invoice only — charged once, with the first month, and
* never again.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
DB::table('plan_prices')->update(['stripe_price_id' => 'price_test']);
CompanyProfile::put([
'name' => 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8',
'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000',
]);
// 99 € net, which is the 118,80 € the price sheet quotes at 20 %.
Settings::set('company.setup_fee_cents', 9900);
});
function setupFeeBuyer(): User
{
return User::factory()->create(['email_verified_at' => now()]);
}
it('charges the fee it advertises, as a one-off line beside the monthly one', function () {
$this->actingAs(setupFeeBuyer())
->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1'])
->assertRedirect();
$checkout = $this->stripe->checkouts[0];
// Gross, like every other figure this platform charges: the price on the page
// is the price at the till, for everybody.
expect($checkout['one_off'])->not->toBeNull()
->and($checkout['one_off']['amount_cents'])->toBe(11880)
->and($checkout['one_off']['currency'])->toBe('EUR')
->and($checkout['one_off']['label'])->toBe(__('checkout.setup_fee_line'))
// And the figure travels in the metadata, because the webhook has to be
// able to tell the fee back out of Stripe's amount_total.
->and($checkout['metadata']['setup_fee_cents'])->toBe('11880');
});
it('sends no line at all when the operator charges no setup fee', function () {
// Zero is not "a line for nought" on a checkout — it is a charge that does not
// exist, and itemising it would tell the customer they owe nothing for
// something.
Settings::set('company.setup_fee_cents', 0);
$this->actingAs(setupFeeBuyer())->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1']);
expect($this->stripe->checkouts[0]['one_off'])->toBeNull()
// Stated as zero rather than left out. The webhook reads it either way,
// and an explicit nought says this session was priced with no fee — as
// opposed to having been opened by something that knew nothing about fees.
->and($this->stripe->checkouts[0]['metadata']['setup_fee_cents'])->toBe('0');
});
it('quotes the same figure on the booking page as the website and the till', function () {
// The page was printing the NET figure under "zzgl. Einrichtung einmalig",
// so the website said 118,80 € and the page with the button on it said 99 €.
Livewire::actingAs(setupFeeBuyer())->test(OrderPage::class)
->assertViewHas('setup', 11880)
->assertSee('118,80');
});
it('puts the fee on the first invoice as its own line, and nowhere else', function () {
Queue::fake();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_setup',
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_setup',
'payment_status' => 'paid',
'customer_details' => ['email' => 'einrichtung@example.com', 'name' => 'Kanzlei Berger'],
// What Stripe took: 179,00 + 20 % for the package, plus 99,00 + 20 %
// once for the setting-up.
'amount_total' => 21480 + 11880,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn', 'setup_fee_cents' => '11880'],
]],
])->assertOk();
$order = Order::query()->where('stripe_event_id', 'cs_setup')->sole();
// The whole of what was taken stays on the order — that is what a withdrawal
// sends back — with the fee recorded beside it rather than subtracted from it.
expect($order->amount_cents)->toBe(33360)
->and($order->setup_fee_cents)->toBe(11880)
->and($order->packageChargedCents())->toBe(21480);
$invoice = Invoice::query()->where('order_id', $order->id)->sole();
$lines = $invoice->snapshot['lines'];
// Two lines, adding up to exactly what the card was charged. A fee folded into
// the package's line would be a charge the customer can see on their bank
// statement and cannot find on their invoice.
expect($lines)->toHaveCount(2)
->and($lines[1]['description'])->toBe(__('checkout.setup_fee_line'))
->and($invoice->gross_cents)->toBe(33360);
// And the register holds the PACKAGE's charge against the contract's price, so
// a sale with a setup fee on it is not reported as charged at the wrong amount.
$record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_PURCHASE)->sole();
expect($record->snapshot['amounts']['charged_gross_cents'])->toBe(21480)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue();
});
it('never lets a session claim more setup fee than it charged in total', function () {
Queue::fake();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_setup_wild',
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_setup_wild',
'payment_status' => 'paid',
'customer_details' => ['email' => 'wild@example.com'],
'amount_total' => 21480,
'currency' => 'eur',
// A figure no session of ours would carry. Unclamped it would put a
// negative price on the contract's own invoice line.
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn', 'setup_fee_cents' => '99999'],
]],
])->assertOk();
$order = Order::query()->where('stripe_event_id', 'cs_setup_wild')->sole();
expect($order->setup_fee_cents)->toBe(21480)
->and($order->packageChargedCents())->toBe(0);
});