Skip to content

Commit 66c543c

Browse files
committed
feat: Phase 34 — Recurring Invoices with auto-generation
Add reference_prefix, interval, currency_code, exchange_rate columns to recurring_invoices table. Add recurring_invoice_id FK to invoices table. Enhance RecurringInvoice model with computeNextRunDate() supporting interval multiplier, invoices() relationship, and updated generateInvoice() that stamps generated invoices with the reference prefix and recurring_invoice_id. Update Invoice model with recurring_invoice_id fillable field and recurringInvoice() relationship. Update controller, request validation, and API resource to expose new fields. Add 10 new Phase 34 tests covering reference prefix numbering, interval multiplier, recurring_invoice_id FK, currency pass-through, and computeNextRunDate for all four frequencies. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 33ce8f9 commit 66c543c

9 files changed

Lines changed: 364 additions & 43 deletions

File tree

erp/app/Modules/Finance/Http/Controllers/RecurringInvoiceController.php

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,20 @@ public function store(StoreRecurringInvoiceRequest $request): RedirectResponse
6161

6262
$recurringInvoice = DB::transaction(function () use ($data) {
6363
$recurringInvoice = RecurringInvoice::create([
64-
'tenant_id' => auth()->user()->tenant_id,
65-
'contact_id' => $data['contact_id'] ?? null,
66-
'frequency' => $data['frequency'],
67-
'start_date' => $data['start_date'],
68-
'next_run_date' => $data['start_date'],
69-
'end_date' => $data['end_date'] ?? null,
70-
'due_days' => $data['due_days'],
71-
'auto_send' => (bool) ($data['auto_send'] ?? false),
72-
'notes' => $data['notes'] ?? null,
73-
'created_by' => auth()->id(),
64+
'tenant_id' => auth()->user()->tenant_id,
65+
'contact_id' => $data['contact_id'] ?? null,
66+
'reference_prefix' => $data['reference_prefix'] ?? 'REC-INV',
67+
'frequency' => $data['frequency'],
68+
'interval' => $data['interval'] ?? 1,
69+
'start_date' => $data['start_date'],
70+
'next_run_date' => $data['start_date'],
71+
'end_date' => $data['end_date'] ?? null,
72+
'due_days' => $data['due_days'],
73+
'auto_send' => (bool) ($data['auto_send'] ?? false),
74+
'currency_code' => $data['currency_code'] ?? 'USD',
75+
'exchange_rate' => $data['exchange_rate'] ?? 1,
76+
'notes' => $data['notes'] ?? null,
77+
'created_by' => auth()->id(),
7478
]);
7579

7680
foreach ($data['items'] as $item) {
@@ -96,10 +100,9 @@ public function show(RecurringInvoice $recurringInvoice): Response
96100

97101
$recurringInvoice->load(['contact', 'items', 'creator']);
98102

99-
$generatedInvoices = Invoice::where('tenant_id', $recurringInvoice->tenant_id)
100-
->where('contact_id', $recurringInvoice->contact_id)
103+
$generatedInvoices = $recurringInvoice->invoices()
101104
->latest('issue_date')
102-
->take(10)
105+
->take(20)
103106
->get(['id', 'number', 'issue_date', 'status']);
104107

105108
return Inertia::render('Finance/RecurringInvoices/Show', [

erp/app/Modules/Finance/Http/Requests/StoreRecurringInvoiceRequest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ public function rules(): array
1313
{
1414
return [
1515
'contact_id' => ['nullable', Rule::exists('contacts', 'id')],
16+
'reference_prefix' => ['nullable', 'string', 'max:50'],
1617
'frequency' => ['required', Rule::in(['weekly', 'monthly', 'quarterly', 'yearly'])],
18+
'interval' => ['nullable', 'integer', 'min:1', 'max:12'],
1719
'start_date' => ['required', 'date'],
1820
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
1921
'due_days' => ['required', 'integer', 'min:0', 'max:365'],
2022
'auto_send' => ['boolean'],
23+
'currency_code' => ['nullable', 'string', 'size:3'],
24+
'exchange_rate' => ['nullable', 'numeric', 'min:0.000001'],
2125
'notes' => ['nullable', 'string'],
2226
'items' => ['required', 'array', 'min:1'],
2327
'items.*.description' => ['required', 'string'],

erp/app/Modules/Finance/Http/Resources/RecurringInvoiceResource.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ public function toArray(Request $request): array
1414
'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [
1515
'id' => $this->contact->id, 'name' => $this->contact->name,
1616
] : null),
17+
'reference_prefix' => $this->reference_prefix,
1718
'frequency' => $this->frequency,
19+
'interval' => $this->interval,
1820
'start_date' => $this->start_date?->toDateString(),
1921
'next_run_date' => $this->next_run_date?->toDateString(),
2022
'end_date' => $this->end_date?->toDateString(),
2123
'due_days' => $this->due_days,
2224
'status' => $this->status,
2325
'auto_send' => (bool) $this->auto_send,
26+
'currency_code' => $this->currency_code,
27+
'exchange_rate' => $this->exchange_rate,
2428
'notes' => $this->notes,
2529
'last_generated_at' => $this->last_generated_at,
2630
'generated_count' => $this->generated_count,

erp/app/Modules/Finance/Models/Invoice.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Invoice extends Model
2323
use HasStatusTransitions;
2424

2525
protected $fillable = [
26-
'tenant_id', 'contact_id', 'number',
26+
'tenant_id', 'recurring_invoice_id', 'contact_id', 'number',
2727
'issue_date', 'due_date', 'status', 'notes', 'created_by',
2828
'currency_code', 'exchange_rate',
2929
];
@@ -70,4 +70,9 @@ public function creator(): BelongsTo
7070
{
7171
return $this->belongsTo(User::class, 'created_by');
7272
}
73+
74+
public function recurringInvoice(): BelongsTo
75+
{
76+
return $this->belongsTo(RecurringInvoice::class);
77+
}
7378
}

erp/app/Modules/Finance/Models/RecurringInvoice.php

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ class RecurringInvoice extends Model
2020
use HasLineItemTotals;
2121

2222
protected $fillable = [
23-
'tenant_id', 'contact_id', 'frequency', 'start_date', 'next_run_date',
24-
'end_date', 'due_days', 'status', 'auto_send', 'notes',
23+
'tenant_id', 'contact_id', 'reference_prefix', 'frequency', 'interval',
24+
'start_date', 'next_run_date', 'end_date', 'due_days', 'status',
25+
'auto_send', 'currency_code', 'exchange_rate', 'notes',
2526
'last_generated_at', 'generated_count', 'created_by',
2627
];
2728

@@ -34,11 +35,15 @@ class RecurringInvoice extends Model
3435
];
3536

3637
protected $attributes = [
37-
'status' => 'active',
38-
'frequency' => 'monthly',
39-
'due_days' => 30,
40-
'generated_count' => 0,
41-
'auto_send' => false,
38+
'status' => 'active',
39+
'frequency' => 'monthly',
40+
'interval' => 1,
41+
'reference_prefix' => 'REC-INV',
42+
'currency_code' => 'USD',
43+
'exchange_rate' => 1,
44+
'due_days' => 30,
45+
'generated_count' => 0,
46+
'auto_send' => false,
4247
];
4348

4449
public function contact(): BelongsTo
@@ -51,11 +56,32 @@ public function items(): HasMany
5156
return $this->hasMany(RecurringInvoiceItem::class);
5257
}
5358

59+
public function invoices(): HasMany
60+
{
61+
return $this->hasMany(Invoice::class, 'recurring_invoice_id');
62+
}
63+
5464
public function creator(): BelongsTo
5565
{
5666
return $this->belongsTo(User::class, 'created_by');
5767
}
5868

69+
/**
70+
* Compute the next run date relative to a given Carbon date,
71+
* respecting both the frequency and the interval multiplier.
72+
*/
73+
public function computeNextRunDate(\Carbon\Carbon $from): \Carbon\Carbon
74+
{
75+
$n = (int) ($this->interval ?? 1);
76+
77+
return match ($this->frequency) {
78+
'weekly' => $from->copy()->addWeeks($n),
79+
'quarterly' => $from->copy()->addMonthsNoOverflow($n * 3),
80+
'yearly' => $from->copy()->addYears($n),
81+
default => $from->copy()->addMonthsNoOverflow($n),
82+
};
83+
}
84+
5985
public function scopeDue($query)
6086
{
6187
return $query->where('status', 'active')
@@ -64,17 +90,12 @@ public function scopeDue($query)
6490

6591
protected function intervalAdvance(\Carbon\Carbon $date): \Carbon\Carbon
6692
{
67-
return match ($this->frequency) {
68-
'weekly' => $date->copy()->addWeek(),
69-
'quarterly' => $date->copy()->addMonthsNoOverflow(3),
70-
'yearly' => $date->copy()->addYear(),
71-
default => $date->copy()->addMonthNoOverflow(),
72-
};
93+
return $this->computeNextRunDate($date);
7394
}
7495

7596
public function advanceSchedule(): void
7697
{
77-
$next = $this->intervalAdvance(\Carbon\Carbon::parse($this->next_run_date));
98+
$next = $this->computeNextRunDate(\Carbon\Carbon::parse($this->next_run_date));
7899

79100
if ($this->end_date && $next->gt(\Carbon\Carbon::parse($this->end_date))) {
80101
$this->status = 'ended';
@@ -92,18 +113,22 @@ public function generateInvoice(): Invoice
92113
$this->load('items');
93114
}
94115

95-
$invoice = Invoice::create([
96-
'tenant_id' => $this->tenant_id,
97-
'contact_id' => $this->contact_id,
98-
'issue_date' => now()->toDateString(),
99-
'due_date' => now()->addDays($this->due_days)->toDateString(),
100-
'status' => $this->auto_send ? 'sent' : 'draft',
101-
'notes' => $this->notes,
102-
'created_by' => $this->created_by,
103-
]);
116+
$nextCount = $this->generated_count + 1;
117+
$prefix = $this->reference_prefix ?: 'REC-INV';
118+
$refNumber = "{$prefix}-{$nextCount}";
104119

105-
$invoice->update([
106-
'number' => 'INV-' . now()->format('Y') . '-' . str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT),
120+
$invoice = Invoice::create([
121+
'tenant_id' => $this->tenant_id,
122+
'recurring_invoice_id' => $this->id,
123+
'contact_id' => $this->contact_id,
124+
'number' => $refNumber,
125+
'issue_date' => now()->toDateString(),
126+
'due_date' => now()->addDays($this->due_days)->toDateString(),
127+
'status' => $this->auto_send ? 'sent' : 'draft',
128+
'currency_code' => $this->currency_code ?? 'USD',
129+
'exchange_rate' => $this->exchange_rate ?? 1,
130+
'notes' => $this->notes,
131+
'created_by' => $this->created_by,
107132
]);
108133

109134
foreach ($this->items as $item) {
@@ -116,7 +141,7 @@ public function generateInvoice(): Invoice
116141
]);
117142
}
118143

119-
$this->generated_count = $this->generated_count + 1;
144+
$this->generated_count = $nextCount;
120145
$this->last_generated_at = now();
121146
$this->save();
122147

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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::table('recurring_invoices', function (Blueprint $table) {
12+
$table->string('reference_prefix', 50)->default('REC-INV')->after('contact_id');
13+
$table->tinyInteger('interval')->unsigned()->default(1)->after('frequency');
14+
$table->string('currency_code', 3)->default('USD')->after('auto_send');
15+
$table->decimal('exchange_rate', 15, 6)->default(1)->after('currency_code');
16+
});
17+
}
18+
19+
public function down(): void
20+
{
21+
Schema::table('recurring_invoices', function (Blueprint $table) {
22+
$table->dropColumn(['reference_prefix', 'interval', 'currency_code', 'exchange_rate']);
23+
});
24+
}
25+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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::table('invoices', function (Blueprint $table) {
12+
$table->foreignId('recurring_invoice_id')
13+
->nullable()
14+
->after('tenant_id')
15+
->constrained('recurring_invoices')
16+
->nullOnDelete();
17+
});
18+
}
19+
20+
public function down(): void
21+
{
22+
Schema::table('invoices', function (Blueprint $table) {
23+
$table->dropForeign(['recurring_invoice_id']);
24+
$table->dropColumn('recurring_invoice_id');
25+
});
26+
}
27+
};

erp/resources/js/types/finance.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,14 @@ export interface RecurringInvoiceItem {
258258
}
259259
export interface RecurringInvoice {
260260
id: number; status: RecurringStatus; frequency: RecurringFrequency;
261+
reference_prefix?: string; interval?: number;
261262
start_date: string; next_run_date: string; end_date?: string;
262-
due_days: number; auto_send: boolean; notes?: string;
263-
last_generated_at?: string; generated_count: number;
263+
due_days: number; auto_send: boolean;
264+
currency_code?: string; exchange_rate?: number;
265+
notes?: string; last_generated_at?: string; generated_count: number;
264266
contact?: { id: number; name: string } | null;
265267
items?: RecurringInvoiceItem[]; subtotal?: number; tax_total?: number; total?: number;
266-
created_by?: string; created_at?: string;
268+
invoices?: Invoice[]; created_by?: string; created_at?: string;
267269
}
268270

269271
export interface ExchangeRate {

0 commit comments

Comments
 (0)