231 lines
10 KiB
PHP
231 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Billing;
|
|
use App\Livewire\ConfirmRemoveOrder;
|
|
use App\Models\Customer;
|
|
use App\Models\Order;
|
|
use App\Models\User;
|
|
|
|
function cartCustomer(): array
|
|
{
|
|
$customer = Customer::factory()->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('accepts a verified value stored in display form', 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']);
|
|
|
|
// A verifier may hand back the number the way it prints it; spaces are
|
|
// cosmetic and must not invalidate a genuine confirmation.
|
|
$customer->update([
|
|
'vat_id' => 'DE811907980',
|
|
'vat_id_verified_at' => now(),
|
|
'vat_id_verified_value' => 'DE 811 907 980',
|
|
]);
|
|
|
|
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue()
|
|
->and($customer->fresh()->vat_id_verified_at)->toBeInstanceOf(Illuminate\Support\Carbon::class)
|
|
->and(Order::query()->first()->fresh()->grossCents())->toBe(1000);
|
|
});
|
|
|
|
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);
|
|
});
|