Skip to content

Commit 1961bc9

Browse files
committed
feat(finance): Phase 60 — Subscription Billing with recurring invoice generation
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent aeea5db commit 1961bc9

18 files changed

Lines changed: 1379 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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\Subscription;
8+
use App\Modules\Finance\Models\SubscriptionPlan;
9+
use Carbon\Carbon;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Validation\Rule;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class SubscriptionController extends Controller
17+
{
18+
public function index(Request $request): Response
19+
{
20+
$this->authorize('viewAny', Subscription::class);
21+
22+
$subscriptions = Subscription::with(['contact', 'plan'])
23+
->when($request->status, fn ($q) => $q->where('status', $request->status))
24+
->latest()
25+
->paginate(15)
26+
->withQueryString();
27+
28+
return Inertia::render('Finance/Subscriptions/Index', [
29+
'subscriptions' => $subscriptions,
30+
'filters' => $request->only(['status']),
31+
'breadcrumbs' => [
32+
['label' => 'Finance'],
33+
['label' => 'Subscriptions', 'href' => route('finance.subscriptions.index')],
34+
],
35+
]);
36+
}
37+
38+
public function create(): Response
39+
{
40+
$this->authorize('create', Subscription::class);
41+
42+
return Inertia::render('Finance/Subscriptions/Create', [
43+
'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']),
44+
'plans' => SubscriptionPlan::where('is_active', true)->orderBy('name')->get(['id', 'name', 'billing_cycle', 'price']),
45+
'breadcrumbs' => [
46+
['label' => 'Finance'],
47+
['label' => 'Subscriptions', 'href' => route('finance.subscriptions.index')],
48+
['label' => 'New Subscription'],
49+
],
50+
]);
51+
}
52+
53+
public function store(Request $request): RedirectResponse
54+
{
55+
$this->authorize('create', Subscription::class);
56+
57+
$data = $request->validate([
58+
'contact_id' => ['required', Rule::exists('contacts', 'id')],
59+
'subscription_plan_id' => ['required', Rule::exists('subscription_plans', 'id')],
60+
'started_at' => ['required', 'date'],
61+
'notes' => ['nullable', 'string'],
62+
]);
63+
64+
$plan = SubscriptionPlan::find($data['subscription_plan_id']);
65+
66+
$trialEndsAt = null;
67+
$status = 'active';
68+
69+
if ($plan && $plan->trial_days > 0) {
70+
$status = 'trial';
71+
$trialEndsAt = Carbon::parse($data['started_at'])->addDays($plan->trial_days)->toDateString();
72+
}
73+
74+
$subscription = Subscription::create([
75+
'tenant_id' => auth()->user()->tenant_id,
76+
'contact_id' => $data['contact_id'],
77+
'subscription_plan_id' => $data['subscription_plan_id'],
78+
'status' => $status,
79+
'started_at' => $data['started_at'],
80+
'trial_ends_at' => $trialEndsAt,
81+
'notes' => $data['notes'] ?? null,
82+
]);
83+
84+
return redirect()->route('finance.subscriptions.show', $subscription)
85+
->with('success', 'Subscription created.');
86+
}
87+
88+
public function show(Subscription $subscription): Response
89+
{
90+
$this->authorize('view', $subscription);
91+
92+
$subscription->load(['contact', 'plan']);
93+
94+
return Inertia::render('Finance/Subscriptions/Show', [
95+
'subscription' => $subscription,
96+
'breadcrumbs' => [
97+
['label' => 'Finance'],
98+
['label' => 'Subscriptions', 'href' => route('finance.subscriptions.index')],
99+
['label' => "Subscription #{$subscription->id}"],
100+
],
101+
]);
102+
}
103+
104+
public function destroy(Subscription $subscription): RedirectResponse
105+
{
106+
$this->authorize('delete', $subscription);
107+
108+
$subscription->delete();
109+
110+
return redirect()->route('finance.subscriptions.index')
111+
->with('success', 'Subscription deleted.');
112+
}
113+
114+
public function activate(Subscription $subscription): RedirectResponse
115+
{
116+
$this->authorize('create', Subscription::class);
117+
118+
$subscription->load('plan');
119+
$subscription->activate();
120+
121+
return back()->with('success', 'Subscription activated.');
122+
}
123+
124+
public function cancel(Subscription $subscription): RedirectResponse
125+
{
126+
$this->authorize('create', Subscription::class);
127+
128+
$subscription->cancel();
129+
130+
return back()->with('success', 'Subscription cancelled.');
131+
}
132+
133+
public function pause(Subscription $subscription): RedirectResponse
134+
{
135+
$this->authorize('create', Subscription::class);
136+
137+
$subscription->pause();
138+
139+
return back()->with('success', 'Subscription paused.');
140+
}
141+
142+
public function generateInvoice(Subscription $subscription): RedirectResponse
143+
{
144+
$this->authorize('create', Subscription::class);
145+
146+
$subscription->load('plan');
147+
$invoice = $subscription->generateInvoice();
148+
149+
return redirect()->route('finance.invoices.show', $invoice)
150+
->with('success', 'Invoice generated.');
151+
}
152+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\SubscriptionPlan;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Validation\Rule;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class SubscriptionPlanController extends Controller
14+
{
15+
public function index(): Response
16+
{
17+
$this->authorize('viewAny', SubscriptionPlan::class);
18+
19+
$plans = SubscriptionPlan::withCount('subscriptions')
20+
->latest()
21+
->paginate(15)
22+
->withQueryString();
23+
24+
return Inertia::render('Finance/SubscriptionPlans/Index', [
25+
'plans' => $plans,
26+
'breadcrumbs' => [
27+
['label' => 'Finance'],
28+
['label' => 'Subscription Plans', 'href' => route('finance.subscription-plans.index')],
29+
],
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', SubscriptionPlan::class);
36+
37+
return Inertia::render('Finance/SubscriptionPlans/Create', [
38+
'breadcrumbs' => [
39+
['label' => 'Finance'],
40+
['label' => 'Subscription Plans', 'href' => route('finance.subscription-plans.index')],
41+
['label' => 'New Plan'],
42+
],
43+
]);
44+
}
45+
46+
public function store(Request $request): RedirectResponse
47+
{
48+
$this->authorize('create', SubscriptionPlan::class);
49+
50+
$data = $request->validate([
51+
'name' => ['required', 'string', 'max:255'],
52+
'billing_cycle' => ['required', Rule::in(['monthly', 'quarterly', 'annually'])],
53+
'price' => ['required', 'numeric', 'min:0'],
54+
'currency_code' => ['nullable', 'string', 'size:3'],
55+
'trial_days' => ['nullable', 'integer', 'min:0'],
56+
'description' => ['nullable', 'string'],
57+
'is_active' => ['nullable', 'boolean'],
58+
]);
59+
60+
$plan = SubscriptionPlan::create([
61+
'tenant_id' => auth()->user()->tenant_id,
62+
'name' => $data['name'],
63+
'billing_cycle' => $data['billing_cycle'],
64+
'price' => $data['price'],
65+
'currency_code' => $data['currency_code'] ?? 'USD',
66+
'trial_days' => $data['trial_days'] ?? 0,
67+
'description' => $data['description'] ?? null,
68+
'is_active' => $data['is_active'] ?? true,
69+
]);
70+
71+
return redirect()->route('finance.subscription-plans.show', $plan)
72+
->with('success', 'Subscription plan created.');
73+
}
74+
75+
public function show(SubscriptionPlan $subscriptionPlan): Response
76+
{
77+
$this->authorize('view', $subscriptionPlan);
78+
79+
$subscriptionPlan->loadCount('subscriptions');
80+
81+
return Inertia::render('Finance/SubscriptionPlans/Show', [
82+
'plan' => $subscriptionPlan,
83+
'breadcrumbs' => [
84+
['label' => 'Finance'],
85+
['label' => 'Subscription Plans', 'href' => route('finance.subscription-plans.index')],
86+
['label' => $subscriptionPlan->name],
87+
],
88+
]);
89+
}
90+
91+
public function destroy(SubscriptionPlan $subscriptionPlan): RedirectResponse
92+
{
93+
$this->authorize('delete', $subscriptionPlan);
94+
95+
$subscriptionPlan->delete();
96+
97+
return redirect()->route('finance.subscription-plans.index')
98+
->with('success', 'Subscription plan deleted.');
99+
}
100+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Carbon\Carbon;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
use Illuminate\Support\Facades\DB;
11+
12+
class Subscription extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id', 'contact_id', 'subscription_plan_id', 'status',
19+
'started_at', 'trial_ends_at', 'current_period_start', 'current_period_end',
20+
'cancelled_at', 'next_invoice_date', 'notes',
21+
];
22+
23+
protected $casts = [
24+
'started_at' => 'date',
25+
'trial_ends_at' => 'date',
26+
'current_period_start' => 'date',
27+
'current_period_end' => 'date',
28+
'cancelled_at' => 'datetime',
29+
'next_invoice_date' => 'date',
30+
];
31+
32+
public function contact(): BelongsTo
33+
{
34+
return $this->belongsTo(Contact::class);
35+
}
36+
37+
public function plan(): BelongsTo
38+
{
39+
return $this->belongsTo(SubscriptionPlan::class, 'subscription_plan_id');
40+
}
41+
42+
public function activate(): void
43+
{
44+
$today = Carbon::today()->toDateString();
45+
$this->status = 'active';
46+
$this->current_period_start = $today;
47+
$this->current_period_end = $this->plan->getNextBillingDate($today);
48+
$this->next_invoice_date = $today;
49+
$this->save();
50+
}
51+
52+
public function cancel(): void
53+
{
54+
$this->status = 'cancelled';
55+
$this->cancelled_at = Carbon::now();
56+
$this->save();
57+
}
58+
59+
public function pause(): void
60+
{
61+
$this->status = 'paused';
62+
$this->save();
63+
}
64+
65+
public function generateInvoice(): Invoice
66+
{
67+
$today = Carbon::today()->toDateString();
68+
$plan = $this->plan;
69+
70+
$periodStart = $this->current_period_start
71+
? $this->current_period_start->toDateString()
72+
: $today;
73+
$periodEnd = $this->current_period_end
74+
? $this->current_period_end->toDateString()
75+
: $plan->getNextBillingDate($today);
76+
77+
$period = "{$periodStart} - {$periodEnd}";
78+
79+
$invoice = DB::transaction(function () use ($today, $plan, $period) {
80+
$inv = Invoice::create([
81+
'tenant_id' => $this->tenant_id,
82+
'contact_id' => $this->contact_id,
83+
'status' => 'draft',
84+
'issue_date' => $today,
85+
'due_date' => Carbon::today()->addDays(30)->toDateString(),
86+
]);
87+
88+
InvoiceItem::create([
89+
'invoice_id' => $inv->id,
90+
'description' => "Subscription: {$plan->name} ({$period})",
91+
'quantity' => 1,
92+
'unit_price' => $plan->price,
93+
'tax_rate' => 0,
94+
]);
95+
96+
return $inv;
97+
});
98+
99+
$this->next_invoice_date = $plan->getNextBillingDate($today);
100+
$this->save();
101+
102+
return $invoice;
103+
}
104+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Carbon\Carbon;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class SubscriptionPlan extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id', 'name', 'description', 'billing_cycle',
18+
'price', 'currency_code', 'trial_days', 'is_active',
19+
];
20+
21+
protected $casts = [
22+
'price' => 'decimal:2',
23+
'is_active' => 'boolean',
24+
];
25+
26+
public function subscriptions(): HasMany
27+
{
28+
return $this->hasMany(Subscription::class);
29+
}
30+
31+
public function getNextBillingDate(string $from): string
32+
{
33+
return match ($this->billing_cycle) {
34+
'monthly' => Carbon::parse($from)->addMonth()->toDateString(),
35+
'quarterly' => Carbon::parse($from)->addMonths(3)->toDateString(),
36+
'annually' => Carbon::parse($from)->addYear()->toDateString(),
37+
default => Carbon::parse($from)->addMonth()->toDateString(),
38+
};
39+
}
40+
}

0 commit comments

Comments
 (0)