Skip to content

Commit f1bc91d

Browse files
committed
feat(finance): Phase 105 — Payment Terms Management
Implements Payment Terms CRUD with model, policy, controller, routes, frontend pages (Index/Show), TypeScript types, sidebar entry, and 10 Pest tests (1080 → 1090 passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent ba020a4 commit f1bc91d

11 files changed

Lines changed: 491 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\PaymentTerm;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class PaymentTermController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', PaymentTerm::class);
17+
18+
$paymentTerms = PaymentTerm::where('is_active', true)
19+
->latest()
20+
->paginate(20)
21+
->withQueryString();
22+
23+
return Inertia::render('Finance/PaymentTerms/Index', [
24+
'paymentTerms' => $paymentTerms,
25+
]);
26+
}
27+
28+
public function store(Request $request): RedirectResponse
29+
{
30+
$this->authorize('create', PaymentTerm::class);
31+
32+
$validated = $request->validate([
33+
'name' => 'required|string|max:100',
34+
'days' => 'required|integer|min:0',
35+
'discount_days' => 'nullable|integer|min:0',
36+
'discount_percent' => 'nullable|numeric|min:0|max:100',
37+
'description' => 'nullable|string',
38+
'is_active' => 'boolean',
39+
]);
40+
41+
PaymentTerm::create([
42+
...$validated,
43+
'tenant_id' => auth()->user()->tenant_id,
44+
]);
45+
46+
return redirect()->back()->with('success', 'Payment term created successfully.');
47+
}
48+
49+
public function show(PaymentTerm $paymentTerm): Response
50+
{
51+
$this->authorize('view', $paymentTerm);
52+
53+
return Inertia::render('Finance/PaymentTerms/Show', [
54+
'paymentTerm' => $paymentTerm,
55+
]);
56+
}
57+
58+
public function update(Request $request, PaymentTerm $paymentTerm): RedirectResponse
59+
{
60+
$this->authorize('update', $paymentTerm);
61+
62+
$validated = $request->validate([
63+
'name' => 'required|string|max:100',
64+
'days' => 'required|integer|min:0',
65+
'discount_days' => 'nullable|integer|min:0',
66+
'discount_percent' => 'nullable|numeric|min:0|max:100',
67+
'description' => 'nullable|string',
68+
'is_active' => 'boolean',
69+
]);
70+
71+
$paymentTerm->update($validated);
72+
73+
return redirect()->back()->with('success', 'Payment term updated successfully.');
74+
}
75+
76+
public function destroy(PaymentTerm $paymentTerm): RedirectResponse
77+
{
78+
$this->authorize('delete', $paymentTerm);
79+
80+
$paymentTerm->delete();
81+
82+
return redirect()->route('finance.payment-terms.index')
83+
->with('success', 'Payment term deleted successfully.');
84+
}
85+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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\SoftDeletes;
8+
use Illuminate\Support\Carbon;
9+
10+
class PaymentTerm extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id', 'name', 'days', 'discount_days', 'discount_percent',
16+
'description', 'is_active',
17+
];
18+
19+
protected $casts = [
20+
'days' => 'integer',
21+
'discount_days' => 'integer',
22+
'discount_percent' => 'float',
23+
'is_active' => 'boolean',
24+
];
25+
26+
public function getDueDate(Carbon $fromDate): Carbon
27+
{
28+
return $fromDate->copy()->addDays($this->days);
29+
}
30+
31+
public function getDiscountDueDate(Carbon $fromDate): Carbon
32+
{
33+
return $fromDate->copy()->addDays($this->discount_days);
34+
}
35+
36+
public function getHasEarlyDiscountAttribute(): bool
37+
{
38+
return $this->discount_days > 0 && $this->discount_percent > 0;
39+
}
40+
41+
public function getDisplayLabelAttribute(): string
42+
{
43+
if ($this->has_early_discount) {
44+
return "{$this->discount_percent}/{$this->discount_days} Net {$this->days}";
45+
}
46+
return "Net {$this->days}";
47+
}
48+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\PaymentTerm;
7+
8+
class PaymentTermPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, PaymentTerm $paymentTerm): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function update(User $user, PaymentTerm $paymentTerm): bool { return $user->can('finance.create'); }
14+
public function delete(User $user, PaymentTerm $paymentTerm): bool { return $user->can('finance.delete'); }
15+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@
8888
use App\Modules\Finance\Policies\VendorBillPolicy;
8989
use App\Modules\Finance\Models\ExpenseItem;
9090
use App\Modules\Finance\Policies\ExpenseClaimPolicy;
91+
use App\Modules\Finance\Models\PaymentTerm;
92+
use App\Modules\Finance\Policies\PaymentTermPolicy;
9193
use Illuminate\Support\Facades\Gate;
9294
use Illuminate\Support\ServiceProvider;
9395

