Commit b6fd3fb7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(groups): show per-product ownership and real amounts paid

A programme can now bundle products it requires (program_products), so
the group view can answer "who has not bought their registration card"
— which nothing in the system could express before. products.is_essential
is global; this is per-programme.

Each bundled product gets its own column: bought or not, a progress bar,
and the amount settled against the amount billed. Instalments fall out
of this for free rather than needing their own column.

Reading a payment off a line is not possible here — a subscription and a
registration card routinely share one invoice. ParticipantBillingService
allocates each payment across the lines it covers, pro rata on
subtotal_amount, rounding down so the remainder stays unallocated rather
than inventing money. Allocation is capped at the amount billed: a
payment settles total_amount, which also carries tax and fees, so paying
in full would otherwise allocate over 100% of a line. Verified against
production — no invoice over-allocates.

The payment column now shows the amount paid rather than a bare "paid",
with مجاني for free players and لم يدفع for unpaid, and participants
carry their عضو / غير عضو tag. The enrolment-date column is gone.
Total collected is shown to users with invoices.list.

The bundling migration is conditional: it acts only where an academy has
both an active product named قيد and programmes named فريق. Elsewhere it
does nothing, which is what makes it safe for every tenant. On oc-sport
that is exactly one product across 12 programmes.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 35200985
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Participant\Models\Participant;
use Illuminate\Support\Facades\DB;
/**
* How much a participant has actually paid toward one part of their bill.
*
* Invoices here are routinely bundled — a subscription and a registration card
* on the same invoice, sometimes several products — and a payment lands on the
* invoice, never on a line. So "how much did they pay for the card" can only be
* answered by allocating each payment across the lines in proportion to their
* share of the invoice.
*
* The denominator is subtotal_amount (the sum of the line totals), not
* total_amount: Invoice::recalculateTotals() defines
* total = subtotal - discount + tax + service_fee, so dividing by total makes
* the shares exceed 1 whenever a header discount exists.
*
* All amounts are piastres. Integer arithmetic only.
*/
class ParticipantBillingService
{
/**
* Piastres paid toward programme subscription lines, keyed by participant id.
*
* A subscription line is one with no itemable — products and kits carry a
* morph. (POS sales historically left that null, which is what made product
* sales read as subscription revenue; see the 2026_09_01 backfill.)
*/
public function subscriptionPaid(array $participantIds): array
{
if (empty($participantIds)) {
return [];
}
return $this->allocate($participantIds, function ($q) {
$q->whereNull('invoice_items.itemable_type');
});
}
/**
* Piastres paid toward one product, keyed by participant id.
*/
public function productPaid(array $participantIds, int $productId): array
{
if (empty($participantIds)) {
return [];
}
return $this->allocate($participantIds, function ($q) use ($productId) {
$q->where('invoice_items.itemable_type', \App\Domain\Inventory\Models\Product::class)
->where('invoice_items.itemable_id', $productId);
});
}
/**
* What each participant was billed for one product, keyed by participant id.
*
* Billed is not the same as paid — someone paying in instalments is billed
* the full amount on day one. Both numbers are needed to show progress.
*
* @return array<int, array{billed:int, quantity:int}>
*/
public function productBilled(array $participantIds, int $productId): array
{
if (empty($participantIds)) {
return [];
}
$rows = DB::table('invoice_items')
->join('invoices', 'invoices.id', '=', 'invoice_items.invoice_id')
->where('invoice_items.itemable_type', \App\Domain\Inventory\Models\Product::class)
->where('invoice_items.itemable_id', $productId)
->where('invoices.billable_type', Participant::class)
->whereIn('invoices.billable_id', $participantIds)
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->groupBy('invoices.billable_id')
->select(
'invoices.billable_id',
DB::raw('SUM(invoice_items.total_amount) as billed'),
DB::raw('SUM(invoice_items.quantity) as quantity')
)
->get();
$out = [];
foreach ($rows as $r) {
$out[(int) $r->billable_id] = [
'billed' => (int) $r->billed,
'quantity' => (int) $r->quantity,
];
}
return $out;
}
/**
* Allocate confirmed inbound payments across the lines matched by $filter,
* in proportion to those lines' share of each invoice.
*
* @return array<int, int> participant id => piastres
*/
private function allocate(array $participantIds, callable $filter): array
{
// Share of each invoice represented by the lines we care about.
$shares = DB::table('invoice_items')
->join('invoices', 'invoices.id', '=', 'invoice_items.invoice_id')
->where('invoices.billable_type', Participant::class)
->whereIn('invoices.billable_id', $participantIds)
->whereNull('invoices.deleted_at')
->where('invoices.status', '!=', 'cancelled')
->where('invoices.subtotal_amount', '>', 0)
->tap($filter)
->groupBy('invoice_items.invoice_id')
->select('invoice_items.invoice_id', DB::raw('SUM(invoice_items.total_amount) as part_total'))
->get()
->keyBy('invoice_id');
if ($shares->isEmpty()) {
return [];
}
$rows = DB::table('payments')
->join('invoices', 'invoices.id', '=', 'payments.invoice_id')
->whereIn('payments.invoice_id', $shares->keys()->all())
->where('payments.status', 'confirmed')
->where('payments.direction', 'inbound')
->whereNull('payments.deleted_at')
->groupBy('invoices.billable_id', 'payments.invoice_id', 'invoices.subtotal_amount')
->select(
'invoices.billable_id',
'payments.invoice_id',
'invoices.subtotal_amount',
DB::raw('SUM(payments.amount) as paid')
)
->get();
$out = [];
foreach ($rows as $r) {
$part = (int) ($shares[$r->invoice_id]->part_total ?? 0);
$subtotal = (int) $r->subtotal_amount;
if ($part <= 0 || $subtotal <= 0) {
continue;
}
// Round down; the remainder stays unallocated rather than inventing
// money that was never paid.
$allocated = intdiv((int) $r->paid * $part, $subtotal);
// The share is of subtotal_amount, but a payment settles
// total_amount — which also carries tax and service fees. Paying an
// invoice in full therefore allocates slightly more than the line
// was billed, and a progress bar would read over 100%. Nobody can
// have paid more toward a line than it cost.
$allocated = min($allocated, $part);
$pid = (int) $r->billable_id;
$out[$pid] = ($out[$pid] ?? 0) + $allocated;
}
return $out;
}
}
...@@ -87,6 +87,22 @@ public function groups(): HasMany ...@@ -87,6 +87,22 @@ public function groups(): HasMany
return $this->hasMany(TrainingGroup::class, 'training_program_id'); return $this->hasMany(TrainingGroup::class, 'training_program_id');
} }
/**
* Products a participant must buy to be properly enrolled in this
* programme. Unlike products.is_essential (a global flag) this is per
* programme, so a "فرق" team can require a registration card while an
* academy-hour programme does not.
*/
public function bundledProducts()
{
return $this->belongsToMany(
\App\Domain\Inventory\Models\Product::class,
'program_products',
'training_program_id',
'product_id'
)->withPivot(['is_required', 'quantity'])->withTimestamps();
}
public function enrollments(): HasMany public function enrollments(): HasMany
{ {
return $this->hasMany(Enrollment::class, 'training_program_id'); return $this->hasMany(Enrollment::class, 'training_program_id');
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use App\Domain\Financial\Models\InvoiceItem; use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Models\PaymentPlan; use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Inventory\Models\Product; use App\Domain\Inventory\Models\Product;
use App\Domain\Financial\Services\ParticipantBillingService;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Scheduling\Models\Assignment; use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Enums\SessionStatus; use App\Domain\Training\Enums\SessionStatus;
...@@ -330,6 +331,62 @@ public function render() ...@@ -330,6 +331,62 @@ public function render()
->orderBy('name_ar') ->orderBy('name_ar')
->get(['id', 'name_ar', 'name']); ->get(['id', 'name_ar', 'name']);
// ---- Money actually collected, per participant -------------------
//
// Invoices are routinely bundled (subscription + registration card on
// one invoice), so a payment cannot simply be read off a line. The
// billing service allocates each payment across the lines it covers.
$billing = app(ParticipantBillingService::class);
$subscriptionPaid = $billing->subscriptionPaid($participantIds);
// Products this programme requires. Falls back to nothing when the
// programme has no bundle configured, so groups are unaffected until
// someone sets one up.
$bundledProducts = $this->group->program
? $this->group->program->bundledProducts()->get()
: collect();
$bundleColumns = [];
foreach ($bundledProducts as $product) {
$paid = $billing->productPaid($participantIds, $product->id);
$billed = $billing->productBilled($participantIds, $product->id);
$rows = [];
foreach ($participantIds as $pid) {
$billedAmount = $billed[$pid]['billed'] ?? 0;
$paidAmount = $paid[$pid] ?? 0;
$owned = $billedAmount > 0;
$rows[$pid] = [
'owned' => $owned,
'billed' => $billedAmount,
'paid' => $paidAmount,
// Percentage of the product's price actually settled.
'percent' => $billedAmount > 0
? min(100, (int) round($paidAmount * 100 / $billedAmount))
: 0,
'fully_paid' => $owned && $paidAmount >= $billedAmount,
];
}
$bundleColumns[] = [
'product' => $product,
'required' => (bool) ($product->pivot->is_required ?? true),
'rows' => $rows,
'missing_count' => count(array_filter($rows, fn ($r) => ! $r['owned'])),
];
}
// Collected for this group, for the header summary. Only shown to users
// allowed to see money.
$groupCollected = array_sum($subscriptionPaid);
foreach ($bundleColumns as $col) {
$groupCollected += array_sum(array_column($col['rows'], 'paid'));
}
$canSeeFinancials = auth()->user()?->can('invoices.list') ?? false;
return view('livewire.groups.group-show', [ return view('livewire.groups.group-show', [
'activeEnrollments' => $activeEnrollments, 'activeEnrollments' => $activeEnrollments,
'notPaidParticipantIds' => $notPaidParticipantIds, 'notPaidParticipantIds' => $notPaidParticipantIds,
...@@ -338,6 +395,10 @@ public function render() ...@@ -338,6 +395,10 @@ public function render()
'freeCount' => $freeCount, 'freeCount' => $freeCount,
'essentialProductStats' => $essentialProductStats, 'essentialProductStats' => $essentialProductStats,
'participantInstallments' => $participantInstallments, 'participantInstallments' => $participantInstallments,
'subscriptionPaid' => $subscriptionPaid,
'bundleColumns' => $bundleColumns,
'groupCollected' => $groupCollected,
'canSeeFinancials' => $canSeeFinancials,
'totalEnrollments' => $totalEnrollments, 'totalEnrollments' => $totalEnrollments,
'recentSessions' => $recentSessions, 'recentSessions' => $recentSessions,
'upcomingSessions' => $upcomingSessions, 'upcomingSessions' => $upcomingSessions,
......
...@@ -58,11 +58,21 @@ class ProgramForm extends Component ...@@ -58,11 +58,21 @@ class ProgramForm extends Component
public string $member_price = ''; public string $member_price = '';
public string $non_member_price = ''; public string $non_member_price = '';
/**
* Product ids that come bundled with this programme. A player cannot be
* considered properly enrolled without them, and the group view flags
* anyone missing one.
*
* @var array<int>
*/
public array $bundled_product_ids = [];
public function mount(?TrainingProgram $program = null): void public function mount(?TrainingProgram $program = null): void
{ {
if ($program && $program->exists) { if ($program && $program->exists) {
$this->program = $program; $this->program = $program;
$this->editing = true; $this->editing = true;
$this->bundled_product_ids = $program->bundledProducts()->pluck('products.id')->all();
$this->name = $program->name ?? ''; $this->name = $program->name ?? '';
$this->name_ar = $program->name_ar; $this->name_ar = $program->name_ar;
$this->slug = $program->slug ?? ''; $this->slug = $program->slug ?? '';
...@@ -236,10 +246,12 @@ public function save(TrainingProgramService $service): void ...@@ -236,10 +246,12 @@ public function save(TrainingProgramService $service): void
if ($this->editing) { if ($this->editing) {
$service->update($this->program, $data); $service->update($this->program, $data);
$this->savePrices($this->program); $this->savePrices($this->program);
$this->saveBundledProducts($this->program);
session()->flash('success', __('تم تحديث البرنامج بنجاح')); session()->flash('success', __('تم تحديث البرنامج بنجاح'));
} else { } else {
$program = $service->create($data, auth()->user()); $program = $service->create($data, auth()->user());
$this->savePrices($program); $this->savePrices($program);
$this->saveBundledProducts($program);
session()->flash('success', __('تم إنشاء البرنامج بنجاح')); session()->flash('success', __('تم إنشاء البرنامج بنجاح'));
} }
...@@ -249,6 +261,26 @@ public function save(TrainingProgramService $service): void ...@@ -249,6 +261,26 @@ public function save(TrainingProgramService $service): void
} }
} }
/**
* Sync the bundle. academy_id is written explicitly because the pivot has
* no model and so no BelongsToAcademy hook to stamp it.
*/
private function saveBundledProducts(TrainingProgram $program): void
{
$academyId = $program->academy_id ?? app('current_academy')?->id;
$payload = [];
foreach (array_filter($this->bundled_product_ids) as $productId) {
$payload[(int) $productId] = [
'academy_id' => $academyId,
'is_required' => true,
'quantity' => 1,
];
}
$program->bundledProducts()->sync($payload);
}
private function savePrices(TrainingProgram $program): void private function savePrices(TrainingProgram $program): void
{ {
$types = [ $types = [
...@@ -300,6 +332,8 @@ public function render() ...@@ -300,6 +332,8 @@ public function render()
$workloads = app(TrainerWorkloadService::class)->getWorkloadsForUsers($trainers->pluck('id')->toArray()); $workloads = app(TrainerWorkloadService::class)->getWorkloadsForUsers($trainers->pluck('id')->toArray());
return view('livewire.programs.program-form', [ return view('livewire.programs.program-form', [
'availableProducts' => \App\Domain\Inventory\Models\Product::where('is_active', true)
->orderBy('name_ar')->get(['id', 'name_ar', 'selling_price']),
'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']), 'activities' => Activity::where('is_active', true)->orderBy('name_ar')->get(['id', 'name_ar']),
'branches' => Branch::orderBy('name_ar')->get(['id', 'name_ar']), 'branches' => Branch::orderBy('name_ar')->get(['id', 'name_ar']),
'trainers' => $trainers, 'trainers' => $trainers,
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Products that come bundled with a training programme.
*
* `products.is_essential` already exists but is a global flag — "every player
* everywhere should own this". That cannot express "a player in فرق must own a
* registration card, but an academy-hour player need not". This pivot does.
*
* is_required = true means enrolment in the programme is not complete until the
* product is bought, and the group view flags anyone missing it.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('program_products')) {
return;
}
Schema::create('program_products', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained('academies')->cascadeOnDelete();
$table->foreignId('training_program_id')->constrained('training_programs')->cascadeOnDelete();
$table->foreignId('product_id')->constrained('products')->cascadeOnDelete();
$table->boolean('is_required')->default(true);
$table->unsignedInteger('quantity')->default(1);
$table->timestamps();
// Tenant-scoped uniqueness, per the academy_id rule.
$table->unique(['academy_id', 'training_program_id', 'product_id'], 'program_products_unique');
$table->index(['training_program_id', 'is_required']);
});
}
public function down(): void
{
Schema::dropIfExists('program_products');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Bundle a club's registration product ("قيد") onto its team ("فريق") programmes.
*
* Every player in a team programme is required to hold a registration card, but
* nothing in the system encoded that — so the group view had no way to flag who
* was missing one.
*
* Deliberately conditional: it only acts where an academy has BOTH an active
* product whose name contains "قيد" AND programmes whose name begins with
* "فريق". On any client without that shape it does nothing at all, which is
* what makes it safe to ship to every tenant.
*
* Idempotent — the pivot's unique key means re-running inserts nothing.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('program_products')
|| ! Schema::hasTable('products')
|| ! Schema::hasTable('training_programs')) {
return;
}
$products = DB::table('products')
->where('is_active', true)
->whereNull('deleted_at')
->where('name_ar', 'ilike', '%قيد%')
->get(['id', 'academy_id']);
foreach ($products as $product) {
$programIds = DB::table('training_programs')
->where('academy_id', $product->academy_id)
->whereNull('deleted_at')
->where('name_ar', 'ilike', 'فريق%')
->pluck('id');
foreach ($programIds as $programId) {
$exists = DB::table('program_products')
->where('training_program_id', $programId)
->where('product_id', $product->id)
->exists();
if ($exists) {
continue;
}
DB::table('program_products')->insert([
'academy_id' => $product->academy_id,
'training_program_id' => $programId,
'product_id' => $product->id,
'is_required' => true,
'quantity' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
public function down(): void
{
if (! Schema::hasTable('program_products')) {
return;
}
// Remove only the links this migration could have created.
$productIds = DB::table('products')->where('name_ar', 'ilike', '%قيد%')->pluck('id');
if ($productIds->isEmpty()) {
return;
}
$programIds = DB::table('training_programs')->where('name_ar', 'ilike', 'فريق%')->pluck('id');
DB::table('program_products')
->whereIn('product_id', $productIds)
->whereIn('training_program_id', $programIds)
->delete();
}
};
...@@ -254,6 +254,30 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 ...@@ -254,6 +254,30 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2
<p class="text-xs text-gray-500 mt-1">{{ __('السعر لغير الأعضاء') }}</p> <p class="text-xs text-gray-500 mt-1">{{ __('السعر لغير الأعضاء') }}</p>
</div> </div>
</div> </div>
{{-- Bundled products — a player is not properly enrolled in this
programme until they own these. The group view flags anyone
missing one, and the registration wizard offers them. --}}
<div class="pt-6 border-t border-gray-200">
<h3 class="text-sm font-bold text-gray-800 mb-1">{{ __('منتجات مرتبطة بالبرنامج') }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ __('أي لاعب في هذا البرنامج لازم يشتري المنتجات دي. هتظهر في شاشة المجموعة ومين اشتراها ومين لأ.') }}</p>
@if(count($availableProducts) === 0)
<p class="text-xs text-gray-400">{{ __('لا توجد منتجات نشطة') }}</p>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-64 overflow-y-auto p-1">
@foreach($availableProducts as $product)
<label class="flex items-center gap-2 p-2.5 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-50 transition">
<input type="checkbox" wire:model="bundled_product_ids" value="{{ $product->id }}"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700 flex-1 truncate">{{ $product->name_ar }}</span>
<span class="text-xs text-gray-400 whitespace-nowrap" dir="ltr">{{ number_format($product->selling_price / 100, 0) }}</span>
</label>
@endforeach
</div>
@endif
</div>
</div> </div>
{{-- Submit --}} {{-- Submit --}}
......
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