Commit f0f54ec3 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(groups): read the subscription column off what the invoice actually says

The roster column called itself الدفع and answered two questions wrongly.

WHAT COUNTS. "Subscription" was every invoice line with no itemable — which
is every line a receptionist typed by hand. On the live tenant that swept in
42,800 EGP of federation registration fees ("قسط القيد", "قيد اشتراك",
"أقساط متبقية من قيد اتحاد الكرة") and kit ("الزي", "شنطة لبس") and reported
it as training money players had paid. SubscriptionLine now reads the text:
the academy's own product names first, then its programme names, then the
words for registration and kit — matched on whole normalised WORDS, never
substrings, because "تجهيزي" contains the letters of "زي" and a substring
match turns a subscription into merchandise. Anything still unrecognised
keeps counting as subscription, so no line disappears unannounced.

WHICH MONTH. The month was the invoice's issue_date, which is only the
covered month when the invoice was raised inside it. August's subscription
typed up in September belonged to no cycle at all, and one invoice covering
July and August counted twice over. Months now come from the invoice's own
metadata, then any month named in the line or the notes ("اشتراك يوليو 2026",
"اشتراك شهر 8", ranges), then the issue date — and a line covering several
months is split evenly across them, remainder on the last, so the parts can
never exceed what was billed. A line that names no month is still judged by
its date, which is the only evidence there is.

A year is only read as a date when it sits near the invoice, so the age group
in "فريق 2011/2012" cannot date a 2026 subscription to 2011.

Verified against a restored copy of the live tenant: subscription billed for
July 123,173 -> 101,073 EGP and August 200,538 -> 187,038 EGP, and every
excluded line was checked one by one — all 14 are registration or kit, none
is a programme. No line currently moves month; that half is future-proofing
plus the combined-invoice case the old data is full of.

