Skip to content

Commit 073c8c0

Browse files
committed
Phase 51: Payment Terms & Schedules REST API
Adds CRUD for payment terms (Net 30, 2/10 Net 30, etc.) with early-discount display formatting, and a payment schedule API that auto-generates monthly/ quarterly/yearly installments and tracks per-item paid status. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d02d3ac commit 073c8c0

3 files changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Finance\Models\PaymentSchedule;
6+
use App\Modules\Finance\Models\PaymentScheduleItem;
7+
use App\Modules\Finance\Models\PaymentTerm;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
11+
class PaymentTermApiController extends ApiController
12+
{
13+
// ── Payment Terms ─────────────────────────────────────────────────────────
14+
15+
public function indexTerms(Request $request): JsonResponse
16+
{
17+
$tenantId = $this->tenantId($request);
18+
$terms = PaymentTerm::where('tenant_id', $tenantId)
19+
->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true))
20+
->orderBy('days')
21+
->get();
22+
23+
return $this->success($terms->map(fn ($t) => [
24+
'id' => $t->id,
25+
'name' => $t->name,
26+
'days' => $t->days,
27+
'discount_days' => $t->discount_days,
28+
'discount_percent' => $t->discount_percent,
29+
'has_early_discount' => $t->has_early_discount,
30+
'display_label' => $t->display_label,
31+
'description' => $t->description,
32+
'is_active' => $t->is_active,
33+
]));
34+
}
35+
36+
public function storeTerm(Request $request): JsonResponse
37+
{
38+
$tenantId = $this->tenantId($request);
39+
40+
$data = $request->validate([
41+
'name' => ['required', 'string', 'max:100'],
42+
'days' => ['required', 'integer', 'min:0'],
43+
'discount_days' => ['nullable', 'integer', 'min:0'],
44+
'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
45+
'description' => ['nullable', 'string'],
46+
]);
47+
48+
$term = PaymentTerm::create([...$data, 'tenant_id' => $tenantId, 'is_active' => true]);
49+
50+
return $this->success($term, 201);
51+
}
52+
53+
public function updateTerm(Request $request, PaymentTerm $paymentTerm): JsonResponse
54+
{
55+
$data = $request->validate([
56+
'name' => ['sometimes', 'string', 'max:100'],
57+
'days' => ['sometimes', 'integer', 'min:0'],
58+
'discount_days' => ['nullable', 'integer', 'min:0'],
59+
'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
60+
'description' => ['nullable', 'string'],
61+
'is_active' => ['boolean'],
62+
]);
63+
64+
$paymentTerm->update($data);
65+
66+
return $this->success($paymentTerm->fresh());
67+
}
68+
69+
public function destroyTerm(PaymentTerm $paymentTerm): JsonResponse
70+
{
71+
$paymentTerm->delete();
72+
return $this->success(['message' => 'Payment term deleted.']);
73+
}
74+
75+
// ── Payment Schedules ─────────────────────────────────────────────────────
76+
77+
public function indexSchedules(Request $request): JsonResponse
78+
{
79+
$tenantId = $this->tenantId($request);
80+
$schedules = PaymentSchedule::where('tenant_id', $tenantId)
81+
->when($request->status, fn ($q) => $q->where('status', $request->status))
82+
->with('items')
83+
->orderByDesc('created_at')
84+
->paginate(20);
85+
86+
return $this->paginated($schedules);
87+
}
88+
89+
public function storeSchedule(Request $request): JsonResponse
90+
{
91+
$tenantId = $this->tenantId($request);
92+
93+
$data = $request->validate([
94+
'name' => ['required', 'string', 'max:255'],
95+
'total_amount' => ['required', 'numeric', 'min:0.01'],
96+
'currency' => ['nullable', 'string', 'max:3'],
97+
'frequency' => ['required', 'string', 'in:weekly,monthly,quarterly,yearly,custom'],
98+
'installments' => ['required', 'integer', 'min:1', 'max:120'],
99+
'start_date' => ['required', 'date'],
100+
'reference_type' => ['nullable', 'string', 'max:50'],
101+
'reference_id' => ['nullable', 'integer'],
102+
'notes' => ['nullable', 'string'],
103+
]);
104+
105+
$schedule = PaymentSchedule::create([
106+
...$data,
107+
'tenant_id' => $tenantId,
108+
'created_by' => $request->user()->id,
109+
'status' => 'active',
110+
]);
111+
112+
$schedule->schedule_number = $schedule->generateScheduleNumber();
113+
$schedule->save();
114+
115+
$this->generateInstallments($schedule);
116+
117+
return $this->success($schedule->load('items'), 201);
118+
}
119+
120+
public function showSchedule(PaymentSchedule $paymentSchedule): JsonResponse
121+
{
122+
return $this->success($paymentSchedule->load('items'));
123+
}
124+
125+
public function markInstallmentPaid(Request $request, PaymentSchedule $paymentSchedule, int $itemId): JsonResponse
126+
{
127+
$item = PaymentScheduleItem::where('payment_schedule_id', $paymentSchedule->id)
128+
->findOrFail($itemId);
129+
130+
$data = $request->validate([
131+
'paid_date' => ['nullable', 'date'],
132+
]);
133+
134+
$item->markPaid($data['paid_date'] ?? null);
135+
$paymentSchedule->recalculatePaidAmount();
136+
137+
return $this->success([
138+
'item' => $item->fresh(),
139+
'schedule' => $paymentSchedule->fresh(),
140+
]);
141+
}
142+
143+
public function pauseSchedule(PaymentSchedule $paymentSchedule): JsonResponse
144+
{
145+
$paymentSchedule->pause();
146+
return $this->success($paymentSchedule->fresh());
147+
}
148+
149+
public function cancelSchedule(PaymentSchedule $paymentSchedule): JsonResponse
150+
{
151+
$paymentSchedule->cancel();
152+
return $this->success($paymentSchedule->fresh());
153+
}
154+
155+
private function generateInstallments(PaymentSchedule $schedule): void
156+
{
157+
$installmentAmount = round((float) $schedule->total_amount / $schedule->installments, 2);
158+
$startDate = now()->parse($schedule->start_date);
159+
160+
for ($i = 1; $i <= $schedule->installments; $i++) {
161+
$dueDate = match ($schedule->frequency) {
162+
'weekly' => $startDate->copy()->addWeeks($i - 1),
163+
'quarterly' => $startDate->copy()->addMonths(($i - 1) * 3),
164+
'yearly' => $startDate->copy()->addYears($i - 1),
165+
default => $startDate->copy()->addMonths($i - 1), // monthly / custom
166+
};
167+
168+
PaymentScheduleItem::create([
169+
'payment_schedule_id' => $schedule->id,
170+
'installment_number' => $i,
171+
'amount' => $i === $schedule->installments
172+
? (float) $schedule->total_amount - ($installmentAmount * ($schedule->installments - 1))
173+
: $installmentAmount,
174+
'due_date' => $dueDate->toDateString(),
175+
'status' => 'pending',
176+
]);
177+
}
178+
}
179+
180+
private function tenantId(Request $request): int
181+
{
182+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
183+
}
184+
}

