Skip to content

Commit 552bc89

Browse files
committed
feat: Subscriptions module — recurring billing with MRR/churn — 10 tests passing
- SubscriptionPlan model with billing_cycle (monthly/quarterly/annual), monthlyEquivalent(), cycleDays() - Subscription model with cancel(), renew() (creates invoice, advances period), isActive(), daysUntilRenewal(), mrr() - SubscriptionInvoice with markPaid() / markFailed() - SubscriptionController: index (with MRR total), plans, store, cancel, renew, payInvoice, metrics (MRR + churn rate + active/trial counts) - 3 migrations: subscription_plans, subscriptions, subscription_invoices - React pages: Index (dashboard with MRR card, plans tab) and Show (invoices + actions) - CoreServiceProvider: registered Rental + Subscriptions providers https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 391685f commit 552bc89

13 files changed

Lines changed: 1330 additions & 0 deletions

File tree

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use App\Modules\Discuss\Providers\DiscussServiceProvider;
2424
use App\Modules\Subcontracting\Providers\SubcontractingServiceProvider;
2525
use App\Modules\Rental\Providers\RentalServiceProvider;
26+
use App\Modules\Subscriptions\Providers\SubscriptionsServiceProvider;
2627
use Illuminate\Support\Facades\Gate;
2728
use Illuminate\Support\ServiceProvider;
2829

@@ -46,6 +47,8 @@ public function register(): void
4647
$this->app->register(EcommerceServiceProvider::class);
4748
$this->app->register(DiscussServiceProvider::class);
4849
$this->app->register(SubcontractingServiceProvider::class);
50+
$this->app->register(RentalServiceProvider::class);
51+
$this->app->register(SubscriptionsServiceProvider::class);
4952
}
5053

