Skip to content

Commit 494e728

Browse files
committed
feat(finance): Phase 52 — Customer Portal with token-based access
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f7bb222 commit 494e728

9 files changed

Lines changed: 604 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\CustomerPortalToken;
8+
use App\Modules\Finance\Models\Invoice;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class CustomerPortalController extends Controller
15+
{
16+
/**
17+
* Admin action: generate a portal token for a contact.
18+
* Requires authenticated user with finance.create permission.
19+
*/
20+
public function generateToken(Request $request, Contact $contact): RedirectResponse
21+
{
22+
$this->authorize('create', Invoice::class);
23+
24+
$validated = $request->validate([
25+
'email' => ['required', 'email'],
26+
'expires_days' => ['nullable', 'integer', 'min:1', 'max:365'],
27+
]);
28+
29+
$days = $validated['expires_days'] ?? 30;
30+
31+
$token = CustomerPortalToken::generate(
32+
$contact->tenant_id,
33+
$contact->id,
34+
$validated['email'],
35+
(int) $days,
36+
);
37+
38+
return redirect()->back()->with('portal_url', route('portal.show', $token->token));
39+
}
40+
41+
/**
42+
* Public portal: show the customer's invoices.
43+
*/
44+
public function show(string $token): Response
45+
{
46+
$portalToken = CustomerPortalToken::where('token', $token)->firstOrFail();
47+
48+
if ($portalToken->is_expired) {
49+
abort(403, 'This portal link has expired.');
50+
}
51+
52+
$portalToken->update(['last_accessed_at' => now()]);
53+
54+
$contact = $portalToken->contact()->with([
55+
'invoices' => function ($query) {
56+
$query->whereIn('status', ['sent', 'partial', 'paid'])
57+
->latest('issue_date')
58+
->limit(20);
59+
},
60+
])->firstOrFail();
61+
62+
return Inertia::render('Portal/Show', [
63+
'token' => $token,
64+
'contact' => [
65+
'id' => $contact->id,
66+
'name' => $contact->name,
67+
],
68+
'invoices' => $contact->invoices->map(fn (Invoice $inv) => [
69+
'id' => $inv->id,
70+
'number' => $inv->number,
71+
'issue_date' => $inv->issue_date?->toDateString(),
72+
'due_date' => $inv->due_date?->toDateString(),
73+
'status' => $inv->status,
74+
'total' => $inv->total,
75+
'amount_due' => $inv->amount_due,
76+
]),
77+
]);
78+
}
79+
80+
/**
81+
* Public portal: show a specific invoice detail.
82+
*/
83+
public function invoice(string $token, int $invoiceId): Response
84+
{
85+
$portalToken = CustomerPortalToken::where('token', $token)->firstOrFail();
86+
87+
if ($portalToken->is_expired) {
88+
abort(403, 'This portal link has expired.');
89+
}
90+
91+
$invoice = Invoice::with('items')->findOrFail($invoiceId);
92+
93+
if ($invoice->contact_id !== $portalToken->contact_id) {
94+
abort(403, 'Access denied.');
95+
}
96+
97+
return Inertia::render('Portal/Invoice', [
98+
'token' => $token,
99+
'invoice' => [
100+
'id' => $invoice->id,
101+
'number' => $invoice->number,
102+
'issue_date' => $invoice->issue_date?->toDateString(),
103+
'due_date' => $invoice->due_date?->toDateString(),
104+
'status' => $invoice->status,
105+
'notes' => $invoice->notes,
106+
'subtotal' => $invoice->subtotal,
107+
'tax_total' => $invoice->tax_total,
108+
'total' => $invoice->total,
109+
'amount_due' => $invoice->amount_due,
110+
'items' => $invoice->items->map(fn ($item) => [
111+
'id' => $item->id,
112+
'description' => $item->description,
113+
'quantity' => $item->quantity,
114+
'unit_price' => $item->unit_price,
115+
'tax_rate' => $item->tax_rate,
116+
'line_total' => $item->line_total,
117+
]),
118+
],
119+
'contact' => [
120+
'id' => $portalToken->contact->id,
121+
'name' => $portalToken->contact->name,
122+
],
123+
]);
124+
}
125+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Support\Str;
9+
10+
class CustomerPortalToken extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'contact_id',
17+
'token',
18+
'email',
19+
'expires_at',
20+
'last_accessed_at',
21+
];
22+
23+
protected $casts = [
24+
'expires_at' => 'datetime',
25+
'last_accessed_at' => 'datetime',
26+
];
27+
28+
public function contact(): BelongsTo
29+
{
30+
return $this->belongsTo(Contact::class);
31+
}
32+
33+
public function getIsExpiredAttribute(): bool
34+
{
35+
if ($this->expires_at === null) {
36+
return false;
37+
}
38+
39+
return $this->expires_at->isPast();
40+
}
41+
42+
public static function generate(int $tenantId, int $contactId, string $email, int $days = 30): self
43+
{
44+
$token = bin2hex(random_bytes(32)); // 64-char hex string
45+
46+
return static::create([
47+
'tenant_id' => $tenantId,
48+
'contact_id' => $contactId,
49+
'token' => $token,
50+
'email' => $email,
51+
'expires_at' => now()->addDays($days),
52+
]);
53+
}
54+
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,7 @@
184184
Route::post('vendors/{contact}/evaluations', [VendorEvaluationController::class, 'store'])->name('vendors.evaluations.store');
185185
Route::delete('vendors/{contact}/evaluations/{evaluation}', [VendorEvaluationController::class, 'destroy'])->name('vendors.evaluations.destroy');
186186

