Commit fa06830c authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(roster): count the registration money however the receptionist typed it

The قيد column asked the database one question — is there an invoice line
carrying this product's itemable morph? That is how the POS writes a sale
and not how most of this money was collected. A receptionist taking the
first instalment types "القسط الاول من القيد" into a free-text line; the
player has paid, and the roster called him a non-buyer. On OC-Sport that
is fourteen lines across nine players, every one of whom had paid.

BundledProductLine reads those lines the way SubscriptionLine reads
subscription ones: over whole normalised words, matched against the words
that identify the product and nothing else. "قيد اشتراك فريق اتحاد الكرة"
is identified by قيد and اتحاد — اشتراك heads half the subscription lines
in the same ledger and فريق is how the programmes are named, so any word
appearing in a programme name is dropped as unable to tell the two apart.
A line that names nothing at all ("القسط الاول") is attributed only where
it can be: the programme requires exactly one product and the invoice pays
for no training, so there is one thing here paid in instalments and that
is what it is paying off.

The column now shows the money rather than a yes/no: paid so far against
what is owed, "أقساط" while it is being paid off, "مدفوع بالكامل" once it
is settled. What is owed comes from the product line when there is one —
that price was agreed and frozen at the sale. Money typed by hand is only
the instalments taken so far, so 2,500 of 2,500 would call a third of a
card paid in full; there the total is the product's price for this
member's tier, marked ≈ and explained in the cell's title.

