159 lines
6.8 KiB
PHP
159 lines
6.8 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', 'terms_accepted' => '1'])
|
|
->assertRedirect();
|
|
|
|
$checkout = $this->stripe->checkouts[0];
|
|
|
|
// Gross, like every other figure a domestic customer is charged: the price on
|
|
// the page is the price at the till. A verified business in another member
|
|
// state is charged the bare net of the fee, exactly as of the package — see
|
|
// ReverseChargePriceTest.
|
|
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', 'terms_accepted' => '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);
|
|
});
|