@@ -159,6 +161,7 @@ public function boot(): void
159161
Gate::policy(VendorBill::class, VendorBillPolicy::class);
160162
Gate::policy(VendorBillItem::class, VendorBillPolicy::class);
161163
Gate::policy(ExpenseItem::class, ExpenseClaimPolicy::class);
164+
Gate::policy(PaymentTerm::class, PaymentTermPolicy::class);
162165
if ($this->app->runningInConsole()) {
163166
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
164167
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use App\Modules\Finance\Http\Controllers\LeadController;
4343
use App\Modules\Finance\Http\Controllers\SupportTicketController;
4444
use App\Modules\Finance\Http\Controllers\CurrencyController;
45+
use App\Modules\Finance\Http\Controllers\PaymentTermController;
4546

4647
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
4748

@@ -328,3 +329,8 @@
328329
Route::post('vendor-bills/{vendorBill}/cancel', [VendorBillController::class, 'cancel'])->name('vendor-bills.cancel');
329330
Route::resource('vendor-bills', VendorBillController::class);
330331
});
332+
333+
// Payment Terms
334+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
335+
Route::resource('payment-terms', PaymentTermController::class)->except(['create', 'edit']);
336+
});
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('payment_terms', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->unsignedInteger('days');
16+
$table->unsignedInteger('discount_days')->default(0);
17+
$table->decimal('discount_percent', 5, 2)->default(0);
18+
$table->text('description')->nullable();
19+
$table->boolean('is_active')->default(true);
20+
$table->timestamps();
21+
$table->softDeletes();
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('payment_terms');
28+
}
29+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ const navItems: NavItem[] = [
144144
{ label: 'Support Tickets', href: '/finance/support-tickets', icon: <span /> },
145145
{ label: 'Expense Claims', href: '/finance/expense-claims', icon: <span /> },
146146
{ label: 'Vendor Bills', href: '/finance/vendor-bills', icon: <span /> },
147+
{ label: 'Payment Terms', href: '/finance/payment-terms', icon: <span /> },
147148
],
148149
},
149150
{
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Head, Link, router } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { Table } from '@/Components/Common/Table';
4+
import { Button } from '@/Components/Common/Button';
5+
import { Pagination } from '@/Components/Inventory/Pagination';
6+
import { usePermission } from '@/Hooks/usePermission';
7+
import type { PageProps } from '@/types';
8+
import type { PaymentTerm } from '@/types/finance';
9+
import type { Paginator } from '@/types/inventory';
10+
11+
interface Props extends PageProps {
12+
paymentTerms: Paginator<PaymentTerm>;
13+
}
14+
15+
export default function PaymentTermsIndex({ paymentTerms }: Props) {
16+
const { can } = usePermission();
17+
18+
function handleDelete(id: number) {
19+
if (!confirm('Delete this payment term?')) return;
20+
router.delete(`/finance/payment-terms/${id}`);
21+
}
22+
23+
return (
24+
<AppLayout>
25+
<Head title="Payment Terms" />
26+
<div className="space-y-6">
27+
<div className="flex items-center justify-between">
28+
<div>
29+
<h1 className="text-2xl font-semibold text-slate-900">Payment Terms</h1>
30+
<p className="mt-1 text-sm text-slate-500">{paymentTerms.total} payment terms</p>
31+
</div>
32+
{can('finance.create') && (
33+
<Button onClick={() => router.post('/finance/payment-terms', {
34+
name: 'Net 30', days: 30, discount_days: 0, discount_percent: 0, is_active: true,
35+
})}>
36+
New Payment Term
37+
</Button>
38+
)}
39+
</div>
40+
41+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
42+
<Table
43+
columns={[
44+
{
45+
key: 'name',
46+
header: 'Name',
47+
render: (pt) => pt.name,
48+
},
49+
{
50+
key: 'display_label',
51+
header: 'Label',
52+
render: (pt) => pt.display_label,
53+
},
54+
{
55+
key: 'days',
56+
header: 'Net Days',
57+
render: (pt) => pt.days,
58+
},
59+
{
60+
key: 'discount',
61+
header: 'Early Discount',
62+
render: (pt) => pt.has_early_discount
63+
? `${pt.discount_percent}% in ${pt.discount_days} days`
64+
: '—',
65+
},
66+
{
67+
key: 'is_active',
68+
header: 'Status',
69+
render: (pt) => (
70+
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${pt.is_active ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-600'}`}>
71+
{pt.is_active ? 'Active' : 'Inactive'}
72+
</span>
73+
),
74+
},
75+
{
76+
key: 'actions',
77+
header: '',
78+
render: (pt) => (
79+
<div className="flex items-center gap-3">
80+
<Link href={`/finance/payment-terms/${pt.id}`} className="text-sm text-indigo-600 hover:text-indigo-800">
81+
View
82+
</Link>
83+
{can('finance.delete') && (
84+
<button
85+
onClick={() => handleDelete(pt.id)}
86+
className="text-sm text-red-600 hover:text-red-800"
87+
>
88+
Delete
89+
</button>
90+
)}
91+
</div>
92+
),
93+
},
94+
]}
95+
data={paymentTerms.data}
96+
emptyMessage="No payment terms found."
97+
/>
98+
<Pagination paginator={paymentTerms} />
99+
</div>
100+
</div>
101+
</AppLayout>
102+
);
103+
}

0 commit comments

Comments
 (0)