diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 57df6de..98caa46 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -68,6 +68,12 @@ class Billing extends Component $this->dispatch('notify', message: __('billing.purchased')); } + #[\Livewire\Attributes\On('order-removed')] + public function orderRemoved(): void + { + $this->dispatch('notify', message: __('billing.cart.removed')); + } + public function render() { $customer = $this->customer(); diff --git a/app/Livewire/ConfirmRemoveOrder.php b/app/Livewire/ConfirmRemoveOrder.php new file mode 100644 index 0000000..e541214 --- /dev/null +++ b/app/Livewire/ConfirmRemoveOrder.php @@ -0,0 +1,73 @@ +order($uuid); + abort_if($order === null, 404); + + $this->uuid = $uuid; + $this->label = $order->label(); + $this->amountCents = $order->amount_cents; + } + + public function remove(): void + { + $order = $this->order($this->uuid); + + // Gone or already paid for: say so instead of pretending it worked. + if ($order === null) { + $this->dispatch('notify', message: __('billing.cart.gone')); + $this->closeModal(); + + return; + } + + $order->delete(); + + $this->dispatch('order-removed'); + $this->closeModal(); + } + + /** @return Order|null the customer's own, still-pending order */ + private function order(string $uuid): ?Order + { + $customer = $this->customer(); + if ($customer === null) { + return null; + } + + return Order::query() + ->where('customer_id', $customer->id) + ->where('uuid', $uuid) + ->where('status', 'pending') + ->first(); + } + + public function render() + { + return view('livewire.confirm-remove-order'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index d972a9c..5cb03a8 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -25,6 +25,30 @@ class Order extends Model implements ProvisioningSubject return ['amount_cents' => 'integer']; } + /** + * What this order actually buys, in words. + * + * Lives on the model rather than in the view: the cart, the invoice list and + * any future confirmation mail must not each invent their own wording for + * the same row. + */ + public function label(): string + { + return match ($this->type) { + 'upgrade' => __('billing.cart.upgrade', ['plan' => ucfirst($this->plan)]), + 'storage' => __('billing.cart.storage', ['gb' => (int) config('provisioning.storage_addon.gb', 100)]), + 'traffic' => __('billing.cart.traffic', ['gb' => (int) config('provisioning.traffic.addon.gb', 1000)]), + 'addon' => __('billing.addon.'.$this->addon_key.'.name'), + default => __('billing.cart.plan', ['plan' => ucfirst($this->plan)]), + }; + } + + /** Only a pending order is still the customer's to change. */ + public function isRemovable(): bool + { + return $this->status === 'pending'; + } + public function customer(): BelongsTo { return $this->belongsTo(Customer::class); diff --git a/lang/de/billing.php b/lang/de/billing.php index 3fed853..8ec321f 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -1,6 +1,23 @@ [ + 'title' => 'Vorgemerkte Käufe', + 'subtitle' => 'Wird nach Zahlung aktiviert', + 'added' => 'vorgemerkt am :when', + 'total' => 'Summe', + 'remove' => 'Entfernen', + 'removed' => 'Aus den vorgemerkten Käufen entfernt.', + 'gone' => 'Dieser Eintrag ist nicht mehr vorgemerkt.', + 'keep' => 'Behalten', + 'remove_title' => 'Eintrag entfernen?', + 'remove_body' => '„:item" wird aus den vorgemerkten Käufen genommen. Sie können es jederzeit erneut auswählen.', + 'remove_confirm' => 'Entfernen', + 'upgrade' => 'Wechsel auf Paket :plan', + 'storage' => ':gb GB Zusatzspeicher', + 'traffic' => ':gb GB Datenvolumen', + 'plan' => 'Paket :plan', + ], 'traffic_title' => 'Datenvolumen', 'traffic_body' => ':gb GB zusätzliches Volumen für diesen Monat, :price.', 'traffic_used' => ':used von :quota verbraucht', diff --git a/lang/en/billing.php b/lang/en/billing.php index 306295d..a4a1788 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -1,6 +1,23 @@ [ + 'title' => 'Pending purchases', + 'subtitle' => 'Activated once paid', + 'added' => 'added :when', + 'total' => 'Total', + 'remove' => 'Remove', + 'removed' => 'Removed from your pending purchases.', + 'gone' => 'That entry is no longer pending.', + 'keep' => 'Keep', + 'remove_title' => 'Remove this entry?', + 'remove_body' => '":item" will be taken out of your pending purchases. You can add it again at any time.', + 'remove_confirm' => 'Remove', + 'upgrade' => 'Change to the :plan plan', + 'storage' => ':gb GB extra storage', + 'traffic' => ':gb GB data transfer', + 'plan' => ':plan plan', + ], 'traffic_title' => 'Data transfer', 'traffic_body' => ':gb GB of extra volume for this month, :price.', 'traffic_used' => ':used of :quota used', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 3b3c86f..cb00d7a 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -34,10 +34,39 @@ + {{-- Cart: "5 purchases pending" told nobody what they had ordered, and + offered no way to change their mind. --}} @if ($pending->isNotEmpty()) -
- -

{{ __('billing.pending_note', ['count' => $pending->count()]) }}

+
+
+ +

{{ __('billing.cart.title') }}

+ {{ __('billing.cart.subtitle') }} +
+ +
    + @foreach ($pending as $order) +
  • +
    +

    {{ $order->label() }}

    +

    + {{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }} +

    +
    +

    {{ $eur($order->amount_cents) }}

    + +
  • + @endforeach +
+ +
+ {{ __('billing.cart.total') }} + {{ $eur($pending->sum('amount_cents')) }} +
@endif diff --git a/resources/views/livewire/confirm-remove-order.blade.php b/resources/views/livewire/confirm-remove-order.blade.php new file mode 100644 index 0000000..1cd0173 --- /dev/null +++ b/resources/views/livewire/confirm-remove-order.blade.php @@ -0,0 +1,17 @@ +
+
+
+ +
+
+

{{ __('billing.cart.remove_title') }}

+

{{ __('billing.cart.remove_body', ['item' => $label]) }}

+
+
+
+ {{ __('billing.cart.keep') }} + + {{ __('billing.cart.remove_confirm') }} + +
+
diff --git a/tests/Feature/CartTest.php b/tests/Feature/CartTest.php new file mode 100644 index 0000000..da968f5 --- /dev/null +++ b/tests/Feature/CartTest.php @@ -0,0 +1,72 @@ +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(); +});