From d26200c74b3f1d890355abdb3d3527b09ef509e5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 05:26:49 +0200 Subject: [PATCH] feat(billing): a cart you can actually read and change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "5 purchases pending" told nobody what they had ordered and offered no way to change their mind. The billing page now lists each pending purchase by name, what it costs, when it was added, and the total — with a remove button per row behind a confirmation, like every other destructive action in the console. The wording lives on the Order model rather than in the view, so the cart, the invoice list and any later confirmation mail cannot each invent their own name for the same row. Removal is scoped to the customer's own still-pending orders, checked in the modal itself: modals are reachable without the page's guards. Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Billing.php | 6 ++ app/Livewire/ConfirmRemoveOrder.php | 73 +++++++++++++++++++ app/Models/Order.php | 24 ++++++ lang/de/billing.php | 17 +++++ lang/en/billing.php | 17 +++++ resources/views/livewire/billing.blade.php | 35 ++++++++- .../livewire/confirm-remove-order.blade.php | 17 +++++ tests/Feature/CartTest.php | 72 ++++++++++++++++++ 8 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 app/Livewire/ConfirmRemoveOrder.php create mode 100644 resources/views/livewire/confirm-remove-order.blade.php create mode 100644 tests/Feature/CartTest.php 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(); +});