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