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
<?php
namespace App\Domain\Financial\Enums;
/**
* Why a participant's subscription figure for the current billing cycle is the
* number it is.
*
* The group roster used to show a bare amount, which answered "how much" but
* never "why" — so a coach could not tell 650 collected at the member rate from
* 650 collected after a half-price admin discount on a 1,300 bill. Each case
* below is the single most important reason the figure differs from the
* programme's standard price, and carries the colour the amount is painted in.
*
* Computed at read time from invoices, payments and their metadata. It is never
* persisted, so unlike every other enum in this codebase it has no CHECK
* constraint to match (see docs/agent-rules/16-enums-and-checks.md).
*/
enum SubscriptionPaymentCase: string
{
/** Participant flagged is_free — never billed at all. */
case Free = 'free';
/** No subscription invoice exists for this cycle yet. */
case NotBilled = 'not_billed';
/** Billed for this cycle, nothing collected. */
case Unpaid = 'unpaid';
/** Settled so far against an agreed instalment plan. */
case Installment = 'installment';
/** Some money in, still short of what is due. */
case Partial = 'partial';
/** Settled, but an admin discounted the invoice total. */
case AdminDiscount = 'admin_discount';
/** Settled, but the line price itself was overridden at sale time. */
case PriceOverride = 'price_override';
/** Settled at a mid-month pro-rated share of the full price. */
case Prorated = 'prorated';
/** Settled in full at the programme's standard price. */
case Full = 'full';
/**
* Highest-priority case first. An unsettled bill outranks the reason the
* bill was unusual: a coach needs to see missing money before they need to
* see why the amount was discounted.
*/
public const PRECEDENCE = [
self::Free,
self::NotBilled,
self::Unpaid,
self::Installment,
self::Partial,
self::AdminDiscount,
self::PriceOverride,
self::Prorated,
self::Full,
];
/**
* Decide the single case that best explains one cycle's figure.
*
* $facts is one row of ParticipantBillingService::subscriptionForPeriod(),
* or null when that cycle produced no subscription invoice at all.
*/
public static function classify(?array $facts, bool $isFree): self
{
if ($isFree) {
return self::Free;
}
if ($facts === null || ($facts['billed'] ?? 0) <= 0) {
return self::NotBilled;
}
$due = (int) $facts['due'];
$paid = (int) $facts['paid'];
if ($paid <= 0) {
return self::Unpaid;
}
// An instalment plan explains a short balance; without this an agreed
// plan reads as a shortfall and the coach chases money nobody owes yet.
if (! empty($facts['has_plan']) && $paid < $due) {
return self::Installment;
}
if ($paid < $due) {
return self::Partial;
}
// Settled. What remains is explaining why the sum is not the standard
// programme price — most specific reason first.
if (! empty($facts['admin_override'])) {
return self::AdminDiscount;
}
if (! empty($facts['price_override'])) {
return self::PriceOverride;
}
if (! empty($facts['prorated'])) {
return self::Prorated;
}
return self::Full;
}
/**
* The sentence shown to the admin explaining this participant's figure.
*/
public function explain(?array $facts): string
{
$money = fn (int $p) => number_format($p / 100, 0) . ' ' . __('ج.م');
return match ($this) {
self::Free => __('لاعب مجاني — لا تُصدر له فاتورة اشتراك'),
self::NotBilled => __('لم تُصدر فاتورة اشتراك لهذه الدورة بعد'),
self::Unpaid => __('مستحق :due ولم يُحصَّل منه شيء', [
'due' => $money((int) ($facts['due'] ?? 0)),
]),
self::Installment => __('تقسيط: سدد :paid من :due (قسط :n من :total)', [
'paid' => $money((int) ($facts['paid'] ?? 0)),
'due' => $money((int) ($facts['due'] ?? 0)),
'n' => $facts['plan']['paid'] ?? 0,
'total' => $facts['plan']['total'] ?? 0,
]),
self::Partial => __('سدد :paid من :due — متبقٍ :rest', [
'paid' => $money((int) ($facts['paid'] ?? 0)),
'due' => $money((int) ($facts['due'] ?? 0)),
'rest' => $money(max(0, (int) ($facts['due'] ?? 0) - (int) ($facts['paid'] ?? 0))),
]),
self::AdminDiscount => __('خصم إداري :amount من أصل :billed — :reason', [
'amount' => $money((int) ($facts['admin_override']['discount'] ?? 0)),
'billed' => $money((int) ($facts['billed'] ?? 0)),
'reason' => $facts['admin_override']['reason'] ?: __('بدون سبب مسجل'),
]) . ($facts['admin_override']['by'] ?? null ? ' (' . $facts['admin_override']['by'] . ')' : ''),
self::PriceOverride => __('عُدِّل السعر من :from إلى :to — :reason', [
'from' => $money((int) ($facts['price_override']['original'] ?? 0)),
'to' => $money((int) ($facts['price_override']['final'] ?? 0)),
'reason' => $facts['price_override']['reason'] ?: __('بدون سبب مسجل'),
]) . ($facts['price_override']['by'] ?? null ? ' (' . $facts['price_override']['by'] . ')' : ''),
self::Prorated => __('اشترك بعد بداية الشهر — :label', [
'label' => $facts['proration_label'] ?? __('حُسب بالأيام المتبقية'),
]),
self::Full => __('دفع سعر البرنامج كاملاً (:due)', [
'due' => $money((int) ($facts['due'] ?? 0)),
]),
};
}
public function label(): string
{
return match ($this) {
self::Free => __('لاعب مجاني'),
self::NotBilled => __('لم تُصدر فاتورة'),
self::Unpaid => __('لم يدفع'),
self::Installment => __('أقساط'),
self::Partial => __('دفع جزئي'),
self::AdminDiscount => __('خصم إداري'),
self::PriceOverride => __('سعر معدّل'),
self::Prorated => __('اشتراك متناسب'),
self::Full => __('السعر القياسي'),
};
}
/** One line telling the admin what the colour means, for the legend. */
public function legend(): string
{
return match ($this) {
self::Free => __('معفى من الاشتراك'),
self::NotBilled => __('لم يُفوتر لهذه الدورة بعد'),
self::Unpaid => __('فاتورة صادرة ولم يُحصَّل منها شيء'),
self::Installment => __('يسدد على أقساط متفق عليها'),
self::Partial => __('دفع جزءاً من المستحق'),
self::AdminDiscount => __('خصم على إجمالي الفاتورة بقرار إداري'),
self::PriceOverride => __('تم تعديل سعر البند وقت البيع'),
self::Prorated => __('اشترك بعد بداية الشهر فحُسب بالأيام المتبقية'),
self::Full => __('دفع سعر البرنامج كاملاً'),
};
}
/**
* Tailwind text colour for the amount itself.
*
* All -700 (gray -600): these sit at 10-14px, where WCAG AA wants 4.5:1
* against white. The -500/-600 shades of amber and sky look right in a mock
* and fail the check — amber-600 lands near 3:1.
*/
public function textClass(): string
{
return match ($this) {
self::Free => 'text-violet-700',
self::NotBilled => 'text-gray-600',
self::Unpaid => 'text-red-700',
self::Installment => 'text-indigo-700',
self::Partial => 'text-amber-700',
self::AdminDiscount => 'text-purple-700',
self::PriceOverride => 'text-pink-700',
self::Prorated => 'text-sky-700',
self::Full => 'text-green-700',
};
}
/**
* Tailwind background for the legend swatch.
*
* Same hue as the amount so the legend and the cell read as one system, one
* step lighter so the swatch reads as a marker rather than a second word.
* Kept at -600 because the swatch carries meaning, and WCAG 1.4.11 asks
* non-text indicators for 3:1.
*/
public function swatchClass(): string
{
return match ($this) {
self::Free => 'bg-violet-600',
self::NotBilled => 'bg-gray-500',
self::Unpaid => 'bg-red-600',
self::Installment => 'bg-indigo-600',
self::Partial => 'bg-amber-600',
self::AdminDiscount => 'bg-purple-600',
self::PriceOverride => 'bg-pink-600',
self::Prorated => 'bg-sky-600',
self::Full => 'bg-green-600',
};
}
/**
* Colour alone cannot carry meaning — a colour-blind admin, or one reading a
* printed roster, still needs the case. Shown beside the amount.
*/
public function glyph(): string
{
return match ($this) {
self::Free => '★',
self::NotBilled => '—',
self::Unpaid => '✕',
self::Installment => '≡',
self::Partial => '◐',
self::AdminDiscount => '%',
self::PriceOverride => '✎',
self::Prorated => '◔',
self::Full => '✓',
};
}
/** True when the row should be flagged as owing money. */
public function isOwing(): bool
{
return in_array($this, [self::Unpaid, self::Partial, self::NotBilled], true);
}
}
......@@ -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.
}
};
......@@ -100,7 +100,7 @@ class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('دفعوا الاشتراك') }}</p>
<p class="text-xs text-gray-500">{{ __('سددوا اشتراك الشهر') }}</p>
<p class="text-lg font-bold text-green-700">{{ $paidCount }}</p>
</div>
</div>
......@@ -118,7 +118,7 @@ class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('إجمالي المحصّل') }}</p>
<p class="text-xs text-gray-500">{{ __('المحصّل') }} <span class="text-gray-400">({{ __('اشتراك الشهر + المنتجات') }})</span></p>
<p class="text-lg font-bold text-emerald-700" dir="ltr">
{{ number_format($groupCollected / 100, 0) }}
<span class="text-xs font-normal text-gray-400">{{ __('ج.م') }}</span>
......@@ -137,7 +137,7 @@ class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('لم يدفعوا') }}</p>
<p class="text-xs text-gray-500">{{ __('عليهم مستحقات') }}</p>
<p class="text-lg font-bold text-red-700">{{ $unpaidCount }}</p>
</div>
</div>
......@@ -440,6 +440,28 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin
</div>
@if($activeEnrollments->isNotEmpty())
{{-- The atlas for the الدفع column. Every figure there is money
collected for THIS billing cycle only, and its colour says
why the figure is what it is. --}}
<div class="px-4 py-3 bg-slate-50 border-b border-gray-200">
<div class="flex items-baseline gap-2 mb-2">
<span class="text-xs font-semibold text-gray-700">{{ __('دليل ألوان الدفع') }}</span>
<span class="text-[11px] text-gray-600">
{{ __('الدورة الحالية') }}:
<span dir="ltr">{{ \Carbon\Carbon::parse($cycleStart)->translatedFormat('j M Y') }}
– {{ \Carbon\Carbon::parse($cycleEnd)->subDay()->translatedFormat('j M Y') }}</span>
</span>
</div>
<ul class="flex flex-wrap gap-x-4 gap-y-1.5">
@foreach($caseLegend as $case)
<li class="flex items-center gap-1.5 text-[11px]">
<span aria-hidden="true" class="w-2.5 h-2.5 rounded-full flex-shrink-0 {{ $case->swatchClass() }}"></span>
<span class="font-medium text-gray-700">{{ $case->glyph() }} {{ $case->label() }}</span>
<span class="text-gray-600">— {{ $case->legend() }}</span>
</li>
@endforeach
</ul>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
......@@ -457,6 +479,7 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin
<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">
{{ __('الدفع') }}
<span class="font-normal text-gray-500">({{ \Carbon\Carbon::parse($cycleStart)->translatedFormat('F') }})</span>
@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>
@else
......@@ -478,7 +501,15 @@ class="w-full ps-9 pe-3 py-2 text-sm border border-gray-200 rounded-lg focus:rin
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($activeEnrollments as $enrollment)
@php $isUnpaid = in_array($enrollment->participant_id, $notPaidParticipantIds); @endphp
@php
$subRow = $subscriptionCases[$enrollment->participant_id] ?? null;
$subCase = $subRow['case'] ?? \App\Domain\Financial\Enums\SubscriptionPaymentCase::NotBilled;
$subFacts = $subRow['facts'] ?? null;
// Red now means exactly one thing: money is missing
// for this cycle. It can no longer contradict the
// figure beside it, because both read the same row.
$isUnpaid = $subCase->isOwing();
@endphp
<tr x-show="!enrollSearch || '{{ str_replace("'", "", $enrollment->participant?->person?->name_ar ?? '') }} {{ $enrollment->participant?->person?->name ?? '' }} {{ $enrollment->participant?->person?->phone ?? '' }}'.toLowerCase().includes(enrollSearch.toLowerCase())"
class="{{ $isUnpaid ? 'bg-red-50 hover:bg-red-100' : 'hover:bg-gray-50' }} transition">
<td class="px-4 py-3">
......@@ -518,23 +549,38 @@ class="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline">
</div>
</td>
<td class="px-4 py-3 text-center">
@php $subPaid = $subscriptionPaid[$enrollment->participant_id] ?? 0; @endphp
@if($enrollment->participant?->is_free)
<span class="px-2 py-0.5 text-xs bg-violet-100 text-violet-700 rounded-full">
{{ __('مجاني') }}
</span>
@elseif($isUnpaid && $subPaid <= 0)
<span class="px-2 py-0.5 text-xs bg-red-100 text-red-700 font-bold rounded-full">
{{ __('لم يدفع') }}
</span>
@else
{{-- The amount is what was actually collected for the
subscription, split out of any bundled invoice. --}}
<span class="text-sm font-bold text-green-700" dir="ltr">
{{ number_format($subPaid / 100, 0) }}
@php
$subPaid = (int) ($subFacts['paid'] ?? 0);
// Owing money, the useful comparison is what is still
// due; settled below the standard price, it is what
// they were billed before the discount came off.
$subDue = $subCase->isOwing()
? (int) ($subFacts['due'] ?? 0)
: (int) ($subFacts['billed'] ?? 0);
$cases = \App\Domain\Financial\Enums\SubscriptionPaymentCase::class;
@endphp
{{-- The figure is this cycle's programme subscription only,
split out of any bundled invoice; its colour is why. --}}
<div class="inline-flex flex-col items-center gap-0.5" title="{{ $subCase->explain($subFacts) }}">
<span class="inline-flex items-baseline gap-1 text-sm font-bold tabular-nums {{ $subCase->textClass() }}" dir="ltr">
<span aria-hidden="true" class="text-[11px] leading-none">{{ $subCase->glyph() }}</span>
@if($subCase === $cases::Free || $subCase === $cases::NotBilled)
<span class="text-xs">{{ $subCase->label() }}</span>
@else
{{ number_format($subPaid / 100, 0) }}
@if($subDue !== $subPaid)
<span class="font-normal text-gray-600">/ {{ number_format($subDue / 100, 0) }}</span>
@endif
<span class="text-[10px] font-normal text-gray-600">{{ __('ج.م') }}</span>
@endif
</span>
<span class="text-[10px] text-gray-400">{{ __('ج.م') }}</span>
@endif
@if($subCase !== $cases::Free && $subCase !== $cases::NotBilled)
{{-- Colour alone must never be the only carrier of
meaning (WCAG 1.4.1); the case is named here too. --}}
<span class="text-[10px] leading-tight {{ $subCase->textClass() }}">{{ $subCase->label() }}</span>
@endif
<span class="sr-only">{{ $subCase->explain($subFacts) }}</span>
</div>
</td>
@foreach($bundleColumns as $col)
@php $b = $col['rows'][$enrollment->participant_id] ?? null; @endphp
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Enums\SubscriptionPaymentCase;
use App\Domain\Financial\Services\ParticipantBillingService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* The group roster's الدفع column used to answer a different question from the
* one it appeared to answer.
*
* It showed a LIFETIME subscription total next to a monthly bill, so a player
* who had paid 650 in July, 1,200 for a kit bag and 650 in August read as
* "2,500" for the current month. Worse, the red "unpaid" flag beside it came
* from an unrelated calculation — matching invoice text with ilike '%اشتراك%'
* plus the programme name — so a row could be flagged unpaid and show a green
* figure at the same time.
*
* Every scenario below is taken from real production rows that rendered wrongly.
*/
class SubscriptionCycleFigureTest extends TestCase
{
private const CYCLE_START = '2026-08-01';
private const CYCLE_END = '2026-09-01';
protected function setUp(): void
{
parent::setUp();
$this->createMinimalSchema();
}
private function service(): ParticipantBillingService
{
return new ParticipantBillingService();
}
private function facts(int $participantId): ?array
{
return $this->service()
->subscriptionForPeriod([$participantId], self::CYCLE_START, self::CYCLE_END)[$participantId] ?? null;
}
// ---- the reported bug -------------------------------------------------
public function test_a_lifetime_total_no_longer_leaks_into_the_current_cycle(): void
{
// Production participant 145: July subscription, a kit bag typed as a
// free-text line, and August's subscription. The roster showed 2,500.
$this->invoice(1, 145, '2026-07-01', subtotal: 65000, total: 65000, paid: 65000)
->line(1, 'اشتراك يوليو 2026', 65000);
$this->invoice(2, 145, '2026-07-28', subtotal: 120000, total: 120000, paid: 120000)
->line(2, 'الشنطة', 120000, itemableType: 'App\\Domain\\Inventory\\Models\\Product', itemableId: 1);
$this->invoice(3, 145, '2026-08-02', subtotal: 65000, total: 65000, paid: 65000)
->line(3, 'تجديد اشتراك: اكاديمية اطفال 2020', 65000);
$facts = $this->facts(145);
$this->assertSame(65000, $facts['paid'], 'Only August subscription money belongs to the August cycle.');
$this->assertSame(65000, $facts['billed']);
$this->assertSame(
SubscriptionPaymentCase::Full,
SubscriptionPaymentCase::classify($facts, false)
);
}
public function test_a_row_flagged_as_owing_can_never_also_show_settled_money(): void
{
// Production participant 133: paid 650 in July, August renewal unpaid.
// Old view: red row, green "650". Both now read the same calculation.
$this->invoice(1, 133, '2026-07-01', subtotal: 65000, total: 65000, paid: 65000)
->line(1, 'اشتراك يوليو 2026', 65000);
$this->invoice(2, 133, '2026-08-02', subtotal: 90000, total: 90000, paid: 0)
->line(2, 'تجديد اشتراك: اكاديمية 2011', 90000);
$facts = $this->facts(133);
$case = SubscriptionPaymentCase::classify($facts, false);
$this->assertSame(0, $facts['paid']);
$this->assertSame(SubscriptionPaymentCase::Unpaid, $case);
$this->assertTrue($case->isOwing());
}
public function test_two_months_of_subscriptions_are_not_added_together(): void
{
// Production participant 199 read as 1,350 — July's 450 plus August's 900.
$this->invoice(1, 199, '2026-07-21', subtotal: 45000, total: 45000, paid: 45000)
->line(1, 'اكاديمية 2008-20011 (متناسب: 10 من 30 يوم)', 45000);
$this->invoice(2, 199, '2026-08-05', subtotal: 90000, total: 90000, paid: 90000)
->line(2, 'تجديد اشتراك: اكاديمية 2008-2011', 90000);
$this->assertSame(90000, $this->facts(199)['paid']);
}
// ---- bundled invoices -------------------------------------------------
public function test_a_product_on_the_same_invoice_is_kept_out_of_the_subscription_figure(): void
{
// Production participant 316: 390 subscription + 1,200 kit, paid together.
$this->invoice(1, 316, '2026-08-18', subtotal: 159000, total: 159000, paid: 159000)
->line(1, 'اكاديمية 2008-2011 (متناسب: 13 من 30 يوم)', 39000)
->line(2, 'شنطة ملابس كرة القدم', 120000, itemableType: 'App\\Domain\\Inventory\\Models\\Product', itemableId: 1);
$facts = $this->facts(316);
$this->assertSame(39000, $facts['paid']);
$this->assertSame(39000, $facts['billed']);
$this->assertSame(SubscriptionPaymentCase::Prorated, SubscriptionPaymentCase::classify($facts, false));
}
public function test_a_partly_paid_bundle_allocates_only_the_subscription_share(): void
{
// Production participant 271: 900 subscription + 1,200 kit, 1,400 paid.
$this->invoice(1, 271, '2026-08-03', subtotal: 210000, total: 210000, paid: 140000)
->line(1, 'اكاديمية اطفال 2020', 90000)
->line(2, 'شنطة ملابس كرة القدم', 120000, itemableType: 'App\\Domain\\Inventory\\Models\\Product', itemableId: 1);
$facts = $this->facts(271);
$this->assertSame(60000, $facts['paid'], '1,400 of a 2,100 bill is 600 of the 900 subscription.');
$this->assertSame(90000, $facts['due']);
$this->assertSame(SubscriptionPaymentCase::Partial, SubscriptionPaymentCase::classify($facts, false));
}
public function test_allocation_never_credits_more_than_the_line_cost(): void
{
// Service fees and tax settle on the total, so a full payment allocates
// more than the line was billed unless it is capped.
$this->invoice(1, 500, '2026-08-10', subtotal: 90000, total: 99000, paid: 99000)
->line(1, 'اشتراك', 90000);
$facts = $this->facts(500);
$this->assertSame(90000, $facts['paid']);
$this->assertLessThanOrEqual($facts['billed'], $facts['paid']);
}
// ---- why the figure is what it is -------------------------------------
public function test_an_invoice_level_admin_discount_is_reported_as_the_reason(): void
{
// Production participant 57: 650 billed, 325 discounted away, 325 paid.
$this->invoice(1, 57, '2026-08-02', subtotal: 65000, total: 32500, paid: 32500, metadata: json_encode([
'admin_override' => [
'reason' => 'دفع المتبقى من الشهر',
'applied_by_name' => 'OCZ',
'discount_piasters' => 32500,
],
]))->line(1, 'تجديد اشتراك: فريق 2011/2012', 65000);
$facts = $this->facts(57);
$case = SubscriptionPaymentCase::classify($facts, false);
$this->assertSame(32500, $facts['due'], 'The discount lowers what is owed, not what was billed.');
$this->assertSame(65000, $facts['billed']);
$this->assertSame(SubscriptionPaymentCase::AdminDiscount, $case);
$this->assertFalse($case->isOwing(), 'A discounted bill paid in full is settled, not outstanding.');
$this->assertStringContainsString('دفع المتبقى من الشهر', $case->explain($facts));
$this->assertStringContainsString('OCZ', $case->explain($facts));
}
public function test_a_line_level_price_override_is_reported_with_its_original_price(): void
{
// Production participant 317: pro-rated to 690, overridden down to 450.
$this->invoice(1, 317, '2026-08-18', subtotal: 45000, total: 45000, paid: 45000)
->line(1, 'اكاديمية 2008-2011 (متناسب: 23 من 30 يوم)', 45000, metadata: json_encode([
'overridden_by' => 'Muhammad Aladdin Hammouda',
'original_price' => 69000,
'override_reason' => 'نصف شهر',
'overridden_price' => 45000,
]));
$facts = $this->facts(317);
$case = SubscriptionPaymentCase::classify($facts, false);
$this->assertSame(SubscriptionPaymentCase::PriceOverride, $case);
$this->assertStringContainsString('نصف شهر', $case->explain($facts));
$this->assertStringContainsString('690', $case->explain($facts));
}
public function test_an_agreed_instalment_plan_outranks_a_plain_shortfall(): void
{
$this->invoice(1, 900, '2026-08-01', subtotal: 65000, total: 65000, paid: 25900)
->line(1, 'اشتراك', 65000);
DB::table('payment_plans')->insert([
'id' => 1, 'invoice_id' => 1, 'status' => 'active',
'total_installments' => 3, 'paid_installments' => 1, 'installment_amount' => 25900,
]);
$facts = $this->facts(900);
$this->assertTrue($facts['has_plan']);
$this->assertSame(SubscriptionPaymentCase::Installment, SubscriptionPaymentCase::classify($facts, false));
}
// ---- classification edges ---------------------------------------------
public function test_a_free_player_outranks_every_other_case(): void
{
$this->invoice(1, 7, '2026-08-01', subtotal: 65000, total: 65000, paid: 0)->line(1, 'اشتراك', 65000);
$this->assertSame(SubscriptionPaymentCase::Free, SubscriptionPaymentCase::classify($this->facts(7), true));
}
public function test_no_invoice_in_the_cycle_reads_as_not_billed_rather_than_unpaid(): void
{
$case = SubscriptionPaymentCase::classify(null, false);
$this->assertSame(SubscriptionPaymentCase::NotBilled, $case);
$this->assertTrue($case->isOwing(), 'A missing renewal is still money the group has not collected.');
}
public function test_a_cancelled_invoice_contributes_nothing(): void
{
$this->invoice(1, 800, '2026-08-04', subtotal: 90000, total: 90000, paid: 90000, status: 'cancelled')
->line(1, 'اشتراك', 90000);
$this->assertNull($this->facts(800));
}
public function test_an_unconfirmed_or_outbound_payment_is_not_counted_as_collected(): void
{
$this->invoice(1, 801, '2026-08-04', subtotal: 90000, total: 90000, paid: 0)->line(1, 'اشتراك', 90000);
DB::table('payments')->insert([
['id' => 90, 'invoice_id' => 1, 'amount' => 90000, 'status' => 'pending', 'direction' => 'inbound', 'deleted_at' => null],
['id' => 91, 'invoice_id' => 1, 'amount' => 90000, 'status' => 'confirmed', 'direction' => 'outbound', 'deleted_at' => null],
['id' => 92, 'invoice_id' => 1, 'amount' => 90000, 'status' => 'confirmed', 'direction' => 'inbound', 'deleted_at' => '2026-08-05 00:00:00'],
]);
$this->assertSame(SubscriptionPaymentCase::Unpaid, SubscriptionPaymentCase::classify($this->facts(801), false));
}
public function test_every_case_has_a_label_a_legend_a_colour_and_a_glyph(): void
{
foreach (SubscriptionPaymentCase::cases() as $case) {
$this->assertNotSame('', $case->label(), "{$case->value} has no label");
$this->assertNotSame('', $case->legend(), "{$case->value} has no legend");
$this->assertStringStartsWith('text-', $case->textClass());
$this->assertStringStartsWith('bg-', $case->swatchClass());
$this->assertNotSame('', $case->glyph());
}
$this->assertSame(
count(SubscriptionPaymentCase::cases()),
count(SubscriptionPaymentCase::PRECEDENCE),
'A case missing from PRECEDENCE would silently vanish from the legend.'
);
}
public function test_the_roster_view_only_calls_methods_the_enum_actually_has(): void
{
// The view is Blade, so a renamed enum method is a 500 at request time
// rather than a failure here. This pins the contract between the two.
$view = file_get_contents(resource_path('views/livewire/groups/group-show.blade.php'));
preg_match_all('/\$(?:sub)?[cC]ase->([a-zA-Z]+)\(/', $view, $matches);
$called = array_unique($matches[1]);
$this->assertNotEmpty($called, 'Expected the roster view to call enum methods.');
foreach ($called as $method) {
$this->assertTrue(
method_exists(SubscriptionPaymentCase::class, $method),
"The roster view calls {$method}() which SubscriptionPaymentCase does not define."
);
}
}
public function test_the_roster_view_no_longer_reads_the_removed_lifetime_variables(): void
{
$view = file_get_contents(resource_path('views/livewire/groups/group-show.blade.php'));
foreach (['$subscriptionPaid', '$notPaidParticipantIds'] as $gone) {
$this->assertStringNotContainsString(
$gone,
$view,
"{$gone} is no longer passed to the view; reading it would render an empty figure."
);
}
}
public function test_the_roster_view_uses_logical_properties_only(): void
{
// Arabic is the default locale; a physical margin flips the layout.
$view = file_get_contents(resource_path('views/livewire/groups/group-show.blade.php'));
$offenders = [];
foreach (explode("\n", $view) as $i => $line) {
if (preg_match('/class="[^"]*\b(m[lr]|p[lr])-[0-9]/', $line)) {
$offenders[] = ($i + 1) . ': ' . trim($line);
}
}
$this->assertSame([], $offenders, "Physical margins/paddings found:\n" . implode("\n", $offenders));
}
// ---- fixtures ---------------------------------------------------------
private int $currentInvoice = 0;
private function invoice(
int $id,
int $participantId,
string $issueDate,
int $subtotal,
int $total,
int $paid,
string $status = 'paid',
?string $metadata = null,
): self {
DB::table('invoices')->insert([
'id' => $id,
'number' => 'INV-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT),
'academy_id' => 1,
'billable_type' => 'App\\Domain\\Participant\\Models\\Participant',
'billable_id' => $participantId,
'subtotal_amount' => $subtotal,
'total_amount' => $total,
'issue_date' => $issueDate,
'status' => $status,
'metadata' => $metadata,
'deleted_at' => null,
]);
if ($paid > 0) {
DB::table('payments')->insert([
'id' => $id,
'invoice_id' => $id,
'amount' => $paid,
'status' => 'confirmed',
'direction' => 'inbound',
'deleted_at' => null,
]);
}
$this->currentInvoice = $id;
return $this;
}
private function line(
int $id,
string $description,
int $total,
?string $itemableType = null,
?int $itemableId = null,
?string $metadata = null,
): self {
DB::table('invoice_items')->insert([
'id' => $id,
'invoice_id' => $this->currentInvoice,
'itemable_type' => $itemableType,
'itemable_id' => $itemableId,
'description' => $description,
'quantity' => 1,
'total_amount' => $total,
'metadata' => $metadata,
]);
return $this;
}
private function createMinimalSchema(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->string('number')->nullable();
$table->unsignedBigInteger('academy_id')->nullable();
$table->string('billable_type')->nullable();
$table->unsignedBigInteger('billable_id')->nullable();
$table->bigInteger('subtotal_amount')->default(0);
$table->bigInteger('total_amount')->default(0);
$table->date('issue_date')->nullable();
$table->string('status')->nullable();
$table->text('metadata')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('invoice_items', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('invoice_id');
$table->string('itemable_type')->nullable();
$table->unsignedBigInteger('itemable_id')->nullable();
$table->text('description')->nullable();
$table->integer('quantity')->default(1);
$table->bigInteger('total_amount')->default(0);
$table->text('metadata')->nullable();
});
Schema::create('payments', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('invoice_id');
$table->bigInteger('amount')->default(0);
$table->string('status')->nullable();
$table->string('direction')->nullable();
$table->timestamp('deleted_at')->nullable();
});
Schema::create('payment_plans', function (Blueprint $table) {
$table->unsignedBigInteger('id')->primary();
$table->unsignedBigInteger('invoice_id');
$table->string('status')->nullable();
$table->integer('total_installments')->default(0);
$table->integer('paid_installments')->default(0);
$table->bigInteger('installment_amount')->default(0);
});
}
}
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