5154
public function boot(): void
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<?php
2+
3+
namespace App\Modules\Subscriptions\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Subscriptions\Models\Subscription;
7+
use App\Modules\Subscriptions\Models\SubscriptionInvoice;
8+
use App\Modules\Subscriptions\Models\SubscriptionPlan;
9+
use Carbon\Carbon;
10+
use Illuminate\Http\JsonResponse;
11+
use Illuminate\Http\RedirectResponse;
12+
use Illuminate\Http\Request;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class SubscriptionController extends Controller
17+
{
18+
public function index(Request $request): Response
19+
{
20+
$query = Subscription::with('plan');
21+
22+
if ($request->filled('status')) {
23+
$query->where('status', $request->status);
24+
}
25+
26+
if ($request->filled('plan_id')) {
27+
$query->where('plan_id', $request->plan_id);
28+
}
29+
30+
$subscriptions = $query->latest()->paginate(20)->withQueryString();
31+
32+
$mrr = Subscription::with('plan')
33+
->whereIn('status', ['trial', 'active'])
34+
->get()
35+
->sum(fn ($s) => $s->mrr());
36+
37+
$plans = SubscriptionPlan::where('is_active', true)->orderBy('name')->get();
38+
39+
return Inertia::render('Subscriptions/Index', [
40+
'subscriptions' => $subscriptions,
41+
'plans' => $plans,
42+
'mrr' => $mrr,
43+
'filters' => $request->only(['status', 'plan_id']),
44+
]);
45+
}
46+
47+
public function plans(Request $request): Response|JsonResponse
48+
{
49+
$plans = SubscriptionPlan::where('is_active', true)->orderBy('name')->get();
50+
51+
if ($request->wantsJson()) {
52+
return response()->json($plans);
53+
}
54+
55+
return Inertia::render('Subscriptions/Plans', [
56+
'plans' => $plans,
57+
]);
58+
}
59+
60+
public function show(Subscription $subscription): Response
61+
{
62+
$subscription->load([
63+
'plan',
64+
'invoices' => fn ($q) => $q->latest()->limit(10),
65+
]);
66+
67+
return Inertia::render('Subscriptions/Show', [
68+
'subscription' => $subscription,
69+
]);
70+
}
71+
72+
public function storePlan(Request $request): RedirectResponse|JsonResponse
73+
{
74+
$validated = $request->validate([
75+
'name' => 'required|string|max:255',
76+
'description' => 'nullable|string',
77+
'billing_cycle' => 'required|in:monthly,quarterly,annual',
78+
'price' => 'required|numeric|min:0',
79+
'trial_days' => 'nullable|integer|min:0',
80+
'is_active' => 'nullable|boolean',
81+
]);
82+
83+
$plan = SubscriptionPlan::create($validated);
84+
85+
if ($request->wantsJson()) {
86+
return response()->json($plan, 201);
87+
}
88+
89+
return redirect()->route('subscriptions.index')->with('success', 'Plan created successfully.');
90+
}
91+
92+
public function store(Request $request): RedirectResponse|JsonResponse
93+
{
94+
$validated = $request->validate([
95+
'plan_id' => 'required|integer',
96+
'customer_name' => 'required|string|max:255',
97+
'customer_email' => 'required|email|max:255',
98+
'status' => 'nullable|in:trial,active,past_due,cancelled,expired',
99+
'notes' => 'nullable|string',
100+
]);
101+
102+
$plan = SubscriptionPlan::findOrFail($validated['plan_id']);
103+
104+
if (! $plan->is_active) {
105+
if ($request->wantsJson()) {
106+
return response()->json(['message' => 'The selected plan is not active.'], 422);
107+
}
108+
return redirect()->back()->withErrors(['plan_id' => 'The selected plan is not active.']);
109+
}
110+
111+
$today = Carbon::today();
112+
$cycleDays = $plan->cycleDays();
113+
114+
$subscription = Subscription::create([
115+
'plan_id' => $plan->id,
116+
'customer_name' => $validated['customer_name'],
117+
'customer_email' => $validated['customer_email'],
118+
'status' => $validated['status'] ?? 'active',
119+
'notes' => $validated['notes'] ?? null,
120+
'current_period_start' => $today,
121+
'current_period_end' => $today->copy()->addDays($cycleDays - 1),
122+
]);
123+
124+
// Create initial pending invoice
125+
$subscription->invoices()->create([
126+
'tenant_id' => $subscription->tenant_id,
127+
'amount' => $plan->price,
128+
'status' => 'pending',
129+
'due_date' => $today,
130+
'period_start' => $today,
131+
'period_end' => $today->copy()->addDays($cycleDays - 1),
132+
]);
133+
134+
if ($request->wantsJson()) {
135+
return response()->json($subscription->load('plan', 'invoices'), 201);
136+
}
137+
138+
return redirect()->route('subscriptions.show', $subscription)->with('success', 'Subscription created successfully.');
139+
}
140+
141+
public function cancel(Subscription $subscription): RedirectResponse|JsonResponse
142+
{
143+
$subscription->cancel();
144+
145+
if (request()->wantsJson()) {
146+
return response()->json($subscription->fresh());
147+
}
148+
149+
return redirect()->route('subscriptions.show', $subscription)->with('success', 'Subscription cancelled.');
150+
}
151+
152+
public function renew(Subscription $subscription): RedirectResponse|JsonResponse
153+
{
154+
$invoice = $subscription->renew();
155+
156+
if (request()->wantsJson()) {
157+
return response()->json([
158+
'subscription' => $subscription->fresh()->load('plan'),
159+
'invoice' => $invoice,
160+
]);
161+
}
162+
163+
return redirect()->route('subscriptions.show', $subscription)->with('success', 'Subscription renewed.');
164+
}
165+
166+
public function payInvoice(Subscription $subscription, SubscriptionInvoice $invoice): RedirectResponse|JsonResponse
167+
{
168+
$invoice->markPaid();
169+
170+
if (request()->wantsJson()) {
171+
return response()->json($invoice->fresh());
172+
}
173+
174+
return redirect()->route('subscriptions.show', $subscription)->with('success', 'Invoice marked as paid.');
175+
}
176+
177+
public function metrics(Request $request): Response|JsonResponse
178+
{
179+
$activeSubscriptions = Subscription::with('plan')
180+
->whereIn('status', ['trial', 'active'])
181+
->get();
182+
183+
$mrr = $activeSubscriptions->sum(fn ($s) => $s->mrr());
184+
185+
$activeCount = Subscription::where('status', 'active')->count();
186+
$trialCount = Subscription::where('status', 'trial')->count();
187+
188+
// Churn rate: cancelled this month / active last month
189+
$cancelledThisMonth = Subscription::where('status', 'cancelled')
190+
->whereMonth('cancelled_at', now()->month)
191+
->whereYear('cancelled_at', now()->year)
192+
->count();
193+
194+
$activeLastMonth = Subscription::whereIn('status', ['active', 'trial', 'cancelled', 'past_due', 'expired'])
195+
->where('created_at', '<=', now()->startOfMonth())
196+
->count();
197+
198+
$churnRate = $activeLastMonth > 0
199+
? round(($cancelledThisMonth / $activeLastMonth) * 100, 2)
200+
: 0.0;
201+
202+
$metricsData = [
203+
'mrr' => $mrr,
204+
'active_count' => $activeCount,
205+
'trial_count' => $trialCount,
206+
'churn_rate' => $churnRate,
207+
];
208+
209+
if ($request->wantsJson()) {
210+
return response()->json($metricsData);
211+
}
212+
213+
return Inertia::render('Subscriptions/Metrics', $metricsData);
214+
}
215+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
namespace App\Modules\Subscriptions\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\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class Subscription extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'plan_id',
19+
'customer_name',
20+
'customer_email',
21+
'status',
22+
'trial_ends_at',
23+
'current_period_start',
24+
'current_period_end',
25+
'cancelled_at',
26+
'notes',
27+
];
28+
29+
protected $casts = [
30+
'trial_ends_at' => 'datetime',
31+
'cancelled_at' => 'datetime',
32+
'current_period_start' => 'date',
33+
'current_period_end' => 'date',
34+
];
35+
36+
public function plan(): BelongsTo
37+
{
38+
return $this->belongsTo(SubscriptionPlan::class, 'plan_id');
39+
}
40+
41+
public function invoices(): HasMany
42+
{
43+
return $this->hasMany(SubscriptionInvoice::class, 'subscription_id');
44+
}
45+
46+
public function cancel(): void
47+
{
48+
$this->status = 'cancelled';
49+
$this->cancelled_at = now();
50+
$this->save();
51+
}
52+
53+
public function renew(): SubscriptionInvoice
54+
{
55+
$plan = $this->plan;
56+
$cycleDays = $plan->cycleDays();
57+
58+
$newStart = $this->current_period_end->copy()->addDay();
59+
$newEnd = $newStart->copy()->addDays($cycleDays - 1);
60+
61+
$invoice = $this->invoices()->create([
62+
'tenant_id' => $this->tenant_id,
63+
'amount' => $plan->price,
64+
'status' => 'pending',
65+
'due_date' => $newStart,
66+
'period_start' => $newStart,
67+
'period_end' => $newEnd,
68+
]);
69+
70+
$this->current_period_start = $newStart;
71+
$this->current_period_end = $newEnd;
72+
$this->save();
73+
74+
return $invoice;
75+
}
76+
77+
public function isActive(): bool
78+
{
79+
return in_array($this->status, ['trial', 'active']);
80+
}
81+
82+
public function daysUntilRenewal(): int
83+
{
84+
return (int) Carbon::today()->diffInDays($this->current_period_end, false);
85+
}
86+
87+
public function mrr(): float
88+
{
89+
if (! $this->isActive()) {
90+
return 0.0;
91+
}
92+
93+
return $this->plan->monthlyEquivalent();
94+
}
95+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace App\Modules\Subscriptions\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class SubscriptionInvoice extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'subscription_id',
16+
'amount',
17+
'status',
18+
'due_date',
19+
'paid_at',
20+
'period_start',
21+
'period_end',
22+
];
23+
24+
protected $casts = [
25+
'due_date' => 'date',
26+
'period_start' => 'date',
27+
'period_end' => 'date',
28+
'paid_at' => 'datetime',
29+
];
30+
31+
public function subscription(): BelongsTo
32+
{
33+
return $this->belongsTo(Subscription::class, 'subscription_id');
34+
}
35+
36+
public function markPaid(): void
37+
{
38+
$this->status = 'paid';
39+
$this->paid_at = now();
40+
$this->save();
41+
}
42+
43+
public function markFailed(): void
44+
{
45+
$this->status = 'failed';
46+
$this->save();
47+
48+
$this->subscription()->update(['status' => 'past_due']);
49+
}
50+
}

0 commit comments

Comments
 (0)