Column renamed الدفع -> الاشتراك and the header now carries the year as well
as the month, because "سبتمبر" alone does not say which September.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 36e7bbf3
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
namespace App\Domain\Financial\Services; namespace App\Domain\Financial\Services;
use App\Domain\Financial\Support\SubscriptionLine;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** /**
* How much a participant has actually paid toward one part of their bill. * How much a participant has actually paid toward one part of their bill.
...@@ -69,24 +71,35 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart ...@@ -69,24 +71,35 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart
return []; return [];
} }
// An invoice can name a month it was not issued in — a July catch-up
// typed up in September, or next term paid in advance. The scan is
// widened a year either side so those are visible at all; which of them
// actually count is decided per line, below.
$scanFrom = date('Y-m-d', strtotime($periodStart . ' -12 months'));
$scanTo = date('Y-m-d', strtotime($periodEnd . ' +12 months'));
$invoices = DB::table('invoices') $invoices = DB::table('invoices')
->where('billable_type', Participant::class) ->where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds) ->whereIn('billable_id', $participantIds)
->whereNull('deleted_at') ->whereNull('deleted_at')
->where('status', '!=', 'cancelled') ->where('status', '!=', 'cancelled')
->where('subtotal_amount', '>', 0) ->where('subtotal_amount', '>', 0)
->where('issue_date', '>=', $periodStart) ->where('issue_date', '>=', $scanFrom)
->where('issue_date', '<', $periodEnd) ->where('issue_date', '<', $scanTo)
->get(['id', 'number', 'billable_id', 'subtotal_amount', 'total_amount', 'metadata']); ->get(['id', 'number', 'billable_id', 'academy_id', 'subtotal_amount', 'total_amount', 'issue_date', 'notes', 'metadata']);
if ($invoices->isEmpty()) { if ($invoices->isEmpty()) {
return []; return [];
} }
$invoiceIds = $invoices->pluck('id')->all(); $invoiceIds = $invoices->pluck('id')->all();
$windowMonths = $this->monthsIn($periodStart, $periodEnd);
$vocabulary = $this->academyVocabulary($invoices->pluck('academy_id')->filter()->unique()->all());
// Subscription lines only — anything carrying a morph is a product or a // Hand-typed lines only — anything carrying a morph is a product or a
// kit and belongs in its own column, not in the subscription figure. // kit and belongs in its own column. What is left still has to be read:
// a registration fee or a shirt typed into the same box is not
// subscription money either.
$items = DB::table('invoice_items') $items = DB::table('invoice_items')
->whereIn('invoice_id', $invoiceIds) ->whereIn('invoice_id', $invoiceIds)
->whereNull('itemable_type') ->whereNull('itemable_type')
...@@ -110,10 +123,37 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart ...@@ -110,10 +123,37 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart
$out = []; $out = [];
$issuedInPeriod = fn (string $issueDate) => $issueDate >= $periodStart && $issueDate < $periodEnd;
foreach ($invoices as $invoice) { foreach ($invoices as $invoice) {
$lines = $items[$invoice->id] ?? collect(); $lines = $items[$invoice->id] ?? collect();
$issueDate = substr((string) $invoice->issue_date, 0, 10);
$invoiceMeta = $this->decodeMetadata($invoice->metadata);
$names = $vocabulary[(int) $invoice->academy_id] ?? ['programs' => [], 'products' => []];
// Which of this invoice's money belongs to the cycle being asked
// about, line by line.
$billed = 0;
$contributing = [];
foreach ($lines as $line) {
if (! SubscriptionLine::isSubscription($line->description, $names['programs'], $names['products'])) {
continue;
}
$share = $this->shareForWindow(
(int) $line->total_amount,
SubscriptionLine::explicitMonths($line->description, $invoice->notes, $invoiceMeta, $issueDate),
$windowMonths,
$issuedInPeriod($issueDate),
);
if ($share > 0) {
$billed += $share;
$contributing[] = $line;
}
}
$billed = (int) $lines->sum('total_amount');
if ($billed <= 0) { if ($billed <= 0) {
continue; continue;
} }
...@@ -146,7 +186,7 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart ...@@ -146,7 +186,7 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart
$row['paid'] += $paid; $row['paid'] += $paid;
$row['invoice_numbers'][] = $invoice->number; $row['invoice_numbers'][] = $invoice->number;
foreach ($lines as $line) { foreach ($contributing as $line) {
// Proration is recorded only in the line text the pricing engine // Proration is recorded only in the line text the pricing engine
// wrote ("متناسب: 13 من 30 يوم"), so that is where it is read from. // wrote ("متناسب: 13 من 30 يوم"), so that is where it is read from.
if ($line->description && str_contains($line->description, 'متناسب')) { if ($line->description && str_contains($line->description, 'متناسب')) {
...@@ -165,7 +205,6 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart ...@@ -165,7 +205,6 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart
} }
} }
$invoiceMeta = $this->decodeMetadata($invoice->metadata);
if (! empty($invoiceMeta['admin_override'])) { if (! empty($invoiceMeta['admin_override'])) {
$o = $invoiceMeta['admin_override']; $o = $invoiceMeta['admin_override'];
$row['admin_override'] ??= [ $row['admin_override'] ??= [
...@@ -191,6 +230,105 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart ...@@ -191,6 +230,105 @@ public function subscriptionForPeriod(array $participantIds, string $periodStart
return $out; return $out;
} }
/**
* The months a cycle covers, as 'YYYY-MM' — one per month of its length,
* counted from the month the cycle opens in.
*
* A cycle that does not start on the 1st still belongs to the month it
* opens in: billing from the 15th to the 15th is "August's subscription"
* to everyone who works here, and calling it half of two months would put
* one payment under two headings.
*
* @return array<int, string>
*/
private function monthsIn(string $periodStart, string $periodEnd): array
{
$start = strtotime($periodStart);
$end = strtotime($periodEnd);
$length = max(1, (int) round(($end - $start) / 2629800)); // avg month, seconds
$months = [];
for ($i = 0; $i < $length; $i++) {
$months[] = date('Y-m', strtotime($periodStart . " +{$i} months"));
}
return $months;
}
/**
* How much of one line's total belongs to the cycle being asked about.
*
* With months named on the invoice the amount is split evenly across them —
* one invoice for July and August pays half of each — and only the months
* inside the cycle are counted. Rounding down leaves the remainder on the
* last month, so the parts can never add up to more than was billed.
*
* With nothing named, the invoice's own date is the only evidence there is,
* and the line counts in full or not at all.
*
* @param array<int, string> $lineMonths
* @param array<int, string> $windowMonths
*/
private function shareForWindow(int $lineTotal, array $lineMonths, array $windowMonths, bool $issuedInPeriod): int
{
if ($lineMonths === []) {
return $issuedInPeriod ? $lineTotal : 0;
}
$count = count($lineMonths);
$per = intdiv($lineTotal, $count);
$remainder = $lineTotal - ($per * $count);
$share = 0;
foreach ($lineMonths as $i => $month) {
if (in_array($month, $windowMonths, true)) {
$share += $per + ($i === $count - 1 ? $remainder : 0);
}
}
return $share;
}
/**
* Every programme and product name in the given academies, so a hand-typed
* line can be checked against what this academy actually sells rather than
* against a guess.
*
* @param array<int, mixed> $academyIds
* @return array<int, array{programs: array<int, string>, products: array<int, string>}>
*/
private function academyVocabulary(array $academyIds): array
{
$out = [];
foreach ($academyIds as $id) {
$out[(int) $id] = ['programs' => [], 'products' => []];
}
if ($out === []) {
return [];
}
foreach ([['training_programs', 'programs'], ['products', 'products']] as [$table, $key]) {
if (! Schema::hasTable($table)) {
continue;
}
$rows = DB::table($table)
->whereIn('academy_id', array_keys($out))
->whereNull('deleted_at')
->get(['academy_id', 'name_ar']);
foreach ($rows as $row) {
if ($row->name_ar) {
$out[(int) $row->academy_id][$key][] = $row->name_ar;
}
}
}
return $out;
}
private function decodeMetadata(mixed $raw): array private function decodeMetadata(mixed $raw): array
{ {
if (is_array($raw)) { if (is_array($raw)) {
......
<?php
namespace App\Domain\Financial\Support;
/**
* Reading a hand-typed invoice line: is it a programme subscription, and which
* month or months does it actually pay for?
*
* Both questions used to be answered by proxies that are wrong on real data:
*
* "is it a subscription" was `itemable_type IS NULL`, which is true of every
* line a receptionist typed by hand — so a kit ("الزي"), a bag ("شنطة لبس")
* and a federation registration fee ("قسط القيد") all counted as subscription
* money and inflated what a player appeared to have paid for training.
*
* "which month" was the invoice's issue_date, which is only the covered month
* when the invoice was raised inside it. A back-dated month typed up in
* September, or one invoice covering July and August together, both land on
* the wrong month — and a roster that says "paid" for a month nobody paid for
* is worse than one that says nothing.
*
* Everything here is a pure function over text: no database, no state. The
* matching rules deliberately work on whole normalised WORDS, never substrings
* — "تجهيزي" (preparatory) contains the letters of "زي" (kit), and a substring
* match would reclassify a real subscription as merchandise.
*/
final class SubscriptionLine
{
/**
* Month names as they survive normalise(): hamza forms folded onto ا,
* ة onto ه, ى onto ي. Egyptian/Gulf calendar names plus the English ones,
* because both get typed into the same box.
*/
private const MONTH_WORDS = [
'يناير' => 1, 'january' => 1, 'jan' => 1,
'فبراير' => 2, 'february' => 2, 'feb' => 2,
'مارس' => 3, 'march' => 3, 'mar' => 3,
'ابريل' => 4, 'april' => 4, 'apr' => 4,
'مايو' => 5, 'may' => 5,
'يونيو' => 6, 'يونيه' => 6, 'june' => 6, 'jun' => 6,
'يوليو' => 7, 'يوليه' => 7, 'july' => 7, 'jul' => 7,
'اغسطس' => 8, 'august' => 8, 'aug' => 8,
'سبتمبر' => 9, 'september' => 9, 'sep' => 9,
'اكتوبر' => 10, 'october' => 10, 'oct' => 10,
'نوفمبر' => 11, 'november' => 11, 'nov' => 11,
'ديسمبر' => 12, 'december' => 12, 'dec' => 12,
];
/**
* Words that mean this line is NOT training subscription, matched as whole
* words after the definite article is stripped.
*
* قيد — federation registration / enrolment fee, always a separate charge
* زي / شنطه / ملابس — kit, bag, clothing: merchandise, not training
*
* A programme whose own name contains one of these still wins (see
* isSubscription), so naming a programme "قيد الأولمبي" cannot erase its
* subscriptions from the roster.
*/
private const NOT_SUBSCRIPTION_WORDS = ['قيد', 'زي', 'شنطه', 'ملابس'];
/** Words that turn two named months into every month between them. */
private const RANGE_WORDS = ['الي', 'حتي', 'لغايه', 'وحتي', '-'];
/**
* Fold the spelling variants Arabic text arrives in, so comparisons can be
* exact rather than fuzzy: strip tashkeel and tatweel, unify hamza/alef,
* ة→ه, ى→ي, Arabic-Indic digits→ASCII, punctuation→space.
*/
public static function normalise(?string $text): string
{
if ($text === null || $text === '') {
return '';
}
// Tashkeel (harakat) and tatweel carry no meaning for matching.
$text = preg_replace('/[\x{064B}-\x{0652}\x{0640}\x{0670}]/u', '', $text) ?? '';
$text = strtr($text, [
'أ' => 'ا', 'إ' => 'ا', 'آ' => 'ا', 'ٱ' => 'ا',
'ى' => 'ي', 'ئ' => 'ي', 'ة' => 'ه', 'ؤ' => 'و',
'٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
'٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
'۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
'۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
]);
// A hyphen survives as its own token: it is one of the range markers.
$text = preg_replace('/[^\p{L}\p{N}\-]+/u', ' ', $text) ?? '';
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
return mb_strtolower(trim($text));
}
/**
* Normalised words with the definite article removed, so "القيد" and "قيد"
* are one word and "تجهيزي" stays one word rather than containing "زي".
*
* @return array<int, string>
*/
private static function words(string $normalised): array
{
$out = [];
foreach (explode(' ', $normalised) as $word) {
if ($word === '') {
continue;
}
if (mb_strlen($word) > 3 && str_starts_with($word, 'ال')) {
$word = mb_substr($word, 2);
}
$out[] = $word;
}
return $out;
}
/**
* The proration suffix the pricing engine appends ("(متناسب: 13 من 30 يوم)")
* describes how much of a month was charged, not what was sold — it must not
* take part in matching the line against a programme name.
*/
public static function withoutProrationSuffix(string $description): string
{
return trim(preg_replace('/\s*\(\s*متناسب.*$/u', '', $description) ?? $description);
}
/**
* Does this hand-typed line pay for programme training?
*
* Ordered so the academy's own vocabulary beats the generic keywords:
* a line naming a product is merchandise, a line naming a programme is
* subscription, and only then do the "registration fee / kit" words apply.
* Anything still unrecognised counts as subscription, which is the
* behaviour this replaced — an unknown line keeps being read the way it
* always was rather than silently vanishing from a player's total.
*
* @param array<int, string> $programNames name_ar of every programme in the academy
* @param array<int, string> $productNames name_ar of every product in the academy
*/
public static function isSubscription(?string $description, array $programNames = [], array $productNames = []): bool
{
$line = self::normalise(self::withoutProrationSuffix((string) $description));
if ($line === '') {
return true;
}
foreach ($productNames as $product) {
if (($p = self::normalise($product)) !== '' && $line === $p) {
return false;
}
}
foreach ($programNames as $program) {
$p = self::normalise($program);
if ($p === '') {
continue;
}
// Containment needs a name long enough to be distinctive; a
// two-letter programme ("GK") would otherwise match half the shop.
if ($line === $p || (mb_strlen($p) >= 3 && str_contains($line, $p))) {
return true;
}
}
foreach (self::words($line) as $word) {
if (in_array($word, self::NOT_SUBSCRIPTION_WORDS, true)) {
return false;
}
}
return true;
}
/**
* The months this line pays for, as 'YYYY-MM', most trustworthy source first:
*
* 1. the invoice's own metadata — the retroactive wizard records the month
* it is catching up on, and nothing beats a value written on purpose;
* 2. months named in the line itself ("اشتراك يوليو 2026", "اشتراك شهر 8");
* 3. months named in the invoice notes;
* 4. the issue date, which is right whenever the invoice was raised in the
* month it covers — the common case, and the only one available before
* any of the above exist.
*
* Always returns at least one month, in chronological order.
*
* @param array<string, mixed> $invoiceMetadata
* @return array<int, string>
*/
public static function monthsCovered(
?string $description,
?string $notes,
array $invoiceMetadata,
string $issueDate
): array {
return self::explicitMonths($description, $notes, $invoiceMetadata, $issueDate)
?: [substr($issueDate, 0, 7)];
}
/**
* The same reading, but empty when the invoice says nothing about which
* month it is for — steps 1 to 3 above, without the issue-date fallback.
*
* Callers need that distinction: with an explicit month a line can be
* placed in a cycle it was not issued in, while a silent line must keep
* being judged by its issue date, which is the only evidence there is.
*
* @param array<string, mixed> $invoiceMetadata
* @return array<int, string>
*/
public static function explicitMonths(
?string $description,
?string $notes,
array $invoiceMetadata,
string $issueDate
): array {
if ($fromMetadata = self::monthsFromMetadata($invoiceMetadata)) {
return $fromMetadata;
}
foreach ([$description, $notes] as $text) {
if ($months = self::monthsFromText((string) $text, $issueDate)) {
return $months;
}
}
return [];
}
/**
* @param array<string, mixed> $metadata
* @return array<int, string>
*/
private static function monthsFromMetadata(array $metadata): array
{
if (is_string($metadata['month'] ?? null) && preg_match('/^(\d{4})-(\d{2})$/', $metadata['month'], $m)) {
return [$m[0]];
}
$start = $metadata['period_start'] ?? null;
$end = $metadata['period_end'] ?? null;
if (is_string($start) && preg_match('/^(\d{4})-(\d{2})/', $start, $s)) {
$from = "{$s[1]}-{$s[2]}";
$to = (is_string($end) && preg_match('/^(\d{4})-(\d{2})/', $end, $e)) ? "{$e[1]}-{$e[2]}" : $from;
return self::expand($from, $to);
}
return [];
}
/**
* @return array<int, string>
*/
private static function monthsFromText(string $text, string $issueDate): array
{
$normalised = self::normalise($text);
if ($normalised === '') {
return [];
}
$words = explode(' ', $normalised);
// An explicit YYYY-MM is unambiguous and outranks everything else.
// Matched per word rather than across the string, so a programme named
// "اكاديمية 2012-2013" cannot be read as a date.
$iso = [];
foreach ($words as $word) {
if (preg_match('/^((?:19|20|21)\d{2})-(0[1-9]|1[0-2])$/', $word, $m)) {
$iso[] = "{$m[1]}-{$m[2]}";
}
}
if ($iso !== []) {
return self::rangeOrList($iso, $normalised);
}
$issueYear = (int) substr($issueDate, 0, 4);
// A year is only believable if it is near the invoice: a programme called
// "فريق 2011/2012" must not date a 2026 subscription to 2011.
$year = null;
foreach ($words as $word) {
if (preg_match('/^(19|20|21)\d{2}$/', $word) && abs((int) $word - $issueYear) <= 1) {
$year = (int) $word;
break;
}
}
$found = [];
foreach ($words as $i => $word) {
$bare = (mb_strlen($word) > 3 && str_starts_with($word, 'ال')) ? mb_substr($word, 2) : $word;
$number = self::MONTH_WORDS[$bare] ?? null;
// "يوليو وأغسطس" — the conjunction is written onto the next word.
// Only ever consulted when the whole remainder is a month name, so
// it cannot turn an ordinary word into a date.
if ($number === null && str_starts_with($bare, 'و')) {
$number = self::MONTH_WORDS[mb_substr($bare, 1)] ?? null;
}
// "شهر 8" — the word "month" followed by its number.
if ($number === null && $bare === 'شهر' && isset($words[$i + 1]) && preg_match('/^(0?[1-9]|1[0-2])$/', $words[$i + 1])) {
$number = (int) $words[$i + 1];
}
if ($number !== null) {
$found[] = $number;
}
}
if ($found === []) {
return [];
}
$months = [];
foreach ($found as $number) {
$months[] = sprintf('%04d-%02d', self::yearFor($number, $year, $issueDate), $number);
}
return self::rangeOrList($months, $normalised);
}
/**
* Which year a bare month name belongs to. An explicit nearby year wins;
* otherwise it is the year that puts the month closest to the issue date,
* so "اشتراك ديسمبر" typed on 2 January means the December just gone.
*/
private static function yearFor(int $month, ?int $explicitYear, string $issueDate): int
{
if ($explicitYear !== null) {
return $explicitYear;
}
$issueYear = (int) substr($issueDate, 0, 4);
$issueMonth = (int) substr($issueDate, 5, 2);
$distance = $month - $issueMonth;
if ($distance > 6) {
return $issueYear - 1;
}
if ($distance < -6) {
return $issueYear + 1;
}
return $issueYear;
}
/**
* Two months with a range word between them mean everything in between —
* "اشتراك يوليو إلى سبتمبر" is three months, not two.
*
* @param array<int, string> $months
* @return array<int, string>
*/
private static function rangeOrList(array $months, string $normalised): array
{
$months = array_values(array_unique($months));
sort($months);
if (count($months) === 2) {
foreach (self::RANGE_WORDS as $word) {
if (in_array($word, explode(' ', $normalised), true)) {
return self::expand($months[0], $months[1]);
}
}
}
return $months;
}
/**
* Every month from $from to $to inclusive. Capped at two years so a typo in
* a year cannot produce an unbounded list.
*
* @return array<int, string>
*/
private static function expand(string $from, string $to): array
{
if ($to < $from) {
[$from, $to] = [$to, $from];
}
$months = [];
$year = (int) substr($from, 0, 4);
$month = (int) substr($from, 5, 2);
for ($i = 0; $i < 24; $i++) {
$current = sprintf('%04d-%02d', $year, $month);
$months[] = $current;
if ($current >= $to) {
break;
}
if (++$month > 12) {
$month = 1;
$year++;
}
}
return $months;
}
}
...@@ -440,12 +440,12 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin ...@@ -440,12 +440,12 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin
</div> </div>
@if($activeEnrollments->isNotEmpty()) @if($activeEnrollments->isNotEmpty())
{{-- The atlas for the الدفع column. Every figure there is money {{-- The atlas for the الاشتراك column. Every figure there is money
collected for THIS billing cycle only, and its colour says collected for THIS billing cycle only, and its colour says
why the figure is what it is. --}} why the figure is what it is. --}}
<div class="px-4 py-3 bg-slate-50 border-b border-gray-200"> <div class="px-4 py-3 bg-slate-50 border-b border-gray-200">
<div class="flex items-baseline gap-2 mb-2"> <div class="flex items-baseline gap-2 mb-2">
<span class="text-xs font-semibold text-gray-700">{{ __('دليل ألوان الدفع') }}</span> <span class="text-xs font-semibold text-gray-700">{{ __('دليل ألوان الاشتراك') }}</span>
<span class="text-[11px] text-gray-600"> <span class="text-[11px] text-gray-600">
{{ __('الدورة الحالية') }}: {{ __('الدورة الحالية') }}:
<span dir="ltr">{{ \Carbon\Carbon::parse($cycleStart)->translatedFormat('j M Y') }} <span dir="ltr">{{ \Carbon\Carbon::parse($cycleStart)->translatedFormat('j M Y') }}
...@@ -478,8 +478,8 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin ...@@ -478,8 +478,8 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin
</th> </th>
<th class="px-4 py-3 text-center font-medium text-gray-600"> <th class="px-4 py-3 text-center font-medium text-gray-600">
<button type="button" wire:click="sortEnrollments('payment_status')" class="inline-flex items-center gap-1.5 cursor-pointer hover:text-blue-600 transition-colors group"> <button type="button" wire:click="sortEnrollments('payment_status')" class="inline-flex items-center gap-1.5 cursor-pointer hover:text-blue-600 transition-colors group">
{{ __('الدفع') }} {{ __('الاشتراك') }}
<span class="font-normal text-gray-500">({{ \Carbon\Carbon::parse($cycleStart)->translatedFormat('F') }})</span> <span class="font-normal text-gray-500">({{ \Carbon\Carbon::parse($cycleStart)->translatedFormat('F Y') }})</span>
@if($enrollSortBy === 'payment_status') @if($enrollSortBy === 'payment_status')
<svg class="w-4 h-4 text-blue-600 {{ $enrollSortDir === 'asc' ? '' : 'rotate-180' }}" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 4.414 6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg> <svg class="w-4 h-4 text-blue-600 {{ $enrollSortDir === 'asc' ? '' : 'rotate-180' }}" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 4.414 6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
@else @else
......
<?php
namespace Tests\Feature;
use App\Domain\Training\Models\TrainingGroup;
use App\Models\User;
use Tests\TestCase;
/**
* The subscription column and the POS grid are Blade, so a wrong variable name
* or a renamed method is a 500 at request time and nothing earlier catches it.
*
* Runs against a restored Postgres tenant; skips on the default SQLite suite,
* where the schema does not exist:
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter GroupRosterAndPosRenderTest
*/
class GroupRosterAndPosRenderTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
}
private function anAdmin(): User
{
$user = User::query()->whereHas('roles', fn ($q) => $q->where('name', 'admin'))->first()
?? User::query()->first();
$this->assertNotNull($user, 'The restored tenant has no users to act as.');
return $user;
}
public function test_the_group_roster_renders_and_names_the_subscription_column(): void
{
$group = TrainingGroup::withoutGlobalScopes()
->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
->first();
$this->assertNotNull($group, 'No group with active enrollments in the restored tenant.');
$response = $this->actingAs($this->anAdmin())->get(route('groups.show', $group));
$response->assertOk();
$response->assertSee('الاشتراك', escape: false);
$response->assertDontSee('دليل ألوان الدفع', escape: false);
}
public function test_the_pos_terminal_renders_with_engine_prices(): void
{
$cashier = User::query()->get()->first(fn (User $u) => $u->can('pos.sell'));
if (! $cashier) {
$this->markTestSkipped('No user in the restored tenant may sell.');
}
$this->actingAs($cashier)->get(route('pos.terminal'))->assertOk();
}
}
...@@ -201,6 +201,115 @@ public function test_an_agreed_instalment_plan_outranks_a_plain_shortfall(): voi ...@@ -201,6 +201,115 @@ public function test_an_agreed_instalment_plan_outranks_a_plain_shortfall(): voi
$this->assertSame(SubscriptionPaymentCase::Installment, SubscriptionPaymentCase::classify($facts, false)); $this->assertSame(SubscriptionPaymentCase::Installment, SubscriptionPaymentCase::classify($facts, false));
} }
// ---- reading a legacy invoice -----------------------------------------
public function test_a_registration_fee_typed_by_hand_is_not_subscription_money(): void
{
// Production: "قسط القيد" — an instalment of the federation registration,
// typed into the same box as a subscription and therefore itemable-less.
// It used to read as 2,500 EGP of training money the player never paid.
$this->program(1, 'اكاديمية 2015');
$this->product(1, 'قيد اشتراك فريق اتحاد الكرة');
$this->invoice(1, 500, '2026-08-03', subtotal: 250000, total: 250000, paid: 250000)
->line(1, 'قسط القيد', 250000);
$this->assertNull($this->facts(500), 'A registration fee is not the training subscription.');
}
public function test_a_kit_typed_by_hand_is_not_subscription_money(): void
{
// Production: "الزي" on its own line, no product link, 2,800 EGP.
$this->product(1, 'شنطة ملابس كرة القدم');
$this->invoice(1, 501, '2026-08-06', subtotal: 130000, total: 130000, paid: 130000)
->line(1, 'الزي', 65000)
->line(2, 'تجديد اشتراك: فريق 2015', 65000);
$facts = $this->facts(501);
$this->assertSame(65000, $facts['billed'], 'Only the subscription line is the subscription.');
$this->assertSame(65000, $facts['paid']);
}
public function test_a_programme_whose_name_reads_like_kit_still_counts(): void
{
// "تجهيزي" (preparatory) contains the letters of "زي" (kit).
$this->program(1, 'اكاديمية تجهيزي 2019');
$this->invoice(1, 502, '2026-08-04', subtotal: 65000, total: 65000, paid: 65000)
->line(1, 'اكاديمية تجهيزي 2019', 65000);
$this->assertSame(65000, $this->facts(502)['paid']);
}
public function test_a_month_named_on_the_invoice_beats_the_day_it_was_typed(): void
{
// August's subscription, caught up on in September. Judged by its issue
// date it belongs to no cycle anyone is looking at; it is August's money.
$this->invoice(1, 503, '2026-09-20', subtotal: 65000, total: 65000, paid: 65000)
->line(1, 'اشتراك أغسطس 2026', 65000);
$facts = $this->facts(503);
$this->assertNotNull($facts, 'A back-dated August invoice is still August.');
$this->assertSame(65000, $facts['paid']);
$this->assertSame(SubscriptionPaymentCase::Full, SubscriptionPaymentCase::classify($facts, false));
}
public function test_a_month_named_on_the_invoice_also_keeps_money_out_of_this_cycle(): void
{
// Paid in August, for September. It is not August's collection.
$this->invoice(1, 504, '2026-08-28', subtotal: 65000, total: 65000, paid: 65000)
->line(1, 'اشتراك سبتمبر 2026', 65000);
$this->assertNull($this->facts(504));
}
public function test_one_invoice_covering_two_months_pays_half_of_each(): void
{
// The bundled invoices the old system is full of: two months, one paper.
$this->invoice(1, 505, '2026-07-05', subtotal: 130000, total: 130000, paid: 130000)
->line(1, 'اشتراك يوليو وأغسطس 2026', 130000);
$facts = $this->facts(505);
$this->assertSame(65000, $facts['billed'], 'August is owed half of a two-month invoice.');
$this->assertSame(65000, $facts['paid']);
}
public function test_an_odd_amount_split_across_months_never_invents_a_piastre(): void
{
// 3 months of 65,001 total: 21,667 each and the odd piastre stays on the
// last month rather than being rounded into existence three times.
$this->invoice(1, 506, '2026-07-01', subtotal: 65001, total: 65001, paid: 65001)
->line(1, 'اشتراك من يوليو إلى سبتمبر', 65001);
$july = $this->service()->subscriptionForPeriod([506], '2026-07-01', '2026-08-01')[506];
$august = $this->facts(506);
$september = $this->service()->subscriptionForPeriod([506], '2026-09-01', '2026-10-01')[506];
$this->assertSame(21667, $july['billed']);
$this->assertSame(21667, $august['billed']);
$this->assertSame(21667, $september['billed']);
$this->assertSame(65001, $july['billed'] + $august['billed'] + $september['billed']);
}
public function test_an_invoice_that_names_no_month_is_still_judged_by_its_date(): void
{
// The common case, and the only evidence there is. This must not change.
$this->invoice(1, 507, '2026-08-02', subtotal: 90000, total: 90000, paid: 0)
->line(1, 'تجديد اشتراك: فريق 2015', 90000);
$this->invoice(2, 507, '2026-07-02', subtotal: 90000, total: 90000, paid: 90000)
->line(2, 'تجديد اشتراك: فريق 2015', 90000);
$facts = $this->facts(507);
$this->assertSame(90000, $facts['billed'], "Only August's renewal is August's.");
$this->assertSame(0, $facts['paid']);
$this->assertSame(SubscriptionPaymentCase::Unpaid, SubscriptionPaymentCase::classify($facts, false));
}
// ---- classification edges --------------------------------------------- // ---- classification edges ---------------------------------------------
public function test_a_free_player_outranks_every_other_case(): void public function test_a_free_player_outranks_every_other_case(): void
...@@ -307,6 +416,16 @@ public function test_the_roster_view_uses_logical_properties_only(): void ...@@ -307,6 +416,16 @@ public function test_the_roster_view_uses_logical_properties_only(): void
private int $currentInvoice = 0; private int $currentInvoice = 0;
private function program(int $id, string $nameAr): void
{
DB::table('training_programs')->insert(['id' => $id, 'academy_id' => 1, 'name_ar' => $nameAr, 'deleted_at' => null]);
}
private function product(int $id, string $nameAr): void
{
DB::table('products')->insert(['id' => $id, 'academy_id' => 1, 'name_ar' => $nameAr, 'deleted_at' => null]);
}
private function invoice( private function invoice(
int $id, int $id,
int $participantId, int $participantId,
...@@ -316,6 +435,7 @@ private function invoice( ...@@ -316,6 +435,7 @@ private function invoice(
int $paid, int $paid,
string $status = 'paid', string $status = 'paid',
?string $metadata = null, ?string $metadata = null,
?string $notes = null,
): self { ): self {
DB::table('invoices')->insert([ DB::table('invoices')->insert([
'id' => $id, 'id' => $id,
...@@ -327,6 +447,7 @@ private function invoice( ...@@ -327,6 +447,7 @@ private function invoice(
'total_amount' => $total, 'total_amount' => $total,
'issue_date' => $issueDate, 'issue_date' => $issueDate,
'status' => $status, 'status' => $status,
'notes' => $notes,
'metadata' => $metadata, 'metadata' => $metadata,
'deleted_at' => null, 'deleted_at' => null,
]); ]);
...@@ -381,10 +502,25 @@ private function createMinimalSchema(): void ...@@ -381,10 +502,25 @@ private function createMinimalSchema(): void
$table->bigInteger('total_amount')->default(0); $table->bigInteger('total_amount')->default(0);
$table->date('issue_date')->nullable(); $table->date('issue_date')->nullable();
$table->string('status')->nullable(); $table->string('status')->nullable();
$table->text('notes')->nullable();
$table->text('metadata')->nullable(); $table->text('metadata')->nullable();
$table->timestamp('deleted_at')->nullable(); $table->timestamp('deleted_at')->nullable();
}); });
Schema::create('training_programs', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('academy_id')->nullable();
$table->string('name_ar')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('products', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('academy_id')->nullable();
$table->string('name_ar')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('invoice_items', function (Blueprint $table) { Schema::create('invoice_items', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary(); $table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('invoice_id'); $table->unsignedBigInteger('invoice_id');
......
<?php
namespace Tests\Unit;
use App\Domain\Financial\Support\SubscriptionLine;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Reading hand-typed invoice text.
*
* Every description below is a real line from a production academy's invoices,
* because the failures this guards against are all failures of reading real
* Arabic that a receptionist typed, not of reading text a developer invented.
*/
class SubscriptionLineTest extends TestCase
{
/** Names as they exist in that academy. */
private const PROGRAMS = [
'فريق 2011/2012',
'اكاديمية 2017 -2018',
'أكاديمية الساعة الأولى',
'اكاديمية اطفال 2021 - 2022',
'GK',
];
private const PRODUCTS = [
'شنطة ملابس كرة القدم',
'قيد اشتراك فريق اتحاد الكرة',
];
private function isSubscription(string $description): bool
{
return SubscriptionLine::isSubscription($description, self::PROGRAMS, self::PRODUCTS);
}
// ---- what counts as subscription --------------------------------------
#[DataProvider('subscriptionLines')]
public function test_training_money_is_recognised_as_subscription(string $description): void
{
$this->assertTrue($this->isSubscription($description), "[{$description}] should count as subscription");
}
public static function subscriptionLines(): array
{
return [
'the monthly run' => ['اشتراك يوليو 2026'],
'a renewal' => ['تجديد اشتراك: فريق 2011/2012'],
'the programme name alone' => ['أكاديمية الساعة الأولى'],
'a prorated join' => ['اكاديمية 2017 -2018 (متناسب: 10 من 30 يوم)'],
'generic wording' => ['الاشتراك الشهري'],
'half a month' => ['اشتراك نص شهر'],
// "تجهيزي" (preparatory) contains the letters of "زي" (kit). A
// substring match would file this player's training fee as merchandise.
'a programme whose name contains the letters of "kit"' => ['اكاديمية تجهيزي 2019'],
];
}
#[DataProvider('nonSubscriptionLines')]
public function test_money_for_something_other_than_training_is_excluded(string $description): void
{
$this->assertFalse($this->isSubscription($description), "[{$description}] must not count as subscription");
}
public static function nonSubscriptionLines(): array
{
return [
'kit' => ['الزي'],
'part of the kit' => ['جزء من الزي'],
'a bag' => ['شنطة لبس'],
'part of the bag' => ['جزء من الشنطة'],
'the product, typed by hand' => ['شنطة ملابس كرة القدم'],
'a registration instalment' => ['قسط القيد'],
'a registration instalment with a name on it' => ['قسط قيد ياسين'],
// Contains the word "اشتراك", and is still a federation fee.
'the federation registration' => ['قيد اشتراك'],
'registration settled in full' => ['قيد خالص (د.يحيى فارس)'],
'the remainder of a registration' => ['أقساط متبقية من قيد اتحاد الكرة'],
];
}
public function test_a_programme_whose_own_name_carries_an_excluded_word_still_counts(): void
{
// The academy's vocabulary outranks the generic keywords, so naming a
// programme after the registration cannot erase its subscriptions.
$this->assertTrue(
SubscriptionLine::isSubscription('قيد الأولمبي', ['قيد الأولمبي'], self::PRODUCTS)
);
}
public function test_an_unrecognised_line_is_still_read_as_subscription(): void
{
// The behaviour this replaced. An unknown line keeps counting the way it
// always did rather than disappearing from a player's total unannounced.
$this->assertTrue($this->isSubscription('مبلغ متأخر'));
}
// ---- which month the money is for -------------------------------------
public function test_a_month_named_in_the_line_beats_the_date_it_was_typed(): void
{
$this->assertSame(
['2026-07'],
SubscriptionLine::monthsCovered('اشتراك يوليو 2026', null, [], '2026-09-14')
);
}
public function test_a_month_written_as_a_number_is_read(): void
{
$this->assertSame(
['2026-08'],
SubscriptionLine::monthsCovered('اشتراك شهر 8', null, [], '2026-08-03')
);
}
public function test_arabic_indic_digits_are_read_as_numbers(): void
{
$this->assertSame(
['2026-09'],
SubscriptionLine::monthsCovered('اشتراك شهر ٩', null, [], '2026-09-03')
);
}
public function test_a_year_in_a_programme_name_never_dates_the_subscription(): void
{
// "فريق 2011/2012" is an age group, not a date. Reading it as one would
// move a 2026 subscription fifteen years into the past.
$this->assertSame(
['2026-07'],
SubscriptionLine::monthsCovered('اشتراك يوليو — فريق 2011/2012', null, [], '2026-07-02')
);
}
public function test_a_bare_month_takes_the_year_that_puts_it_nearest_the_invoice(): void
{
// Typed on 2 January, "اشتراك ديسمبر" is the December just gone.
$this->assertSame(
['2025-12'],
SubscriptionLine::monthsCovered('اشتراك ديسمبر', null, [], '2026-01-02')
);
// Typed on 20 December, "اشتراك يناير" is the January about to start.
$this->assertSame(
['2026-01'],
SubscriptionLine::monthsCovered('اشتراك يناير', null, [], '2025-12-20')
);
}
public function test_an_invoice_covering_two_months_names_both(): void
{
$this->assertSame(
['2026-07', '2026-08'],
SubscriptionLine::monthsCovered('اشتراك يوليو وأغسطس', null, [], '2026-07-01')
);
}
public function test_a_range_covers_every_month_between_its_ends(): void
{
$this->assertSame(
['2026-07', '2026-08', '2026-09'],
SubscriptionLine::monthsCovered('اشتراك من يوليو إلى سبتمبر', null, [], '2026-07-01')
);
}
public function test_the_month_recorded_on_the_invoice_outranks_the_text(): void
{
// The retroactive wizard writes the month it is catching up on. Nothing
// beats a value written on purpose.
$this->assertSame(
['2026-05'],
SubscriptionLine::monthsCovered('اشتراك', null, ['month' => '2026-05'], '2026-09-01')
);
}
public function test_the_notes_are_read_when_the_line_itself_says_nothing(): void
{
$this->assertSame(
['2026-06'],
SubscriptionLine::monthsCovered('اشتراك', 'اشتراك الأكاديمية — يونيو', [], '2026-09-01')
);
}
public function test_a_silent_line_falls_back_to_the_date_it_was_issued(): void
{
$this->assertSame(
['2026-08'],
SubscriptionLine::monthsCovered('تجديد اشتراك: فريق 2015', null, [], '2026-08-02')
);
$this->assertSame(
[],
SubscriptionLine::explicitMonths('تجديد اشتراك: فريق 2015', null, [], '2026-08-02'),
'Nothing on the invoice names a month, and the caller has to be able to tell.'
);
}
public function test_an_iso_month_is_read_but_an_age_group_is_not(): void
{
$this->assertSame(
['2026-07'],
SubscriptionLine::monthsCovered('اشتراك 2026-07', null, [], '2026-09-01')
);
$this->assertSame(
['2026-08'],
SubscriptionLine::monthsCovered('اكاديمية 2012-2013', null, [], '2026-08-02'),
'2012-2013 is an age group; reading it as a date would be nonsense.'
);
}
}
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