Commit 805bfc36 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix subscription revenue widget — pro-rata allocation from invoice items

The old query only found 30 enrollments (those with invoice_id set) and
showed 33K (9.3%). The correct approach identifies subscription line items
via invoice_items.itemable_type IS NULL (218 items), then pro-rates each
payment by the subscription portion of that invoice's total. Result: 174K
(48.5%) which matches actual subscription collections including bundled
invoices and installment payments.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 0ec2da41
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
namespace App\Livewire\Dashboard; namespace App\Livewire\Dashboard;
use App\Domain\Financial\Models\Payment; use App\Domain\Financial\Models\Payment;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\Enrollment; use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
...@@ -32,70 +31,23 @@ public function render() ...@@ -32,70 +31,23 @@ public function render()
$academyId = app('current_academy')?->id; $academyId = app('current_academy')?->id;
// Total subscription revenue (via enrollments → invoices → payments) // Current period subscription revenue (pro-rata from invoice items)
$totalSubscriptionRevenue = DB::table('payments') $totalSubscriptionRevenue = $this->getSubscriptionRevenue($startDate, null, $academyId);
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->sum('payments.amount');
// Previous period for comparison // Previous period
$previousSubscriptionRevenue = DB::table('payments') $previousSubscriptionRevenue = $this->getSubscriptionRevenue($previousStart, $startDate, $academyId);
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->where('payments.status', 'confirmed')
->whereBetween('payments.created_at', [$previousStart, $startDate])
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->sum('payments.amount');
$change = $previousSubscriptionRevenue > 0 $change = $previousSubscriptionRevenue > 0
? round(($totalSubscriptionRevenue - $previousSubscriptionRevenue) / $previousSubscriptionRevenue * 100, 1) ? round(($totalSubscriptionRevenue - $previousSubscriptionRevenue) / $previousSubscriptionRevenue * 100, 1)
: ($totalSubscriptionRevenue > 0 ? 100 : 0); : ($totalSubscriptionRevenue > 0 ? 100 : 0);
// Revenue breakdown by activity // Revenue by activity
$byActivity = DB::table('payments') $byActivity = $this->getRevenueByActivity($startDate, $academyId);
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->join('training_programs', 'enrollments.training_program_id', '=', 'training_programs.id')
->join('activities', 'training_programs.activity_id', '=', 'activities.id')
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select(
'activities.id as activity_id',
'activities.name_ar as activity_name',
'activities.category',
DB::raw('SUM(payments.amount) as total'),
DB::raw('COUNT(DISTINCT enrollments.id) as enrollment_count'),
)
->groupBy('activities.id', 'activities.name_ar', 'activities.category')
->orderByDesc('total')
->get();
// Top programs (across all activities) // Top programs by subscription revenue
$topPrograms = DB::table('payments') $topPrograms = $this->getTopPrograms($startDate, $academyId);
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->join('training_programs', 'enrollments.training_program_id', '=', 'training_programs.id')
->join('activities', 'training_programs.activity_id', '=', 'activities.id')
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select(
'training_programs.id as program_id',
'training_programs.name_ar as program_name',
'activities.name_ar as activity_name',
DB::raw('SUM(payments.amount) as total'),
DB::raw('COUNT(DISTINCT enrollments.id) as enrollment_count'),
)
->groupBy('training_programs.id', 'training_programs.name_ar', 'activities.name_ar')
->orderByDesc('total')
->limit(10)
->get();
// New enrollments count this period // Enrollment counts
$newEnrollments = Enrollment::where('created_at', '>=', $startDate) $newEnrollments = Enrollment::where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->count(); ->count();
...@@ -104,7 +56,7 @@ public function render() ...@@ -104,7 +56,7 @@ public function render()
->when($academyId, fn ($q) => $q->where('academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->count(); ->count();
// Total confirmed payments this period (to compute subscription %) // Total confirmed payments this period
$totalRevenue = Payment::where('status', 'confirmed') $totalRevenue = Payment::where('status', 'confirmed')
->where('created_at', '>=', $startDate) ->where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId)) ->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
...@@ -125,4 +77,139 @@ public function render() ...@@ -125,4 +77,139 @@ public function render()
'totalRevenue' => $totalRevenue, 'totalRevenue' => $totalRevenue,
]); ]);
} }
/**
* Calculate subscription revenue using pro-rata allocation on bundled invoices.
* Subscription items = invoice_items WHERE itemable_type IS NULL.
* For each payment: payment.amount * (invoice_sub_total / invoice.total_amount)
*/
private function getSubscriptionRevenue($from, $to, $academyId): int
{
$result = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->joinSub(
DB::table('invoice_items')
->whereNull('itemable_type')
->select('invoice_id', DB::raw('SUM(total_amount) as sub_total'))
->groupBy('invoice_id'),
'sub_items',
'sub_items.invoice_id', '=', 'invoices.id'
)
->where('payments.status', 'confirmed')
->where('invoices.total_amount', '>', 0)
->where('payments.created_at', '>=', $from)
->when($to, fn ($q) => $q->where('payments.created_at', '<', $to))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select(
DB::raw('SUM(payments.amount * sub_items.sub_total / invoices.total_amount) as subscription_revenue')
)
->value('subscription_revenue');
return (int) ($result ?? 0);
}
/**
* Break down subscription revenue by activity.
* Uses the participant's latest enrollment to determine which activity the subscription belongs to.
* For participants with multiple activities, revenue is attributed to each proportionally.
*/
private function getRevenueByActivity($from, $academyId)
{
// Get per-payment subscription amounts with participant info
// Then group by the participant's enrolled activity
return DB::select("
WITH payment_sub_revenue AS (
SELECT
p.id as payment_id,
p.amount * si.sub_total / i.total_amount as sub_revenue,
i.billable_id as participant_id
FROM payments p
JOIN invoices i ON p.invoice_id = i.id
JOIN (
SELECT invoice_id, SUM(total_amount) as sub_total
FROM invoice_items
WHERE itemable_type IS NULL
GROUP BY invoice_id
) si ON si.invoice_id = i.id
WHERE p.status = 'confirmed'
AND i.total_amount > 0
AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant'
AND p.created_at >= ?
" . ($academyId ? "AND p.academy_id = {$academyId}" : "") . "
),
participant_activity AS (
SELECT DISTINCT ON (e.participant_id)
e.participant_id,
a.id as activity_id,
a.name_ar as activity_name,
a.category
FROM enrollments e
JOIN training_programs tp ON e.training_program_id = tp.id
JOIN activities a ON tp.activity_id = a.id
WHERE e.status IN ('active', 'completed')
ORDER BY e.participant_id, e.created_at DESC
)
SELECT
pa.activity_id,
pa.activity_name,
pa.category,
SUM(psr.sub_revenue) as total,
COUNT(DISTINCT psr.participant_id) as subscriber_count
FROM payment_sub_revenue psr
JOIN participant_activity pa ON pa.participant_id = psr.participant_id
GROUP BY pa.activity_id, pa.activity_name, pa.category
ORDER BY total DESC
", [$from]);
}
/**
* Top programs by subscription revenue collected.
*/
private function getTopPrograms($from, $academyId)
{
return DB::select("
WITH payment_sub_revenue AS (
SELECT
p.id as payment_id,
p.amount * si.sub_total / i.total_amount as sub_revenue,
i.billable_id as participant_id
FROM payments p
JOIN invoices i ON p.invoice_id = i.id
JOIN (
SELECT invoice_id, SUM(total_amount) as sub_total
FROM invoice_items
WHERE itemable_type IS NULL
GROUP BY invoice_id
) si ON si.invoice_id = i.id
WHERE p.status = 'confirmed'
AND i.total_amount > 0
AND i.billable_type = 'App\\Domain\\Participant\\Models\\Participant'
AND p.created_at >= ?
" . ($academyId ? "AND p.academy_id = {$academyId}" : "") . "
),
participant_program AS (
SELECT DISTINCT ON (e.participant_id)
e.participant_id,
tp.id as program_id,
tp.name_ar as program_name,
a.name_ar as activity_name
FROM enrollments e
JOIN training_programs tp ON e.training_program_id = tp.id
JOIN activities a ON tp.activity_id = a.id
WHERE e.status IN ('active', 'completed')
ORDER BY e.participant_id, e.created_at DESC
)
SELECT
pp.program_id,
pp.program_name,
pp.activity_name,
SUM(psr.sub_revenue) as total,
COUNT(DISTINCT psr.participant_id) as subscriber_count
FROM payment_sub_revenue psr
JOIN participant_program pp ON pp.participant_id = psr.participant_id
GROUP BY pp.program_id, pp.program_name, pp.activity_name
ORDER BY total DESC
LIMIT 10
", [$from]);
}
} }
...@@ -87,7 +87,7 @@ ...@@ -87,7 +87,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full {{ match($loop->index % 6) { 0 => 'bg-emerald-500', 1 => 'bg-blue-500', 2 => 'bg-purple-500', 3 => 'bg-amber-500', 4 => 'bg-pink-500', 5 => 'bg-cyan-500', default => 'bg-gray-500' } }}"></span> <span class="w-2.5 h-2.5 rounded-full {{ match($loop->index % 6) { 0 => 'bg-emerald-500', 1 => 'bg-blue-500', 2 => 'bg-purple-500', 3 => 'bg-amber-500', 4 => 'bg-pink-500', 5 => 'bg-cyan-500', default => 'bg-gray-500' } }}"></span>
<span class="text-sm font-medium text-gray-700">{{ $activity->activity_name }}</span> <span class="text-sm font-medium text-gray-700">{{ $activity->activity_name }}</span>
<span class="text-xs text-gray-400">({{ $activity->enrollment_count }} {{ __('اشتراك') }})</span> <span class="text-xs text-gray-400">({{ $activity->subscriber_count }} {{ __('مشترك') }})</span>
</div> </div>
<span class="text-sm font-bold text-gray-800" dir="ltr">{{ number_format($activity->total / 100, 0) }} {{ __('ج.م') }}</span> <span class="text-sm font-bold text-gray-800" dir="ltr">{{ number_format($activity->total / 100, 0) }} {{ __('ج.م') }}</span>
</div> </div>
...@@ -121,7 +121,7 @@ ...@@ -121,7 +121,7 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ $program->program_name }}</p> <p class="text-sm font-medium text-gray-800 truncate">{{ $program->program_name }}</p>
<p class="text-xs text-gray-500">{{ $program->activity_name }} &mdash; {{ $program->enrollment_count }} {{ __('اشتراك') }}</p> <p class="text-xs text-gray-500">{{ $program->activity_name }} &mdash; {{ $program->subscriber_count }} {{ __('مشترك') }}</p>
</div> </div>
<span class="text-sm font-bold text-gray-700 whitespace-nowrap" dir="ltr">{{ number_format($program->total / 100, 0) }} {{ __('ج.م') }}</span> <span class="text-sm font-bold text-gray-700 whitespace-nowrap" dir="ltr">{{ number_format($program->total / 100, 0) }} {{ __('ج.م') }}</span>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment