Commit d2f4bf17 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(groups): show what a player paid this month, and why it is that number

The roster's الدفع column answered a different question from the one it
appeared to answer, in three compounding ways.

It showed a LIFETIME subscription total beside a monthly bill. A player who paid
650 in July, 1,200 for a kit bag and 650 in August read as "2,500" for the
current month. Two months of subscriptions were simply added together.

"Subscription" was defined as "an invoice line with no product link" — a
negative definition, so every hand-typed line became subscription money. That
kit bag was typed as free text, so it landed in the subscription figure, was
missing from product revenue, and left the same screen reporting the player had
never bought the kit they had paid for.

The red "has not paid" flag came from an unrelated calculation: matching invoice
text with ilike %اشتراك% plus the programme name. Substring matching on Arabic
also decides that تجهيزي contains زي. On live data the flag and the amount
disagreed on 28 of 247 active enrolments — red rows showing a green figure. The
template's "show unpaid only if flagged AND the amount is zero" guard was not
defensive coding; it was two sources of truth being reconciled where the
disagreement stopped being visible.

The figure is now this billing cycle only, derived from the programme's own
cycle rather than the calendar month, and one computation feeds the amount, the
row flag and the header counts — so they cannot contradict each other again.

Each figure is colour-coded by WHY it is that number, with a legend above the
table: paid in full, pro-rated for a mid-month join, admin discount, line price
override, instalment, partial, unpaid, not yet billed, free. All of it was
already recorded in invoice and line metadata and never surfaced; the reason,
who applied it and the original price now appear on the row. Colour never
carries the meaning alone — each amount also shows a glyph, a label and a
screen-reader sentence, and every case sits at 4.5:1 against white.

The migration links hand-typed product lines to their product where the full
trimmed description matches a product name exactly. Substrings are deliberately
not matched and ambiguous lines are left alone: 44 lines / 209,200 EGP link
safely, 23 lines / 42,800 EGP are reported for a human instead of guessed at.