Two matching consequences: instalment wording no longer counts as
subscription (training is billed by the month here, so a bare "القسط
الاول" made a player who had paid 2,500 toward his card read as having
paid for July's training), and free players no longer inflate the header's
"بدون" count — they are exempt from the bundle, and the cell already says
so with a dash.

Verified against a restored OC-Sport tenant: all six فريق rosters render,
the reported player (عبدالله أحمد صلاح سيد) now reads 2,500 / 8,000 ≈
(31%) أقساط instead of "لم يشترِ", and every hand-typed payer is counted.
271 tests pass.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent e22cd9e4
......@@ -2,6 +2,7 @@
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Support\BundledProductLine;
use App\Domain\Financial\Support\SubscriptionLine;
use App\Domain\Participant\Models\Participant;
use Illuminate\Support\Facades\DB;
......@@ -412,6 +413,187 @@ public function productBilled(array $participantIds, int $productId, ?int $branc
return $out;
}
/**
* What each participant was billed and has paid toward one product the
* programme requires — the registration card, the kit — counting the money
* however it was typed.
*
* productPaid()/productBilled() see only lines carrying the product's
* itemable morph. That is how the POS writes a sale, and it misses every
* receptionist who typed "القسط الاول من القيد" into a free-text line. Those
* players had paid; the roster called them non-buyers. This method reads the
* lines as text as well (BundledProductLine), and allocates each payment
* across the invoice the same way subscriptions are allocated, so a card
* paid on the same invoice as a month's training is still counted.
*
* `billed` is what the matched lines came to — for an instalment typed by
* hand that is the instalment, not the card's price, so the caller decides
* what to compare it against. `from_product_lines` says whether the match
* came from a real product sale (its price is the frozen, agreed one) or
* from text (it is only whatever has been typed so far).
*
* @param array<int, int> $participantIds
* @return array<int, array{billed:int, paid:int, quantity:int,
* from_product_lines:bool, from_text:bool, inferred:bool,
* has_plan:bool, plan:?array}>
*/
public function bundledProductForParticipants(
array $participantIds,
int $productId,
string $productName,
?int $branchId = null,
bool $inferBareInstalments = false
): array {
if (empty($participantIds)) {
return [];
}
// DB::table() carries no global scope, so the branch is named here or
// not at all — a card bought at the branch this player transferred from
// is not this branch's money. Everything below keys off these invoices.
$invoices = DB::table('invoices')
->where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds)
->when($branchId, fn ($q) => $q->where('invoices.branch_id', $branchId))
->whereNull('deleted_at')
->where('status', '!=', 'cancelled')
->where('subtotal_amount', '>', 0)
->get(['id', 'billable_id', 'academy_id', 'subtotal_amount', 'total_amount']);
if ($invoices->isEmpty()) {
return [];
}
$invoiceIds = $invoices->pluck('id')->all();
$vocabulary = $this->academyVocabulary($invoices->pluck('academy_id')->filter()->unique()->all());
$items = DB::table('invoice_items')
->whereIn('invoice_id', $invoiceIds)
->get(['invoice_id', 'itemable_type', 'itemable_id', 'description', 'quantity', 'total_amount'])
->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', 'completed'])
->get(['invoice_id', 'total_installments', 'paid_installments', 'installment_amount'])
->keyBy('invoice_id');
$productClass = \App\Domain\Inventory\Models\Product::class;
$identifyingCache = [];
$out = [];
foreach ($invoices as $invoice) {
$lines = $items[$invoice->id] ?? collect();
if ($lines->isEmpty()) {
continue;
}
$academyId = (int) $invoice->academy_id;
$programNames = $vocabulary[$academyId]['programs'] ?? [];
$identifyingCache[$academyId] ??= BundledProductLine::identifyingWords($productName, $programNames);
$identifying = $identifyingCache[$academyId];
// A bare "القسط الاول" says nothing about what is being paid off.
// It can only be read as this product's instalment when the invoice
// is not also paying for training — otherwise the instalment could
// just as easily be part of the subscription on the line above it.
$invoiceNamesProgram = $lines->contains(
fn ($line) => $line->itemable_type === null
&& BundledProductLine::namesProgram($line->description, $programNames)
);
$matched = 0;
$quantity = 0;
$fromProductLines = false;
$fromText = false;
$inferred = false;
foreach ($lines as $line) {
if ($line->itemable_type === $productClass && (int) $line->itemable_id === $productId) {
$matched += (int) $line->total_amount;
$quantity += (int) $line->quantity;
$fromProductLines = true;
continue;
}
// A morph pointing anywhere else is another product's money.
if ($line->itemable_type !== null) {
continue;
}
if (BundledProductLine::matches($line->description, $productName, $identifying)) {
$matched += (int) $line->total_amount;
$quantity += 1;
$fromText = true;
continue;
}
if ($inferBareInstalments
&& ! $invoiceNamesProgram
&& BundledProductLine::isBareInstalment($line->description)) {
$matched += (int) $line->total_amount;
$quantity += 1;
$fromText = true;
$inferred = true;
}
}
if ($matched <= 0) {
continue;
}
$subtotal = (int) $invoice->subtotal_amount;
$paidOnInvoice = (int) ($payments[$invoice->id] ?? 0);
// Round down, and never allocate more toward a line than it cost:
// a payment settles total_amount (tax and fees included) while the
// share is of subtotal_amount, so paying in full would otherwise
// read as over 100%.
$paid = min(intdiv($paidOnInvoice * $matched, $subtotal), $matched);
$pid = (int) $invoice->billable_id;
$row = $out[$pid] ?? [
'billed' => 0,
'paid' => 0,
'quantity' => 0,
'from_product_lines' => false,
'from_text' => false,
'inferred' => false,
'has_plan' => false,
'plan' => null,
];
$row['billed'] += $matched;
$row['paid'] += $paid;
$row['quantity'] += $quantity;
$row['from_product_lines'] = $row['from_product_lines'] || $fromProductLines;
$row['from_text'] = $row['from_text'] || $fromText;
$row['inferred'] = $row['inferred'] || $inferred;
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;
}
/**
* Allocate confirmed inbound payments across the lines matched by $filter,
* in proportion to those lines' share of each invoice.
......
<?php
namespace App\Domain\Financial\Support;
/**
* Reading a hand-typed invoice line: does it pay for a product the programme
* requires — the federation registration card, the kit — rather than for
* training?
*
* The roster's product columns used to ask the database a much narrower
* question: is there an invoice line whose itemable morph points at this
* product? On a real academy's books that misses most of the money. A
* receptionist taking the first instalment of the registration fee types
* "القسط الاول من القيد" into a free-text line, or "قسط قيد ياسين" with the
* child's name in it; the money is collected and the card is on its way, but
* the roster shows the player as never having bought it. On OC-Sport that was
* fourteen lines across nine players, every one of whom had paid something.
*
* Matching therefore works on the product's own name, over whole normalised
* WORDS (SubscriptionLine::normalise), never over substrings — the same rule
* and the same reason as SubscriptionLine.
*
* The words that identify a product are the ones the academy does not also use
* for everything else. "قيد اشتراك فريق اتحاد الكرة" identifies itself by قيد
* and اتحاد, because اشتراك and فريق are how half the subscription lines in the
* same ledger begin. Any word appearing in a programme's name is dropped for
* exactly that reason: it cannot tell a product line from a subscription line.
*/
final class BundledProductLine
{
/**
* Words that never identify a product on their own, however they are used
* in its name: the vocabulary of billing itself, plus the generic sport
* words every academy's catalogue shares.
*/
private const GENERIC_WORDS = [
'اشتراك', 'اشتراكات', 'تجديد', 'رسوم', 'رسم', 'مصاريف',
'فريق', 'فرق', 'نادي', 'اكاديميه', 'كره', 'مباراه', 'تدريب',
'شهر', 'شهري', 'سنوي', 'سنه', 'موسم',
'قسط', 'اقساط', 'دفعه', 'دفعات', 'مبلغ', 'باقي', 'متبقي', 'خالص',
'بند', 'لاعب', 'عضو',
];
/** A line paying part of something agreed, without saying part of what. */
private const INSTALMENT_WORDS = ['قسط', 'اقساط', 'قسطين', 'دفعه', 'دفعات', 'مقدم'];
/**
* The words that, on their own, mean a line is about this product.
*
* @param array<int, string> $programNames name_ar of every programme in the academy
* @return array<int, string>
*/
public static function identifyingWords(string $productName, array $programNames = []): array
{
$programWords = [];
foreach ($programNames as $program) {
foreach (SubscriptionLine::words(SubscriptionLine::normalise($program)) as $word) {
$programWords[$word] = true;
}
}
$out = [];
foreach (SubscriptionLine::words(SubscriptionLine::normalise($productName)) as $word) {
// Two letters cannot identify anything: "زي" (kit) lives inside
// "تجهيزي" (preparatory), and numbers are how programmes are named.
if (mb_strlen($word) < 3 || is_numeric($word)) {
continue;
}
if (in_array($word, self::GENERIC_WORDS, true) || isset($programWords[$word])) {
continue;
}
$out[$word] = true;
}
return array_keys($out);
}
/**
* Does this hand-typed line pay for the product?
*
* @param array<int, string> $identifyingWords from identifyingWords()
*/
public static function matches(?string $description, string $productName, array $identifyingWords): bool
{
$line = SubscriptionLine::normalise(SubscriptionLine::withoutProrationSuffix((string) $description));
if ($line === '') {
return false;
}
// Typed out verbatim, it is the product whatever its name is made of.
// Containment is deliberately not a rule: a name containing an
// identifying word is already caught below, and a name made entirely of
// generic words ("فريق 2015", a product named after a programme) would
// otherwise swallow every subscription line that mentions it.
$name = SubscriptionLine::normalise($productName);
if ($name !== '' && $line === $name) {
return true;
}
foreach (SubscriptionLine::words($line) as $word) {
if (in_array($word, $identifyingWords, true)) {
return true;
}
}
return false;
}
/**
* A line that pays an instalment of something it never names — "القسط
* الاول", "الشنطة و القسط الاول".
*
* Alone this says nothing about which bill is being paid; the caller has to
* supply that context (see ParticipantBillingService: the invoice must
* name no programme, and the programme must require exactly one product).
*/
public static function isBareInstalment(?string $description): bool
{
foreach (SubscriptionLine::words(SubscriptionLine::normalise((string) $description)) as $word) {
if (in_array($word, self::INSTALMENT_WORDS, true)) {
return true;
}
}
return false;
}
/**
* Does the line name one of the academy's programmes? Same containment rule
* SubscriptionLine uses, so the two agree on what a subscription line is.
*
* @param array<int, string> $programNames
*/
public static function namesProgram(?string $description, array $programNames): bool
{
$line = SubscriptionLine::normalise(SubscriptionLine::withoutProrationSuffix((string) $description));
if ($line === '') {
return false;
}
foreach ($programNames as $program) {
$name = SubscriptionLine::normalise($program);
if ($name === '') {
continue;
}
if ($line === $name || (mb_strlen($name) >= 3 && str_contains($line, $name))) {
return true;
}
}
return false;
}
}
......@@ -52,12 +52,18 @@
*
* قيد — federation registration / enrolment fee, always a separate charge
* زي / شنطه / ملابس — kit, bag, clothing: merchandise, not training
* قسط / اقساط / دفعه — an instalment of something this line does not name.
* Training is billed by the month here, never in instalments, so a line
* that says only "القسط الاول" is paying off a registration card or a kit.
* Counting it as the month's subscription is what made a player who paid
* 2,500 toward his card read as having paid 2,500 for July's 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.
* subscriptions from the roster, and "قسط اشتراك: فريق 2011" stays
* subscription because it names the programme.
*/
private const NOT_SUBSCRIPTION_WORDS = ['قيد', 'زي', 'شنطه', 'ملابس'];
private const NOT_SUBSCRIPTION_WORDS = ['قيد', 'زي', 'شنطه', 'ملابس', 'قسط', 'اقساط', 'دفعه'];
/** Words that turn two named months into every month between them. */
private const RANGE_WORDS = ['الي', 'حتي', 'لغايه', 'وحتي', '-'];
......@@ -96,9 +102,14 @@ public static function normalise(?string $text): string
* Normalised words with the definite article removed, so "القيد" and "قيد"
* are one word and "تجهيزي" stays one word rather than containing "زي".
*
* Public because BundledProductLine reads the same lines and must split
* them into words identically — two splitters would eventually disagree
* about whether a line is subscription money or product money, and the same
* piastres would show up in both columns of the roster.
*
* @return array<int, string>
*/
private static function words(string $normalised): array
public static function words(string $normalised): array
{
$out = [];
foreach (explode(' ', $normalised) as $word) {
......
......@@ -426,26 +426,70 @@ public function render()
? $this->group->program->bundledProducts()->get()
: collect();
// A programme requiring exactly one product is the only case where an
// instalment line that names nothing ("القسط الاول") can be read: there
// is one thing here paid in instalments, so that is what it is paying
// off. With two required products the same line is a coin flip and is
// left where it was.
$singleRequirement = $bundledProducts
->filter(fn ($p) => (bool) ($p->pivot->is_required ?? true))
->count() === 1;
$participantsById = $activeEnrollments->pluck('participant', 'participant_id');
$bundleColumns = [];
foreach ($bundledProducts as $product) {
$paid = $billing->productPaid($participantIds, $product->id, $billingBranchId);
$billed = $billing->productBilled($participantIds, $product->id, $billingBranchId);
$facts = $billing->bundledProductForParticipants(
$participantIds,
$product->id,
(string) $product->name_ar,
$billingBranchId,
$singleRequirement && (bool) ($product->pivot->is_required ?? true),
);
$rows = [];
foreach ($participantIds as $pid) {
$billedAmount = $billed[$pid]['billed'] ?? 0;
$paidAmount = $paid[$pid] ?? 0;
$owned = $billedAmount > 0;
$row = $facts[$pid] ?? null;
$billedAmount = (int) ($row['billed'] ?? 0);
$paidAmount = (int) ($row['paid'] ?? 0);
// What they owe in total. A real product sale froze the agreed
// price on the invoice, so that is the figure. Money typed by
// hand is only the instalments taken so far — showing 2,500 of
// 2,500 would call a third of a card "paid in full" — so the
// expected total is what this player would be charged for it.
$expected = $billedAmount;
$estimated = false;
if ($billedAmount > 0 && ! ($row['from_product_lines'] ?? false)) {
$tier = $participantsById[$pid]?->membership_type?->value ?? 'non_member';
$price = (int) $product->priceForTier($tier);
if ($price > $billedAmount) {
$expected = $price;
$estimated = true;
}
}
$owned = $billedAmount > 0 || $paidAmount > 0;
$rows[$pid] = [
'owned' => $owned,
'billed' => $billedAmount,
// A free player is never billed for the bundle either, so
// the header count must not read them as missing it — the
// cell already shows a dash rather than a warning.
'exempt' => (bool) $participantsById[$pid]?->is_free,
'billed' => $expected,
'paid' => $paidAmount,
// Percentage of the product's price actually settled.
'percent' => $billedAmount > 0
? min(100, (int) round($paidAmount * 100 / $billedAmount))
'percent' => $expected > 0
? min(100, (int) round($paidAmount * 100 / $expected))
: 0,
'fully_paid' => $owned && $paidAmount >= $billedAmount,
'fully_paid' => $owned && $paidAmount >= $expected,
// The card is being paid off rather than bought outright:
// the roster says so instead of flagging a shortfall.
'installment' => $owned && $paidAmount > 0 && $paidAmount < $expected,
'plan' => $row['plan'] ?? null,
'estimated_total' => $estimated,
'from_text' => (bool) ($row['from_text'] ?? false),
];
}
......@@ -453,7 +497,7 @@ public function render()
'product' => $product,
'required' => (bool) ($product->pivot->is_required ?? true),
'rows' => $rows,
'missing_count' => count(array_filter($rows, fn ($r) => ! $r['owned'])),
'missing_count' => count(array_filter($rows, fn ($r) => ! $r['owned'] && ! $r['exempt'])),
];
}
......
......@@ -597,19 +597,51 @@ class="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline">
{{ __('لم يشترِ') }}
</span>
@else
<div class="min-w-[92px]">
@php
// Paid in full, still paying it off, or bought and not
// yet paid at all — three different things, and the
// roster has to say which rather than only colouring.
$bLabel = $b['fully_paid']
? __('مدفوع بالكامل')
: ($b['installment'] ? __('أقساط') : __('لم يُسدَّد'));
$bTitle = $b['fully_paid']
? __('سدد قيمة :name كاملة', ['name' => $col['product']->name_ar])
: __('سدد :paid من :total من قيمة :name', [
'paid' => number_format($b['paid'] / 100, 0) . ' ' . __('ج.م'),
'total' => number_format($b['billed'] / 100, 0) . ' ' . __('ج.م'),
'name' => $col['product']->name_ar,
]);
if ($b['plan']) {
$bTitle .= ' — ' . __('قسط :n من :total', ['n' => $b['plan']['paid'], 'total' => $b['plan']['total']]);
}
if ($b['estimated_total']) {
// The money was typed into a free-text line, so the
// agreed total is not on any invoice; the product's
// price for this member is the best figure there is.
$bTitle .= ' — ' . __('الإجمالي مقدَّر من سعر المنتج (سُجّل الدفع كبند يدوي)');
}
@endphp
<div class="min-w-[92px]" title="{{ $bTitle }}">
<div class="h-1.5 w-full bg-gray-200 rounded-full overflow-hidden">
<div class="h-full rounded-full {{ $b['fully_paid'] ? 'bg-green-500' : 'bg-amber-500' }}"
style="width: {{ $b['percent'] }}%"></div>
<div class="h-full rounded-full {{ $b['fully_paid'] ? 'bg-green-500' : ($b['paid'] > 0 ? 'bg-amber-500' : 'bg-red-400') }}"
style="width: {{ max($b['percent'], $b['paid'] > 0 ? 4 : 0) }}%"></div>
</div>
<div class="mt-1 text-[11px] flex items-center gap-1" dir="ltr">
<span class="font-bold {{ $b['fully_paid'] ? 'text-green-700' : 'text-amber-700' }}">
<span class="font-bold {{ $b['fully_paid'] ? 'text-green-700' : ($b['paid'] > 0 ? 'text-amber-700' : 'text-red-700') }}">
{{ number_format($b['paid'] / 100, 0) }}
</span>
<span class="text-gray-400">/</span>
<span class="text-gray-500">{{ number_format($b['billed'] / 100, 0) }}</span>
@if($b['estimated_total'])
<span class="text-gray-400" aria-hidden="true"></span>
@endif
<span class="text-gray-400">({{ $b['percent'] }}%)</span>
</div>
{{-- Colour is never the only carrier of meaning (WCAG 1.4.1). --}}
<div class="text-[10px] leading-tight {{ $b['fully_paid'] ? 'text-green-700' : ($b['paid'] > 0 ? 'text-amber-700' : 'text-red-700') }}">
{{ $bLabel }}@if($b['plan']) <span class="text-gray-500" dir="ltr">{{ $b['plan']['paid'] }}/{{ $b['plan']['total'] }}</span>@endif
</div>
<span class="sr-only">{{ $bTitle }}</span>
</div>
@endif
</td>
......
This diff is collapsed.
<?php
namespace Tests\Unit;
use App\Domain\Financial\Support\BundledProductLine;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Recognising the registration card in whatever a receptionist typed.
*
* Every description below is a real line from OC-Sport's invoices. The roster
* counted none of them: it looked only for lines carrying the product's morph,
* so nine players who had each paid an instalment of the federation card were
* shown as never having bought one.
*/
class BundledProductLineTest extends TestCase
{
private const PROGRAMS = [
'فريق 2011/2012',
'فريق 2015',
'اكاديمية 2017 -2018',
'أكاديمية الساعة الأولى',
];
private const PRODUCT = 'قيد اشتراك فريق اتحاد الكرة';
private function paysForCard(string $description): bool
{
return BundledProductLine::matches(
$description,
self::PRODUCT,
BundledProductLine::identifyingWords(self::PRODUCT, self::PROGRAMS)
);
}
public function test_the_words_that_identify_the_product_exclude_the_ones_every_line_uses(): void
{
// اشتراك heads half the subscription lines in the same ledger and فريق
// is how the programmes are named — neither can identify a product.
$this->assertSame(
['قيد', 'اتحاد'],
BundledProductLine::identifyingWords(self::PRODUCT, self::PROGRAMS)
);
}
#[DataProvider('registrationLines')]
public function test_money_typed_for_the_registration_is_recognised(string $description): void
{
$this->assertTrue($this->paysForCard($description), "[{$description}] should count toward the card");
}
public static function registrationLines(): array
{
return [
'the product typed out' => ['قيد اشتراك فريق اتحاد الكرة'],
'the word alone' => ['القيد'],
'an instalment' => ['قسط القيد'],
'an instalment, spelled loosely' => ['قسط قيد'],
'an instalment with the child on it' => ['قسط قيد ياسين'],
'the first instalment' => ['القسط الاول من القيد'],
'the first instalment, with hamza' => ['القسط الأول من القيد'],
'what is left of it' => ['أقساط متبقية من قيد اتحاد الكرة'],
'settled in full' => ['قيد خالص (د.يحيى فارس)'],
'the fee, described as a subscription' => ['قيد اشتراك'],
];
}
#[DataProvider('otherLines')]
public function test_training_and_other_goods_are_not_counted_as_the_registration(string $description): void
{
$this->assertFalse($this->paysForCard($description), "[{$description}] must not count toward the card");
}
public static function otherLines(): array
{
return [
'a month of training' => ['اشتراك سبتمبر 2026: فريق 2011'],
'a renewal' => ['تجديد اشتراك: فريق 2015'],
'the programme alone' => ['أكاديمية الساعة الأولى'],
'the kit' => ['الزي'],
'a bag' => ['شنطة لبس'],
// Says nothing about what is being paid off. Only the invoice it
// sits on can answer that, which is the caller's job.
'a bare instalment' => ['القسط الاول'],
'nothing at all' => [''],
];
}
#[DataProvider('bareInstalments')]
public function test_an_instalment_that_names_nothing_is_flagged_as_such(string $description): void
{
$this->assertTrue(BundledProductLine::isBareInstalment($description));
}
public static function bareInstalments(): array
{
return [
'the first instalment' => ['القسط الاول'],
'a bag and the first instalment' => ['الشنطة و القسط الاول'],
'instalments' => ['أقساط متبقية'],
];
}
public function test_a_month_of_training_is_not_a_bare_instalment(): void
{
$this->assertFalse(BundledProductLine::isBareInstalment('اشتراك سبتمبر 2026: فريق 2011'));
}
public function test_a_line_naming_a_programme_is_recognised_as_training(): void
{
$this->assertTrue(BundledProductLine::namesProgram('اشتراك سبتمبر 2026: فريق 2015', self::PROGRAMS));
$this->assertFalse(BundledProductLine::namesProgram('القسط الاول', self::PROGRAMS));
}
public function test_a_product_named_after_a_programme_cannot_claim_its_subscriptions(): void
{
// Every word of this product's name is also a programme's, so it has no
// identifying word left and only its full name can match it. A month's
// training must never be counted as the product.
$product = 'فريق 2015';
$words = BundledProductLine::identifyingWords($product, self::PROGRAMS);
$this->assertSame([], $words);
$this->assertFalse(BundledProductLine::matches('اشتراك سبتمبر 2026: فريق 2015', $product, $words));
$this->assertTrue(BundledProductLine::matches('فريق 2015', $product, $words));
}
}
......@@ -77,9 +77,22 @@ public static function nonSubscriptionLines(): array
'the federation registration' => ['قيد اشتراك'],
'registration settled in full' => ['قيد خالص (د.يحيى فارس)'],
'the remainder of a registration' => ['أقساط متبقية من قيد اتحاد الكرة'],
// Training is billed by the month, never in instalments, so a line
// that only says "the first instalment" is paying off a card or a
// kit. Counted as subscription it made a player who had paid 2,500
// toward his registration read as having paid for July's training.
'an instalment of something unnamed' => ['القسط الاول'],
'a bag and an instalment on one line' => ['الشنطة و القسط الاول'],
];
}
public function test_an_instalment_that_names_the_programme_is_still_subscription(): void
{
// The academy's own vocabulary outranks the generic keywords: paying a
// month's training in two halves is still training money.
$this->assertTrue($this->isSubscription('قسط اشتراك: فريق 2011/2012'));
}
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
......
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