erp/routes/api.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,22 @@
456456
Route::post('/{contact}/evaluate', [\App\Http\Controllers\Api\V1\VendorPerformanceController::class, 'evaluate']);
457457
});
458458

459+
// Payment Terms & Schedules
460+
Route::prefix('payment-terms')->group(function () {
461+
Route::get('/', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'indexTerms']);
462+
Route::post('/', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'storeTerm']);
463+
Route::put('/{paymentTerm}', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'updateTerm']);
464+
Route::delete('/{paymentTerm}', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'destroyTerm']);
465+
});
466+
Route::prefix('payment-schedules')->group(function () {
467+
Route::get('/', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'indexSchedules']);
468+
Route::post('/', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'storeSchedule']);
469+
Route::get('/{paymentSchedule}', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'showSchedule']);
470+
Route::post('/{paymentSchedule}/pause', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'pauseSchedule']);
471+
Route::post('/{paymentSchedule}/cancel', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'cancelSchedule']);
472+
Route::post('/{paymentSchedule}/items/{itemId}/pay', [\App\Http\Controllers\Api\V1\PaymentTermApiController::class, 'markInstallmentPaid']);
473+
});
474+
459475
// Inventory Valuation
460476
Route::prefix('inventory-valuation')->group(function () {
461477
Route::get('/summary', [\App\Http\Controllers\Api\V1\InventoryValuationController::class, 'summary']);
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\PaymentSchedule;
6+
use App\Modules\Finance\Models\PaymentScheduleItem;
7+
use App\Modules\Finance\Models\PaymentTerm;
8+
use Database\Seeders\RolePermissionSeeder;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Payment Co', 'slug' => 'pay-co-' . uniqid()]);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
});
18+
19+
test('can create a payment term', function () {
20+
$this->withToken($this->token)
21+
->postJson('/api/v1/payment-terms', [
22+
'name' => 'Net 30',
23+
'days' => 30,
24+
'discount_days' => 10,
25+
'discount_percent' => 2.0,
26+
])
27+
->assertStatus(201)
28+
->assertJsonPath('data.name', 'Net 30')
29+
->assertJsonPath('data.days', 30);
30+
});
31+
32+
test('can list payment terms', function () {
33+
PaymentTerm::create([
34+
'tenant_id' => $this->tenant->id,
35+
'name' => 'Net 60',
36+
'days' => 60,
37+
'is_active' => true,
38+
]);
39+
40+
$data = $this->withToken($this->token)
41+
->getJson('/api/v1/payment-terms')
42+
->assertStatus(200)
43+
->json('data');
44+
45+
expect($data)->not->toBeEmpty();
46+
expect($data[0]['display_label'])->toBe('Net 60');
47+
});
48+
49+
test('display_label shows early discount format', function () {
50+
$term = PaymentTerm::create([
51+
'tenant_id' => $this->tenant->id,
52+
'name' => '2/10 Net 30',
53+
'days' => 30,
54+
'discount_days' => 10,
55+
'discount_percent' => 2.0,
56+
'is_active' => true,
57+
]);
58+
59+
$data = $this->withToken($this->token)
60+
->getJson('/api/v1/payment-terms')
61+
->assertStatus(200)
62+
->json('data');
63+
64+
$found = collect($data)->firstWhere('id', $term->id);
65+
expect($found['has_early_discount'])->toBeTrue();
66+
expect($found['display_label'])->toBe('2/10 Net 30');
67+
});
68+
69+
test('can update a payment term', function () {
70+
$term = PaymentTerm::create([
71+
'tenant_id' => $this->tenant->id,
72+
'name' => 'Immediate',
73+
'days' => 0,
74+
'is_active' => true,
75+
]);
76+
77+
$this->withToken($this->token)
78+
->putJson("/api/v1/payment-terms/{$term->id}", ['days' => 7, 'name' => 'Net 7'])
79+
->assertStatus(200)
80+
->assertJsonPath('data.days', 7);
81+
});
82+
83+
test('can delete a payment term', function () {
84+
$term = PaymentTerm::create([
85+
'tenant_id' => $this->tenant->id,
86+
'name' => 'Temp',
87+
'days' => 15,
88+
'is_active' => true,
89+
]);
90+
91+
$this->withToken($this->token)
92+
->deleteJson("/api/v1/payment-terms/{$term->id}")
93+
->assertStatus(200);
94+
95+
expect(PaymentTerm::find($term->id))->toBeNull();
96+
});
97+
98+
test('can create a payment schedule with installments', function () {
99+
$response = $this->withToken($this->token)
100+
->postJson('/api/v1/payment-schedules', [
101+
'name' => 'Quarterly Plan',
102+
'total_amount' => 1200.00,
103+
'frequency' => 'monthly',
104+
'installments' => 12,
105+
'start_date' => now()->toDateString(),
106+
])
107+
->assertStatus(201)
108+
->assertJsonStructure(['data' => ['id', 'schedule_number', 'items']]);
109+
110+
expect(count($response->json('data.items')))->toBe(12);
111+
expect((float) $response->json('data.items.0.amount'))->toBe(100.0);
112+
});
113+
114+
test('can mark an installment as paid', function () {
115+
$schedule = PaymentSchedule::create([
116+
'tenant_id' => $this->tenant->id,
117+
'name' => 'Test Schedule',
118+
'total_amount' => 300.00,
119+
'frequency' => 'monthly',
120+
'installments' => 3,
121+
'start_date' => now()->toDateString(),
122+
'status' => 'active',
123+
'created_by' => $this->user->id,
124+
]);
125+
126+
$item = PaymentScheduleItem::create([
127+
'payment_schedule_id' => $schedule->id,
128+
'installment_number' => 1,
129+
'amount' => 100.00,
130+
'due_date' => now()->toDateString(),
131+
'status' => 'pending',
132+
]);
133+
134+
$this->withToken($this->token)
135+
->postJson("/api/v1/payment-schedules/{$schedule->id}/items/{$item->id}/pay", [
136+
'paid_date' => now()->toDateString(),
137+
])
138+
->assertStatus(200)
139+
->assertJsonPath('data.item.status', 'paid');
140+
});
141+
142+
test('can pause and cancel a schedule', function () {
143+
$schedule = PaymentSchedule::create([
144+
'tenant_id' => $this->tenant->id,
145+
'name' => 'Pauseable',
146+
'total_amount' => 500.00,
147+
'frequency' => 'monthly',
148+
'installments' => 5,
149+
'start_date' => now()->toDateString(),
150+
'status' => 'active',
151+
'created_by' => $this->user->id,
152+
]);
153+
154+
$this->withToken($this->token)
155+
->postJson("/api/v1/payment-schedules/{$schedule->id}/pause")
156+
->assertStatus(200)
157+
->assertJsonPath('data.status', 'paused');
158+
159+
$this->withToken($this->token)
160+
->postJson("/api/v1/payment-schedules/{$schedule->id}/cancel")
161+
->assertStatus(200)
162+
->assertJsonPath('data.status', 'cancelled');
163+
});
164+
165+
test('requires authentication', function () {
166+
$this->getJson('/api/v1/payment-terms')->assertStatus(401);
167+
$this->getJson('/api/v1/payment-schedules')->assertStatus(401);
168+
});

0 commit comments

Comments
 (0)