feat(billing): a cart you can actually read and change
"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 <noreply@anthropic.com>feat/portal-design
parent
a6d7594e5c
commit
d26200c74b
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\ResolvesCustomer;
|
||||
use App\Models\Order;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Taking a not-yet-paid purchase back out of the cart.
|
||||
*
|
||||
* Scoped to the signed-in customer's own orders: modals are reachable without
|
||||
* the page's own guards, so the ownership check belongs here, not in the view
|
||||
* that happens to link to it.
|
||||
*/
|
||||
class ConfirmRemoveOrder extends ModalComponent
|
||||
{
|
||||
use ResolvesCustomer;
|
||||
|
||||
public string $uuid;
|
||||
|
||||
public string $label = '';
|
||||
|
||||
public int $amountCents = 0;
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$order = $this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'cart' => [
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'cart' => [
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -34,10 +34,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cart: "5 purchases pending" told nobody what they had ordered, and
|
||||
offered no way to change their mind. --}}
|
||||
@if ($pending->isNotEmpty())
|
||||
<div class="flex items-start gap-3 rounded-xl border border-info-border bg-info-bg p-4 animate-rise">
|
||||
<x-ui.icon name="bell" class="size-5 shrink-0 text-info" />
|
||||
<p class="text-sm text-body">{{ __('billing.pending_note', ['count' => $pending->count()]) }}</p>
|
||||
<div class="overflow-hidden rounded-xl border border-info-border bg-surface shadow-xs animate-rise">
|
||||
<div class="flex items-center gap-2 border-b border-line bg-info-bg px-5 py-3">
|
||||
<x-ui.icon name="receipt" class="size-4 shrink-0 text-info" />
|
||||
<h2 class="text-sm font-semibold text-ink">{{ __('billing.cart.title') }}</h2>
|
||||
<span class="ml-auto text-xs text-muted">{{ __('billing.cart.subtitle') }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-line">
|
||||
@foreach ($pending as $order)
|
||||
<li wire:key="order-{{ $order->uuid }}" class="flex flex-wrap items-center gap-3 px-5 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink">{{ $order->label() }}</p>
|
||||
<p class="mt-0.5 text-xs text-faint">
|
||||
{{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-body">{{ $eur($order->amount_cents) }}</p>
|
||||
<button type="button" aria-label="{{ __('billing.cart.remove') }}" title="{{ __('billing.cart.remove') }}"
|
||||
x-on:click="$dispatch('openModal', { component: 'confirm-remove-order', arguments: { uuid: '{{ $order->uuid }}' } })"
|
||||
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
|
||||
<x-ui.icon name="trash-2" class="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-line bg-surface-2 px-5 py-3">
|
||||
<span class="text-sm font-medium text-body">{{ __('billing.cart.total') }}</span>
|
||||
<span class="font-mono text-sm font-semibold text-ink">{{ $eur($pending->sum('amount_cents')) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="grid size-10 shrink-0 place-items-center rounded-full border border-danger-border bg-danger-bg">
|
||||
<x-ui.icon name="trash-2" class="size-5 text-danger" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('billing.cart.remove_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('billing.cart.remove_body', ['item' => $label]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('billing.cart.keep') }}</x-ui.button>
|
||||
<x-ui.button variant="danger" wire:click="remove" wire:loading.attr="disabled">
|
||||
{{ __('billing.cart.remove_confirm') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<?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();
|
||||
});
|
||||
Loading…
Reference in New Issue