Verified by replaying the real production rows behind both reported screenshots
through the service: every figure the user questioned is now explained.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 820ea078
This diff is collapsed.
......@@ -24,7 +24,13 @@
class ParticipantBillingService
{
/**
* Piastres paid toward programme subscription lines, keyed by participant id.
* Piastres paid toward programme subscription lines over ALL TIME, keyed by
* participant id.
*
* This is a lifetime total and answers "how much has this person ever paid
* us for subscriptions" — it is NOT the current month. A roster showing it
* beside a monthly bill reads as though someone paid 2,500 for one month.
* For anything cycle-shaped, use subscriptionForPeriod().
*
* 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
......@@ -41,6 +47,165 @@ public function subscriptionPaid(array $participantIds): array
});
}
/**
* What each participant was billed and has paid for programme subscription
* within ONE billing cycle, plus why the figure is what it is.
*
* `billed` is what the subscription lines came to; `due` is that share of
* the invoice total, so a header discount lowers what they actually owe
* while `billed` still shows the undiscounted price. Comparing the two is
* what lets the roster say "650 of 900, because an admin discounted it"
* rather than just "650".
*
* $periodStart is inclusive, $periodEnd exclusive, both Y-m-d.
*
* @return array<int, array{billed:int, due:int, paid:int, prorated:bool,
* proration_label:?string, admin_override:?array, price_override:?array,
* has_plan:bool, plan:?array, invoice_numbers:array<int,string>}>
*/
public function subscriptionForPeriod(array $participantIds, string $periodStart, string $periodEnd): array
{
if (empty($participantIds)) {
return [];
}
$invoices = DB::table('invoices')
->where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds)
->whereNull('deleted_at')
->where('status', '!=', 'cancelled')
->where('subtotal_amount', '>', 0)
->where('issue_date', '>=', $periodStart)
->where('issue_date', '<', $periodEnd)
->get(['id', 'number', 'billable_id', 'subtotal_amount', 'total_amount', 'metadata']);
if ($invoices->isEmpty()) {
return [];
}
$invoiceIds = $invoices->pluck('id')->all();
// Subscription lines only — anything carrying a morph is a product or a
// kit and belongs in its own column, not in the subscription figure.
$items = DB::table('invoice_items')
->whereIn('invoice_id', $invoiceIds)
->whereNull('itemable_type')
->get(['invoice_id', 'description', 'total_amount', 'metadata'])
->groupBy('invoice_id');
$payments = DB::table('payments')
->whereIn('invoice_id', $invoiceIds)
->where('status', 'confirmed')
->where('direction', 'inbound')
->whereNull('deleted_at')
->groupBy('invoice_id')
->select('invoice_id', DB::raw('SUM(amount) as paid'))
->pluck('paid', 'invoice_id');
$plans = DB::table('payment_plans')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['active', 'partial'])
->get(['invoice_id', 'total_installments', 'paid_installments', 'installment_amount'])
->keyBy('invoice_id');
$out = [];
foreach ($invoices as $invoice) {
$lines = $items[$invoice->id] ?? collect();
$billed = (int) $lines->sum('total_amount');
if ($billed <= 0) {
continue;
}
$subtotal = (int) $invoice->subtotal_amount;
$total = (int) $invoice->total_amount;
$paidOnInvoice = (int) ($payments[$invoice->id] ?? 0);
// Round down throughout; an unallocated remainder is honest, money
// conjured by rounding up is not.
$due = min(intdiv($total * $billed, $subtotal), $billed);
$paid = min(intdiv($paidOnInvoice * $billed, $subtotal), $billed);
$pid = (int) $invoice->billable_id;
$row = $out[$pid] ?? [
'billed' => 0,
'due' => 0,
'paid' => 0,
'prorated' => false,
'proration_label' => null,
'admin_override' => null,
'price_override' => null,
'has_plan' => false,
'plan' => null,
'invoice_numbers' => [],
];
$row['billed'] += $billed;
$row['due'] += $due;
$row['paid'] += $paid;
$row['invoice_numbers'][] = $invoice->number;
foreach ($lines as $line) {
// Proration is recorded only in the line text the pricing engine
// wrote ("متناسب: 13 من 30 يوم"), so that is where it is read from.
if ($line->description && str_contains($line->description, 'متناسب')) {
$row['prorated'] = true;
$row['proration_label'] ??= $line->description;
}
$meta = $this->decodeMetadata($line->metadata);
if (! empty($meta['overridden_by'])) {
$row['price_override'] ??= [
'by' => $meta['overridden_by'],
'reason' => $meta['override_reason'] ?? null,
'original' => (int) ($meta['original_price'] ?? 0),
'final' => (int) ($meta['overridden_price'] ?? 0),
];
}
}
$invoiceMeta = $this->decodeMetadata($invoice->metadata);
if (! empty($invoiceMeta['admin_override'])) {
$o = $invoiceMeta['admin_override'];
$row['admin_override'] ??= [
'by' => $o['applied_by_name'] ?? null,
'reason' => $o['reason'] ?? null,
'discount' => (int) ($o['discount_piasters'] ?? 0),
'at' => $o['applied_at'] ?? null,
];
}
if ($plan = $plans[$invoice->id] ?? null) {
$row['has_plan'] = true;
$row['plan'] ??= [
'paid' => (int) $plan->paid_installments,
'total' => (int) $plan->total_installments,
'amount' => (int) $plan->installment_amount,
];
}
$out[$pid] = $row;
}
return $out;
}
private function decodeMetadata(mixed $raw): array
{
if (is_array($raw)) {
return $raw;
}
if (! is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Piastres paid toward one product, keyed by participant id.
*/
......
......@@ -5,7 +5,7 @@
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Enums\SubscriptionPaymentCase;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Inventory\Models\Product;
......@@ -41,6 +41,38 @@ public function sortEnrollments(string $column): void
}
}
/**
* The billing cycle that contains today, as [start, end) in Y-m-d.
*
* Derived from the programme's own cycle rather than the calendar month:
* "this month" is only the same thing while billing_day is 1, and a roster
* that silently disagrees with the invoice run is worse than no roster.
*/
private function currentCycleWindow(): array
{
$program = $this->group->program;
$months = match ($program?->billing_cycle) {
'quarterly' => 3,
'semi_annual' => 6,
'annual' => 12,
default => 1,
};
$day = max(1, (int) ($program?->billing_day ?? 1));
$today = now();
$anchor = $today->copy()->startOfMonth();
$start = $anchor->copy()->addDays(min($day, $anchor->daysInMonth) - 1);
// Before this month's billing day, the live cycle is the previous one.
if ($today->lt($start)) {
$start = $start->subMonths($months);
}
return [$start->toDateString(), $start->copy()->addMonths($months)->toDateString()];
}
public function mount(TrainingGroup $group): void
{
$this->authorize('groups.list');
......@@ -126,68 +158,56 @@ public function render()
$activeEnrollments = $enrollQuery->get();
// Subscription payment status — ONLY checks program subscription, not products
$participantIds = $activeEnrollments->pluck('participant_id')->toArray();
$notPaidParticipantIds = [];
$programName = $this->group->program?->name_ar ?? '';
foreach ($activeEnrollments as $enrollment) {
if ($enrollment->participant?->is_free) {
continue;
}
$paymentStatus = $enrollment->payment_status?->value ?? $enrollment->payment_status;
// Enrollment explicitly marked as paid or waived
if (in_array($paymentStatus, ['paid', 'waived'])) {
continue;
}
// Check if the enrollment's linked invoice is paid
if ($enrollment->invoice_id) {
$invoiceStatus = Invoice::where('id', $enrollment->invoice_id)
->whereNull('deleted_at')
->value('status');
$paidStatuses = [InvoiceStatus::Paid, InvoiceStatus::Overpaid];
if ($invoiceStatus && in_array($invoiceStatus, $paidStatuses)) {
continue;
}
}
// ---- Subscription, for the billing cycle we are actually in --------
//
// The old version answered a different question in each place: the red
// "unpaid" flag matched invoice descriptions with ilike '%اشتراك%' plus
// the programme name, while the amount beside it was a lifetime total.
// The two disagreed constantly — a row could be flagged unpaid and show
// a green figure, because they were never the same calculation. One
// computation now feeds the flag, the amount and the header counts.
$billing = app(ParticipantBillingService::class);
// Check if there's a paid renewal/subscription invoice for current period
$hasPaidSubscriptionInvoice = Invoice::where('billable_type', Participant::class)
->where('billable_id', $enrollment->participant_id)
->whereIn('status', [InvoiceStatus::Paid, InvoiceStatus::Overpaid])
->whereNull('deleted_at')
->when($enrollment->last_billed_at, fn ($q) => $q->where('issue_date', '>=', $enrollment->last_billed_at))
->whereHas('items', function ($q) use ($programName) {
$q->where('description', 'ilike', '%اشتراك%');
if ($programName) {
$q->where('description', 'ilike', "%{$programName}%");
}
})
->exists();
[$cycleStart, $cycleEnd] = $this->currentCycleWindow();
$cycleFacts = $billing->subscriptionForPeriod($participantIds, $cycleStart, $cycleEnd);
if ($hasPaidSubscriptionInvoice) {
continue;
}
$subscriptionCases = [];
foreach ($activeEnrollments as $enrollment) {
$pid = $enrollment->participant_id;
$facts = $cycleFacts[$pid] ?? null;
$notPaidParticipantIds[] = $enrollment->participant_id;
$subscriptionCases[$pid] = [
'case' => SubscriptionPaymentCase::classify($facts, (bool) $enrollment->participant?->is_free),
'facts' => $facts,
];
}
$notPaidParticipantIds = array_unique($notPaidParticipantIds);
// Payment summary: paid, unpaid, free
$freePlayerIds = $activeEnrollments
->filter(fn ($e) => $e->participant?->is_free)
->pluck('participant_id')
->toArray();
$freeCount = count($freePlayerIds);
$nonFreeParticipantIds = array_diff($participantIds, $freePlayerIds);
$paidCount = count(array_diff($nonFreeParticipantIds, $notPaidParticipantIds));
$unpaidCount = count(array_intersect($nonFreeParticipantIds, $notPaidParticipantIds));
// Legend entries, in precedence order, limited to cases actually present
// so the roster never explains a colour nobody can see.
$presentCases = array_values(array_unique(array_map(
fn ($row) => $row['case'],
$subscriptionCases
), SORT_REGULAR));
$caseLegend = array_values(array_filter(
SubscriptionPaymentCase::PRECEDENCE,
fn ($case) => in_array($case, $presentCases, true)
));
$countOf = fn (array $cases) => count(array_filter(
$subscriptionCases,
fn ($row) => in_array($row['case'], $cases, true)
));
$freeCount = $countOf([SubscriptionPaymentCase::Free]);
$unpaidCount = $countOf([
SubscriptionPaymentCase::Unpaid,
SubscriptionPaymentCase::Partial,
SubscriptionPaymentCase::NotBilled,
]);
$paidCount = count($subscriptionCases) - $freeCount - $unpaidCount;
// Essential products: who bought / didn't buy each one
$essentialProducts = Product::where('is_essential', true)
......@@ -336,10 +356,7 @@ public function render()
// 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.
......@@ -380,7 +397,9 @@ public function render()
// Collected for this group, for the header summary. Only shown to users
// allowed to see money.
$groupCollected = array_sum($subscriptionPaid);
// Subscription money for THIS cycle only; product money is one-off, so
// it is counted in full rather than pinned to a month.
$groupCollected = array_sum(array_column($cycleFacts, 'paid'));
foreach ($bundleColumns as $col) {
$groupCollected += array_sum(array_column($col['rows'], 'paid'));
}
......@@ -389,13 +408,15 @@ public function render()
return view('livewire.groups.group-show', [
'activeEnrollments' => $activeEnrollments,
'notPaidParticipantIds' => $notPaidParticipantIds,
'subscriptionCases' => $subscriptionCases,
'caseLegend' => $caseLegend,
'cycleStart' => $cycleStart,
'cycleEnd' => $cycleEnd,
'paidCount' => $paidCount,
'unpaidCount' => $unpaidCount,
'freeCount' => $freeCount,
'essentialProductStats' => $essentialProductStats,
'participantInstallments' => $participantInstallments,
'subscriptionPaid' => $subscriptionPaid,
'bundleColumns' => $bundleColumns,
'groupCollected' => $groupCollected,
'canSeeFinancials' => $canSeeFinancials,
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Point hand-typed product invoice lines at the product they sold.
*
* 2026_09_01_000002 repaired POS sales by matching each line back to its
* pos_transaction_item. Lines typed straight into the enrolment and payment
* wizards have no POS row to match against, so they stayed NULL — and a NULL
* itemable_type reads everywhere as "programme subscription". A shopping bag
* sold that way inflates the player's subscription figure, is missing from
* product revenue, and leaves the group roster claiming they never bought the
* kit they are wearing.
*
* Matching is on the FULL trimmed description being identical to the product's
* name, never a substring. Substring matching on Arabic is how you decide that
* "تجهيزي" (preparatory) contains "زي" (kit) and reclassify a subscription as
* merchandise. Partial descriptions ("الشنطة", "قسط القيد") are deliberately
* left alone: they are money, and a guess about money is worse than a gap.
*
* Only fills rows that are still NULL and only where exactly one product in
* that academy carries the name, so it is safe to re-run and cannot overwrite
* a correct value.
*/
return new class extends Migration
{
public function up(): void
{
foreach (['invoice_items', 'invoices', 'products'] as $table) {
if (! Schema::hasTable($table)) {
return;
}
}
foreach ([['invoice_items', 'itemable_type'], ['invoices', 'academy_id'], ['products', 'name_ar']] as [$table, $column]) {
if (! Schema::hasColumn($table, $column)) {
return;
}
}
DB::statement("
UPDATE invoice_items ii
SET itemable_type = ?, itemable_id = m.product_id
FROM invoices inv,
(
SELECT p.id AS product_id,
p.academy_id,
TRIM(p.name_ar) AS nm
FROM products p
WHERE p.deleted_at IS NULL
AND p.name_ar IS NOT NULL
AND TRIM(p.name_ar) <> ''
-- Two products sharing a name make the match ambiguous;
-- skip rather than pick one.
AND NOT EXISTS (
SELECT 1
FROM products q
WHERE q.deleted_at IS NULL
AND q.academy_id = p.academy_id
AND q.id <> p.id
AND TRIM(q.name_ar) = TRIM(p.name_ar)
)
) AS m
WHERE ii.invoice_id = inv.id
AND ii.itemable_type IS NULL
AND inv.academy_id = m.academy_id
AND TRIM(ii.description) = m.nm
", ['App\\Domain\\Inventory\\Models\\Product']);
}
public function down(): void
{
// Intentionally irreversible, for the same reason as
// 2026_09_01_000002: up() only fills rows that were NULL and keeps no
// record of which, so any rollback would also blank correct links
// written afterwards. Losing more than the rollback gains is not a
// rollback.
}
};
This diff is collapsed.
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