187+
// Customer Portal Token (admin generates token for a contact)
188+
Route::post('contacts/{contact}/portal-token', [\App\Modules\Finance\Http\Controllers\CustomerPortalController::class, 'generateToken'])->name('contacts.portal-token');
187189

188190
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('customer_portal_tokens', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('contact_id')->constrained('contacts')->cascadeOnDelete();
15+
$table->string('token', 64)->unique();
16+
$table->string('email');
17+
$table->timestamp('expires_at')->nullable();
18+
$table->timestamp('last_accessed_at')->nullable();
19+
$table->timestamps();
20+
21+
$table->index('tenant_id');
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('customer_portal_tokens');
28+
}
29+
};
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { Head, Link } from '@inertiajs/react';
2+
3+
interface InvoiceItem {
4+
id: number;
5+
description: string;
6+
quantity: number;
7+
unit_price: number;
8+
tax_rate: number;
9+
line_total?: number;
10+
}
11+
12+
interface InvoiceDetail {
13+
id: number;
14+
number?: string;
15+
issue_date: string;
16+
due_date?: string | null;
17+
status: string;
18+
notes?: string | null;
19+
subtotal?: number;
20+
tax_total?: number;
21+
total?: number;
22+
amount_due?: number;
23+
items: InvoiceItem[];
24+
}
25+
26+
interface Contact {
27+
id: number;
28+
name: string;
29+
}
30+
31+
interface Props {
32+
token: string;
33+
invoice: InvoiceDetail;
34+
contact: Contact;
35+
}
36+
37+
const STATUS_COLORS: Record<string, string> = {
38+
sent: 'bg-blue-100 text-blue-800',
39+
partial: 'bg-yellow-100 text-yellow-800',
40+
paid: 'bg-green-100 text-green-800',
41+
cancelled: 'bg-red-100 text-red-800',
42+
draft: 'bg-gray-100 text-gray-800',
43+
};
44+
45+
function fmt(n?: number | null): string {
46+
if (n === undefined || n === null) return '—';
47+
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
48+
}
49+
50+
export default function PortalInvoice({ token, invoice, contact }: Props) {
51+
const invLabel = invoice.number ?? `INV-${invoice.id}`;
52+
53+
return (
54+
<>
55+
<Head title={`Invoice ${invLabel} — Customer Portal`} />
56+
57+
<div className="min-h-screen bg-gray-50">
58+
{/* Nav bar */}
59+
<nav className="bg-white border-b border-gray-200 shadow-sm">
60+
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
61+
<span className="text-lg font-semibold text-gray-800">Customer Portal</span>
62+
<span className="text-sm text-gray-500">{contact.name}</span>
63+
</div>
64+
</nav>
65+
66+
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
67+
{/* Back link */}
68+
<Link
69+
href={`/portal/${token}`}
70+
className="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 hover:underline mb-6"
71+
>
72+
&larr; Back to portal
73+
</Link>
74+
75+
<div className="bg-white shadow rounded-lg p-6 sm:p-8">
76+
{/* Invoice header */}
77+
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4 mb-8">
78+
<div>
79+
<h1 className="text-2xl font-bold text-gray-900">{invLabel}</h1>
80+
<div className="mt-2 space-y-1 text-sm text-gray-600">
81+
<div><span className="font-medium">Issue Date:</span> {invoice.issue_date}</div>
82+
{invoice.due_date && (
83+
<div><span className="font-medium">Due Date:</span> {invoice.due_date}</div>
84+
)}
85+
</div>
86+
</div>
87+
<div className="flex items-start">
88+
<span className={`inline-flex px-3 py-1 rounded-full text-sm font-semibold ${STATUS_COLORS[invoice.status] ?? 'bg-gray-100 text-gray-700'}`}>
89+
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
90+
</span>
91+
</div>
92+
</div>
93+
94+
{/* Line items */}
95+
<div className="mb-8">
96+
<table className="min-w-full divide-y divide-gray-200">
97+
<thead className="bg-gray-50">
98+
<tr>
99+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
100+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Qty</th>
101+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unit Price</th>
102+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Tax %</th>
103+
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
104+
</tr>
105+
</thead>
106+
<tbody className="bg-white divide-y divide-gray-200">
107+
{invoice.items.map((item) => (
108+
<tr key={item.id}>
109+
<td className="px-4 py-3 text-sm text-gray-900">{item.description}</td>
110+
<td className="px-4 py-3 text-sm text-gray-700 text-right">{item.quantity}</td>
111+
<td className="px-4 py-3 text-sm text-gray-700 text-right">{fmt(item.unit_price)}</td>
112+
<td className="px-4 py-3 text-sm text-gray-700 text-right">{item.tax_rate}%</td>
113+
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">{fmt(item.line_total)}</td>
114+
</tr>
115+
))}
116+
</tbody>
117+
</table>
118+
</div>
119+
120+
{/* Totals */}
121+
<div className="flex justify-end">
122+
<div className="w-64 space-y-2 text-sm">
123+
<div className="flex justify-between text-gray-600">
124+
<span>Subtotal</span>
125+
<span>{fmt(invoice.subtotal)}</span>
126+
</div>
127+
<div className="flex justify-between text-gray-600">
128+
<span>Tax</span>
129+
<span>{fmt(invoice.tax_total)}</span>
130+
</div>
131+
<div className="flex justify-between font-bold text-gray-900 border-t border-gray-200 pt-2">
132+
<span>Total</span>
133+
<span>{fmt(invoice.total)}</span>
134+
</div>
135+
<div className="flex justify-between text-indigo-700 font-semibold">
136+
<span>Amount Due</span>
137+
<span>{fmt(invoice.amount_due)}</span>
138+
</div>
139+
</div>
140+
</div>
141+
142+
{/* Notes */}
143+
{invoice.notes && (
144+
<div className="mt-8 pt-6 border-t border-gray-200">
145+
<h3 className="text-sm font-medium text-gray-700 mb-1">Notes</h3>
146+
<p className="text-sm text-gray-600 whitespace-pre-line">{invoice.notes}</p>
147+
</div>
148+
)}
149+
</div>
150+
</main>
151+
</div>
152+
</>
153+
);
154+
}

0 commit comments

Comments
 (0)