create(); $user = User::factory()->create(['email' => $customer->email]); return [$customer, $user]; } it('says what was ordered instead of counting it', function () { [$customer, $user] = cartCustomer(); Order::factory()->for($customer)->create(['type' => 'traffic', 'plan' => 'team', 'amount_cents' => 500, 'status' => 'pending']); Order::factory()->for($customer)->create(['type' => 'upgrade', 'plan' => 'business', 'amount_cents' => 39900, 'status' => 'pending']); Livewire\Livewire::actingAs($user)->test(Billing::class) ->assertSee(__('billing.cart.title')) ->assertSee(__('billing.cart.traffic', ['gb' => 1000])) ->assertSee(__('billing.cart.upgrade', ['plan' => 'Business'])) // …and what it all comes to. ->assertSee('404,00'); }); it('leaves paid orders out of the cart', function () { [$customer, $user] = cartCustomer(); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'paid']); Livewire\Livewire::actingAs($user)->test(Billing::class)->assertDontSee(__('billing.cart.title')); }); it('removes an entry the customer changed their mind about', function () { [$customer, $user] = cartCustomer(); $order = Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); Livewire\Livewire::actingAs($user) ->test(ConfirmRemoveOrder::class, ['uuid' => $order->uuid]) ->assertSee(__('billing.cart.storage', ['gb' => 100])) ->call('remove'); expect(Order::query()->count())->toBe(0); }); it('will not let one customer remove another customer\'s order', function () { [, $user] = cartCustomer(); $stranger = Customer::factory()->create(); $order = Order::factory()->for($stranger)->create(['status' => 'pending']); // Modals are reachable without the page's guards, so this is the real check. Livewire\Livewire::actingAs($user) ->test(ConfirmRemoveOrder::class, ['uuid' => $order->uuid]) ->assertNotFound(); expect(Order::query()->whereKey($order->id)->exists())->toBeTrue(); }); it('refuses to remove an order that is already paid', function () { [$customer, $user] = cartCustomer(); $order = Order::factory()->for($customer)->create(['status' => 'paid']); Livewire\Livewire::actingAs($user) ->test(ConfirmRemoveOrder::class, ['uuid' => $order->uuid]) ->assertNotFound(); expect(Order::query()->whereKey($order->id)->exists())->toBeTrue(); }); it('keeps at most one plan change in the cart', function () { [$customer, $user] = cartCustomer(); App\Models\Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']); $component = Livewire\Livewire::actingAs($user)->test(Billing::class); $component->call('purchase', 'upgrade', 'business'); $component->call('purchase', 'upgrade', 'enterprise'); // Two contradicting plan changes cannot both be paid for — the second // replaces the first instead of stacking. $upgrades = Order::query()->where('type', 'upgrade')->where('status', 'pending')->get(); expect($upgrades)->toHaveCount(1) ->and($upgrades->first()->plan)->toBe('enterprise'); }); it('still lets add-ons stack', function () { [$customer, $user] = cartCustomer(); App\Models\Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']); $component = Livewire\Livewire::actingAs($user)->test(Billing::class); $component->call('purchase', 'storage'); $component->call('purchase', 'storage'); $component->call('purchase', 'traffic'); // Buying 200 GB as two packs is a perfectly sensible thing to want. expect(Order::query()->where('type', 'storage')->count())->toBe(2) ->and(Order::query()->where('type', 'traffic')->count())->toBe(1); }); it('says net, gross and how often for every price', function () { [$customer, $user] = cartCustomer(); config()->set('provisioning.tax.rate_percent', 20); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); Order::factory()->for($customer)->create(['type' => 'traffic', 'amount_cents' => 500, 'status' => 'pending']); Livewire\Livewire::actingAs($user)->test(Billing::class) ->assertSee(__('billing.cart.net')) ->assertSee(__('billing.cart.per_month')) // storage recurs ->assertSee(__('billing.cart.once')) // traffic does not ->assertSee('12,00') // 15,00 net → 18,00 gross ->assertSee('18,00') ->assertSee(__('billing.cart.total_gross')); }); it('separates what recurs from a one-off top-up', function () { [$customer, $user] = cartCustomer(); config()->set('provisioning.tax.rate_percent', 20); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); Order::factory()->for($customer)->create(['type' => 'traffic', 'amount_cents' => 500, 'status' => 'pending']); // 10,00 net recurring → 12,00 gross per month, not the 18,00 total. // Formatted through the same helper the view uses, or the non-breaking // space in the currency makes this a test about whitespace. $expected = __('billing.cart.recurring_note', [ 'amount' => Illuminate\Support\Number::currency(12.00, in: 'EUR', locale: 'de'), ]); Livewire\Livewire::actingAs($user)->test(Billing::class)->assertSee($expected); }); it('keeps the recurring figure consistent with the total to the cent', function () { [$customer, $user] = cartCustomer(); config()->set('provisioning.tax.rate_percent', 20); // Cent-level prices: rounding after aggregating instead of per order makes // the two figures disagree, and a customer who spots that stops trusting // every other number on the page. Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 3, 'status' => 'pending']); $expected = Illuminate\Support\Number::currency(0.08, in: 'EUR', locale: 'de'); Livewire\Livewire::actingAs($user)->test(Billing::class) ->assertSee($expected) // total gross ->assertSee(__('billing.cart.recurring_note', ['amount' => $expected])); // and the note }); it('does not charge Austrian VAT to an EU business with a VAT ID', function () { config()->set('provisioning.tax.rate_percent', 20); config()->set('provisioning.tax.seller_country', 'AT'); [$customer, $user] = cartCustomer(); $customer->update(['vat_id' => 'DE 811 907 980', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'DE811907980']); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); // Reverse charge: we invoice net, the customer accounts for the VAT. Livewire\Livewire::actingAs($user)->test(Billing::class) ->assertSee(__('billing.cart.reverse_charge')) ->assertDontSee(__('billing.cart.vat', ['percent' => '20'])); expect(Order::query()->first()->grossCents())->toBe(1000); }); it('charges domestic VAT to a domestic business and to private customers', function () { config()->set('provisioning.tax.rate_percent', 20); config()->set('provisioning.tax.seller_country', 'AT'); [$customer, $user] = cartCustomer(); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); // No VAT ID at all → domestic rate. expect(Order::query()->first()->grossCents())->toBe(1200); // An Austrian VAT ID is a domestic business sale, taxed normally. $customer->update(['vat_id' => 'ATU12345678', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'ATU12345678']); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); Livewire\Livewire::actingAs($user)->test(Billing::class) ->assertSee(__('billing.cart.vat', ['percent' => '20'])); }); it('will not let a typed-in VAT ID zero the tax', function () { config()->set('provisioning.tax.rate_percent', 20); config()->set('provisioning.tax.seller_country', 'AT'); [$customer, ] = cartCustomer(); Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']); // Plausible-looking but unverified: still domestic VAT. $customer->update(['vat_id' => 'DE811907980']); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); // Nonsense, even if someone marked it verified: not an EU member state. $customer->update(['vat_id' => 'XX123', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'XX123']); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); // Verified and from a member state that is not ours: reverse charge. $customer->update(['vat_id' => 'DE811907980', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'DE811907980']); expect(Order::query()->first()->fresh()->grossCents())->toBe(1000); // Swapping the number keeps the old confirmation in the row — it must not // vouch for a value nobody checked. $customer->update(['vat_id' => 'FR12345678901']); expect(Order::query()->first()->fresh()->grossCents())->toBe(1200); });