Skip to content

Commit f309372

Browse files
committed
feat(finance): Phase 77 — Customer Loyalty & Rewards with points and tiers
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 4620da2 commit f309372

16 files changed

Lines changed: 1129 additions & 1 deletion
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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\LoyaltyEnrollment;
8+
use App\Modules\Finance\Models\LoyaltyProgram;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class LoyaltyProgramController extends Controller
15+
{
16+
public function index(): Response
17+
{
18+
$this->authorize('viewAny', LoyaltyProgram::class);
19+
20+
$loyaltyPrograms = LoyaltyProgram::withCount('enrollments')
21+
->latest()
22+
->paginate(15);
23+
24+
return Inertia::render('Finance/Loyalty/Index', [
25+
'loyaltyPrograms' => $loyaltyPrograms,
26+
'breadcrumbs' => [
27+
['label' => 'Finance'],
28+
['label' => 'Loyalty Programs', 'href' => route('finance.loyalty-programs.index')],
29+
],
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', LoyaltyProgram::class);
36+
37+
return Inertia::render('Finance/Loyalty/Create', [
38+
'breadcrumbs' => [
39+
['label' => 'Finance'],
40+
['label' => 'Loyalty Programs', 'href' => route('finance.loyalty-programs.index')],
41+
['label' => 'New Program'],
42+
],
43+
]);
44+
}
45+
46+
public function store(Request $request): RedirectResponse
47+
{
48+
$this->authorize('create', LoyaltyProgram::class);
49+
50+
$data = $request->validate([
51+
'name' => ['required', 'string', 'max:255'],
52+
'description' => ['nullable', 'string'],
53+
'points_per_currency_unit' => ['required', 'numeric', 'min:0.0001'],
54+
'points_to_currency_rate' => ['required', 'numeric', 'min:0.000001'],
55+
'minimum_redemption_points' => ['required', 'integer', 'min:1'],
56+
'is_active' => ['boolean'],
57+
]);
58+
59+
$data['tenant_id'] = app('tenant')->id;
60+
61+
$program = LoyaltyProgram::create($data);
62+
63+
return redirect()->route('finance.loyalty-programs.show', $program);
64+
}
65+
66+
public function show(LoyaltyProgram $loyaltyProgram): Response
67+
{
68+
$this->authorize('view', $loyaltyProgram);
69+
70+
$enrollments = $loyaltyProgram->enrollments()
71+
->with('contact')
72+
->paginate(20);
73+
74+
return Inertia::render('Finance/Loyalty/Show', [
75+
'loyaltyProgram' => $loyaltyProgram,
76+
'enrollments' => $enrollments,
77+
'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']),
78+
'breadcrumbs' => [
79+
['label' => 'Finance'],
80+
['label' => 'Loyalty Programs', 'href' => route('finance.loyalty-programs.index')],
81+
['label' => $loyaltyProgram->name],
82+
],
83+
]);
84+
}
85+
86+
public function destroy(LoyaltyProgram $loyaltyProgram): RedirectResponse
87+
{
88+
$this->authorize('delete', $loyaltyProgram);
89+
90+
$loyaltyProgram->delete();
91+
92+
return redirect()->route('finance.loyalty-programs.index');
93+
}
94+
95+
public function enroll(Request $request, LoyaltyProgram $loyaltyProgram): RedirectResponse
96+
{
97+
$this->authorize('create', LoyaltyProgram::class);
98+
99+
$data = $request->validate([
100+
'contact_id' => ['required', 'exists:contacts,id'],
101+
]);
102+
103+
$tenantId = app('tenant')->id;
104+
105+
LoyaltyEnrollment::firstOrCreate(
106+
[
107+
'loyalty_program_id' => $loyaltyProgram->id,
108+
'contact_id' => $data['contact_id'],
109+
],
110+
[
111+
'tenant_id' => $tenantId,
112+
'enrolled_at' => now(),
113+
]
114+
);
115+
116+
return back()->with('success', 'Contact enrolled successfully.');
117+
}
118+
119+
public function earnPoints(Request $request, LoyaltyProgram $loyaltyProgram): RedirectResponse
120+
{
121+
$this->authorize('create', LoyaltyProgram::class);
122+
123+
$data = $request->validate([
124+
'enrollment_id' => ['required', 'exists:loyalty_enrollments,id'],
125+
'points' => ['required', 'integer', 'min:1'],
126+
'description' => ['nullable', 'string'],
127+
]);
128+
129+
$enrollment = LoyaltyEnrollment::findOrFail($data['enrollment_id']);
130+
$enrollment->earnPoints($data['points'], $data['description'] ?? '');
131+
132+
return back()->with('success', 'Points earned successfully.');
133+
}
134+
135+
public function redeemPoints(Request $request, LoyaltyProgram $loyaltyProgram): RedirectResponse
136+
{
137+
$this->authorize('create', LoyaltyProgram::class);
138+
139+
$data = $request->validate([
140+
'enrollment_id' => ['required', 'exists:loyalty_enrollments,id'],
141+
'points' => ['required', 'integer', 'min:1'],
142+
]);
143+
144+
$enrollment = LoyaltyEnrollment::findOrFail($data['enrollment_id']);
145+
146+
try {
147+
$enrollment->redeemPoints($data['points']);
148+
} catch (\Exception $e) {
149+
return back()->with('error', $e->getMessage());
150+
}
151+
152+
return back()->with('success', 'Points redeemed successfully.');
153+
}
154+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
class LoyaltyEnrollment extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $table = 'loyalty_enrollments';
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'loyalty_program_id',
19+
'contact_id',
20+
'points_balance',
21+
'total_points_earned',
22+
'total_points_redeemed',
23+
'enrolled_at',
24+
'tier_name',
25+
];
26+
27+
protected $casts = [
28+
'points_balance' => 'integer',
29+
'total_points_earned' => 'integer',
30+
'total_points_redeemed' => 'integer',
31+
'enrolled_at' => 'datetime',
32+
];
33+
34+
public function program(): BelongsTo
35+
{
36+
return $this->belongsTo(LoyaltyProgram::class, 'loyalty_program_id');
37+
}
38+
39+
public function contact(): BelongsTo
40+
{
41+
return $this->belongsTo(Contact::class);
42+
}
43+
44+
public function transactions(): HasMany
45+
{
46+
return $this->hasMany(LoyaltyTransaction::class);
47+
}
48+
49+
public function earnPoints(int $points, string $description = '', ?int $referenceId = null): LoyaltyTransaction
50+
{
51+
$this->increment('points_balance', $points);
52+
$this->increment('total_points_earned', $points);
53+
$this->refresh();
54+
55+
return LoyaltyTransaction::create([
56+
'tenant_id' => $this->tenant_id,
57+
'loyalty_enrollment_id' => $this->id,
58+
'type' => 'earn',
59+
'points' => $points,
60+
'description' => $description,
61+
'reference_id' => $referenceId,
62+
'balance_after' => $this->points_balance,
63+
]);
64+
}
65+
66+
public function redeemPoints(int $points, string $description = ''): LoyaltyTransaction
67+
{
68+
if ($points > $this->points_balance) {
69+
throw new \Exception('Insufficient points balance.');
70+
}
71+
72+
$this->decrement('points_balance', $points);
73+
$this->increment('total_points_redeemed', $points);
74+
$this->refresh();
75+
76+
return LoyaltyTransaction::create([
77+
'tenant_id' => $this->tenant_id,
78+
'loyalty_enrollment_id' => $this->id,
79+
'type' => 'redeem',
80+
'points' => $points,
81+
'description' => $description,
82+
'balance_after' => $this->points_balance,
83+
]);
84+
}
85+
86+
public function updateTier(): void
87+
{
88+
$tier = $this->program->getTierForPoints($this->total_points_earned);
89+
$this->tier_name = $tier['name'] ?? null;
90+
$this->save();
91+
}
92+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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\Relations\HasMany;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class LoyaltyProgram extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $table = 'loyalty_programs';
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'name',
20+
'description',
21+
'points_per_currency_unit',
22+
'points_to_currency_rate',
23+
'minimum_redemption_points',
24+
'is_active',
25+
'tier_config',
26+
];
27+
28+
protected $casts = [
29+
'points_per_currency_unit' => 'decimal:4',
30+
'points_to_currency_rate' => 'decimal:6',
31+
'minimum_redemption_points' => 'integer',
32+
'is_active' => 'boolean',
33+
'tier_config' => 'array',
34+
];
35+
36+
public function enrollments(): HasMany
37+
{
38+
return $this->hasMany(LoyaltyEnrollment::class);
39+
}
40+
41+
public function calculatePointsEarned(float $amount): int
42+
{
43+
return (int) floor($amount * $this->points_per_currency_unit);
44+
}
45+
46+
public function calculateRedemptionValue(int $points): float
47+
{
48+
return round($points * $this->points_to_currency_rate, 2);
49+
}
50+
51+
public function getTierForPoints(int $points): ?array
52+
{
53+
$tiers = $this->tier_config;
54+
55+
if (empty($tiers)) {
56+
return null;
57+
}
58+
59+
usort($tiers, fn ($a, $b) => $b['min_points'] <=> $a['min_points']);
60+
61+
foreach ($tiers as $tier) {
62+
if ($points >= $tier['min_points']) {
63+
return $tier;
64+
}
65+
}
66+
67+
return null;
68+
}
69+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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\Relations\BelongsTo;
8+
9+
class LoyaltyTransaction extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'loyalty_transactions';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'loyalty_enrollment_id',
18+
'type',
19+
'points',
20+
'description',
21+
'reference_id',
22+
'balance_after',
23+
];
24+
25+
protected $casts = [
26+
'points' => 'integer',
27+
'balance_after' => 'integer',
28+
];
29+
30+
public function enrollment(): BelongsTo
31+
{
32+
return $this->belongsTo(LoyaltyEnrollment::class, 'loyalty_enrollment_id');
33+
}
34+
}
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\LoyaltyProgram;
7+
8+
class LoyaltyPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, LoyaltyProgram $loyaltyProgram): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function update(User $user, LoyaltyProgram $loyaltyProgram): bool { return $user->can('finance.create'); }
14+
public function delete(User $user, LoyaltyProgram $loyaltyProgram): bool { return $user->can('finance.delete'); }
15+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
use App\Modules\Finance\Models\ServiceAgreementItem;
6868
use App\Modules\Finance\Models\MaintenanceLog;
6969
use App\Modules\Finance\Policies\ServiceAgreementPolicy;
70+
use App\Modules\Finance\Models\LoyaltyProgram;
71+
use App\Modules\Finance\Models\LoyaltyEnrollment;
72+
use App\Modules\Finance\Models\LoyaltyTransaction;
73+
use App\Modules\Finance\Policies\LoyaltyPolicy;
7074
use Illuminate\Support\Facades\Gate;
7175
use Illuminate\Support\ServiceProvider;
7276

@@ -120,6 +124,10 @@ public function boot(): void
120124
Gate::policy(ServiceAgreementItem::class, ServiceAgreementPolicy::class);
121125
Gate::policy(MaintenanceLog::class, ServiceAgreementPolicy::class);
122126

127+
Gate::policy(LoyaltyProgram::class, LoyaltyPolicy::class);
128+
Gate::policy(LoyaltyEnrollment::class, LoyaltyPolicy::class);
129+
Gate::policy(LoyaltyTransaction::class, LoyaltyPolicy::class);
130+
123131
if ($this->app->runningInConsole()) {
124132
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
125133
}

0 commit comments

Comments
 (0)