Commit 6b1c1297 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(uploads): one 150 MB ceiling, agreed on by every layer that can refuse

A file crossing this system passes four size checks and they disagreed:
the component's rules said 5 MB, Livewire's undeclared default said 12,
PHP said 20, nginx said 25. Whichever was smallest won, with a message
written by whoever owned that layer — and nginx's refusal is a 413 error
page, not something a receptionist can act on.

config/uploads.php holds the number now, and the four layers are set from
it in the right order: nginx (160M) is the most generous so it never
refuses first, then post_max_size (160M) above upload_max_filesize (150M)
so a file at the limit is rejected as a file rather than as a malformed
request, then Livewire's temporary-upload rule, then the component. The
size named in each Arabic error message is interpolated from the same
config instead of retyped, because the old messages said "5 ميجابايت"
while the rule said something else.

Time limits went with it: max_input_time is what cuts off a body still
arriving, and 150 MB over Egyptian mobile data is minutes, so it and
nginx's client_body_timeout go to 300s and Livewire's max_upload_time to
30 minutes. Pictures keep their own small ceilings — a logo is carried on
every page load.

And the settlement worklist stops treating the running month as a problem.
Late now means a month that has ENDED and was not collected — read from
the month the invoice names, not from its due date — because a club
collects all month and does not consider a player a problem on the 9th.
A card being paid on an agreed plan whose next instalment has not come due
is not an anomaly either. Together: a player who owes only this month and
whose bundled product is bought or paid up to date does not appear at all,
which is the whole point of the screen. An unpaid bundled product goes
back to standing on its own, since that is exactly what it exists to find.

Verified on the restored tenant: 24 settlement cases including the new
month rule (last month unpaid flags, this month never does, whatever the
due date), full suite 334 tests on both connections.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent d396f784
...@@ -33,7 +33,7 @@ class AccountAnomalyScanner ...@@ -33,7 +33,7 @@ class AccountAnomalyScanner
public const CASES = [ public const CASES = [
'never_paid' => [ 'never_paid' => [
'label' => 'لم يُسجَّل له أي دفع', 'label' => 'لم يُسجَّل له أي دفع',
'hint' => 'صدرت له فواتير تجاوزت تاريخ استحقاقها ولم يُسجَّل عليها أي تحصيل — يُراجَع مع الاستقبال: هل حُصِّل نقداً؟', 'hint' => 'شهور انتهت ولم يُسجَّل عليها أي تحصيل — يُراجَع مع الاستقبال: هل حُصِّل نقداً؟ (الشهر الجاري لا يُحتسب)',
'severity' => 1, 'severity' => 1,
], ],
'handtyped_product' => [ 'handtyped_product' => [
...@@ -48,7 +48,7 @@ class AccountAnomalyScanner ...@@ -48,7 +48,7 @@ class AccountAnomalyScanner
], ],
'stacked_unpaid' => [ 'stacked_unpaid' => [
'label' => 'متأخرات متراكمة', 'label' => 'متأخرات متراكمة',
'hint' => 'فاتورتان متأخرتان فأكثر (بعد تاريخ الاستحقاق) — تُراجَع شهراً شهراً. تجديد الشهر الحالي قبل استحقاقه لا يُحتسب.', 'hint' => 'شهران منتهيان فأكثر بلا تحصيل — يُراجَعان شهراً شهراً. اشتراك الشهر الجاري لا يُحتسب هنا.',
'severity' => 4, 'severity' => 4,
], ],
'unbilled_month' => [ 'unbilled_month' => [
...@@ -63,7 +63,7 @@ class AccountAnomalyScanner ...@@ -63,7 +63,7 @@ class AccountAnomalyScanner
], ],
'partial_bundle' => [ 'partial_bundle' => [
'label' => 'مستلزم مدفوع جزئياً', 'label' => 'مستلزم مدفوع جزئياً',
'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج (قيد اتحاد الكرة مثلاً) — الباقي لم يُحصَّل بعد.', 'hint' => 'سدد جزءاً من قيمة مستلزم البرنامج وتأخر عن قسط شهر منتهٍ، أو سُجِّل دفعه خارج المنتج. الأقساط المنتظمة لا تظهر هنا.',
'severity' => 6, 'severity' => 6,
], ],
'duplicate_person' => [ 'duplicate_person' => [
...@@ -77,7 +77,7 @@ class AccountAnomalyScanner ...@@ -77,7 +77,7 @@ class AccountAnomalyScanner
* Cases that annotate an account but do not, on their own, mean somebody * Cases that annotate an account but do not, on their own, mean somebody
* has to look at it. * has to look at it.
*/ */
private const INFORMATIONAL = ['missing_bundle']; private const INFORMATIONAL = [];
/** How far back a missing month is worth flagging. */ /** How far back a missing month is worth flagging. */
private const MONTHS_BACK = 6; private const MONTHS_BACK = 6;
...@@ -114,8 +114,9 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3 ...@@ -114,8 +114,9 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3
$detail = []; $detail = [];
if ($m) { if ($m) {
// Never a single piastre, and something is already late: that is // Never a single piastre, and a month that has already ended
// a file to ask the front desk about, not a debtor to chase. // went uncollected: that is a file to ask the front desk about,
// not a debtor to chase.
if ($m['paid'] === 0 && $m['billed'] > 0 && $m['overdue'] > 0) { if ($m['paid'] === 0 && $m['billed'] > 0 && $m['overdue'] > 0) {
$cases[] = 'never_paid'; $cases[] = 'never_paid';
} }
...@@ -150,11 +151,15 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3 ...@@ -150,11 +151,15 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3
// and calling it missing is what made a boy who had handed over // and calling it missing is what made a boy who had handed over
// 2,500 look like he had bought nothing. // 2,500 look like he had bought nothing.
// //
// But a card on an agreed plan, sold properly through the till, // But a card on an agreed plan, sold properly through the till
// is ordinary business and does not belong on this list either. // and paid up to this month, is ordinary business and does not
// Only money recorded outside the product — typed into a // belong on this list either. What is left is money recorded
// free-text line — is an anomaly a person has to fix. // outside the product, or a plan that has missed an instalment
$partialAnomalies = array_values(array_filter($partial, fn ($b) => $b['from_text'])); // from a month that has already ended.
$partialAnomalies = array_values(array_filter(
$partial,
fn ($b) => ! $b['on_schedule'] && ($b['from_text'] || $b['paid'] === 0)
));
if ($partial !== []) { if ($partial !== []) {
$detail['partial_bundle'] = $partial; $detail['partial_bundle'] = $partial;
...@@ -179,13 +184,11 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3 ...@@ -179,13 +184,11 @@ public function scan(?int $branchId = null, ?string $only = null, int $limit = 3
continue; continue;
} }
// "The programme requires a card and he has not bought one" is a // Cases that only annotate an account never summon it here on
// sales fact, not a payment anomaly: on a club of 250 it would put // their own. Nothing is in that list today: a card the programme
// half the academy on this screen and bury the files that need a // requires and nobody was charged for is exactly what this screen
// decision. It still shows inside the wizard, and it can still be // is meant to surface.
// filtered for deliberately — it just does not summon an account if (! $only && self::INFORMATIONAL !== [] && array_diff($cases, self::INFORMATIONAL) === []) {
// here on its own.
if (! $only && array_diff($cases, self::INFORMATIONAL) === []) {
continue; continue;
} }
...@@ -257,12 +260,19 @@ private function moneyByParticipant(array $ids): array ...@@ -257,12 +260,19 @@ private function moneyByParticipant(array $ids): array
DB::raw('SUM(paid_amount) as paid'), DB::raw('SUM(paid_amount) as paid'),
DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN total_amount - paid_amount ELSE 0 END) as owed'), DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN total_amount - paid_amount ELSE 0 END) as owed'),
DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN 1 ELSE 0 END) as unpaid'), DB::raw('SUM(CASE WHEN total_amount > paid_amount THEN 1 ELSE 0 END) as unpaid'),
// Late is not the same as unpaid. This month's renewal, issued // Late means a month that has already ENDED and was not paid.
// on the 1st and due on the 8th, is ordinary business that the //
// collect-payment screen handles — flagging it here buries the // Not "past its due date": this month's subscription is due on
// files that actually need a decision. // the 8th and collected all month, and a club does not consider
DB::raw('SUM(CASE WHEN total_amount > paid_amount AND due_date < CURRENT_DATE THEN 1 ELSE 0 END) as overdue'), // a player a problem on the 9th. Anything still open from the
DB::raw('SUM(CASE WHEN total_amount > paid_amount AND due_date < CURRENT_DATE THEN total_amount - paid_amount ELSE 0 END) as overdue_amount'), // month we are in is the collect-payment screen's business, and
// putting it here buries the files that need a decision.
//
// The month an invoice pays for is the one it names in metadata
// (the renewal job and the settlement wizard both write it),
// falling back to the month it was issued in.
DB::raw("SUM(CASE WHEN total_amount > paid_amount AND COALESCE(metadata->>'month', to_char(issue_date, 'YYYY-MM')) < to_char(CURRENT_DATE, 'YYYY-MM') THEN 1 ELSE 0 END) as overdue"),
DB::raw("SUM(CASE WHEN total_amount > paid_amount AND COALESCE(metadata->>'month', to_char(issue_date, 'YYYY-MM')) < to_char(CURRENT_DATE, 'YYYY-MM') THEN total_amount - paid_amount ELSE 0 END) as overdue_amount"),
DB::raw('SUM(CASE WHEN total_amount = 0 THEN 1 ELSE 0 END) as zero_invoices'), DB::raw('SUM(CASE WHEN total_amount = 0 THEN 1 ELSE 0 END) as zero_invoices'),
) )
->get(); ->get();
...@@ -428,6 +438,26 @@ private function bundleStatus($participants): array ...@@ -428,6 +438,26 @@ private function bundleStatus($participants): array
// could be paying off — the same rule the roster applies. // could be paying off — the same rule the roster applies.
$singleRequirementPrograms = array_keys(array_filter($byProgram, fn ($ps) => count($ps) === 1)); $singleRequirementPrograms = array_keys(array_filter($byProgram, fn ($ps) => count($ps) === 1));
// A card being paid on an agreed plan is only a problem when an
// instalment from a month that has ended was never collected. The plan
// rows live on the invoices that carry the product.
$plans = DB::table('payment_plans')
->join('invoices', 'invoices.id', '=', 'payment_plans.invoice_id')
->where('invoices.billable_type', Participant::class)
->whereIn('invoices.billable_id', $participants->pluck('id')->all())
->whereNull('invoices.deleted_at')
->whereIn('payment_plans.status', ['active', 'completed'])
->get([
'invoices.billable_id',
'payment_plans.status',
'payment_plans.next_due_date',
'payment_plans.paid_installments',
'payment_plans.total_installments',
])
->groupBy('billable_id');
$currentMonthStart = now()->startOfMonth()->toDateString();
$facts = []; $facts = [];
foreach ($productIds as $productId) { foreach ($productIds as $productId) {
$product = $products[$productId] ?? null; $product = $products[$productId] ?? null;
...@@ -488,6 +518,20 @@ private function bundleStatus($participants): array ...@@ -488,6 +518,20 @@ private function bundleStatus($participants): array
default => 'partial', default => 'partial',
}; };
// On schedule: there is a plan, and its next instalment is
// not yet a month that has ended. A player paying his card
// in three goes, on time, is not an anomaly — and the
// operator does not want to see him at all.
$onSchedule = false;
foreach ($plans[$participant->id] ?? [] as $plan) {
if ($plan->status === 'completed'
|| ! $plan->next_due_date
|| substr((string) $plan->next_due_date, 0, 10) >= $currentMonthStart) {
$onSchedule = true;
break;
}
}
$out[$participant->id][$productId] = [ $out[$participant->id][$productId] = [
'product_id' => (int) $productId, 'product_id' => (int) $productId,
'product_name' => (string) $product->name_ar, 'product_name' => (string) $product->name_ar,
...@@ -497,6 +541,7 @@ private function bundleStatus($participants): array ...@@ -497,6 +541,7 @@ private function bundleStatus($participants): array
'price' => (int) $price, 'price' => (int) $price,
'from_text' => (bool) ($row['from_text'] ?? false), 'from_text' => (bool) ($row['from_text'] ?? false),
'inferred' => (bool) ($row['inferred'] ?? false), 'inferred' => (bool) ($row['inferred'] ?? false),
'on_schedule' => $onSchedule,
'status' => $status, 'status' => $status,
]; ];
} }
......
...@@ -99,7 +99,9 @@ class SystemSettings extends Component ...@@ -99,7 +99,9 @@ class SystemSettings extends Component
'medical_certificate_expiry_required' => ['type' => 'boolean', 'label' => 'إلزام تاريخ انتهاء الشهادة عند الموافقة'], 'medical_certificate_expiry_required' => ['type' => 'boolean', 'label' => 'إلزام تاريخ انتهاء الشهادة عند الموافقة'],
'medical_certificate_max_age_months' => ['type' => 'number', 'label' => 'أقصى مدة صلاحية الشهادة الطبية (شهور)', 'step' => '1', 'min' => 1, 'max' => 60], 'medical_certificate_max_age_months' => ['type' => 'number', 'label' => 'أقصى مدة صلاحية الشهادة الطبية (شهور)', 'step' => '1', 'min' => 1, 'max' => 60],
'block_attendance_without_medical' => ['type' => 'boolean', 'label' => 'منع الحضور بدون شهادة طبية سارية'], 'block_attendance_without_medical' => ['type' => 'boolean', 'label' => 'منع الحضور بدون شهادة طبية سارية'],
'max_document_size_mb' => ['type' => 'number', 'label' => 'الحد الأقصى لحجم الملف (ميجابايت)', 'step' => '1', 'min' => 1, 'max' => 50], // 150 = the platform ceiling in config/uploads.php (max_kb / 1024).
// A literal because a property initialiser cannot call config().
'max_document_size_mb' => ['type' => 'number', 'label' => 'الحد الأقصى لحجم الملف (ميجابايت)', 'step' => '1', 'min' => 1, 'max' => 150],
], ],
]; ];
......
...@@ -45,7 +45,7 @@ public function rules(): array ...@@ -45,7 +45,7 @@ public function rules(): array
'receipt_reference' => 'nullable|string|max:100', 'receipt_reference' => 'nullable|string|max:100',
'expense_date' => 'required|date', 'expense_date' => 'required|date',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
'attachment' => 'nullable|file|max:5120|mimes:jpg,jpeg,png,pdf,webp', 'attachment' => 'nullable|file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp',
]; ];
} }
...@@ -59,7 +59,7 @@ public function messages(): array ...@@ -59,7 +59,7 @@ public function messages(): array
'description.max' => 'الوصف يجب ألا يتجاوز 500 حرف', 'description.max' => 'الوصف يجب ألا يتجاوز 500 حرف',
'payment_method.required' => 'اختر طريقة الدفع', 'payment_method.required' => 'اختر طريقة الدفع',
'expense_date.required' => 'تاريخ المصروف مطلوب', 'expense_date.required' => 'تاريخ المصروف مطلوب',
'attachment.max' => 'حجم الملف لا يتجاوز 5 ميجابايت', 'attachment.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'attachment.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp) أو PDF', 'attachment.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp) أو PDF',
]; ];
} }
......
...@@ -59,7 +59,7 @@ private function expense(): Expense ...@@ -59,7 +59,7 @@ private function expense(): Expense
public function rules(): array public function rules(): array
{ {
return [ return [
'receipt' => 'required|file|max:5120|mimes:jpg,jpeg,png,pdf,webp', 'receipt' => 'required|file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp',
]; ];
} }
...@@ -67,7 +67,7 @@ public function messages(): array ...@@ -67,7 +67,7 @@ public function messages(): array
{ {
return [ return [
'receipt.required' => 'اختر ملف الإيصال أولاً', 'receipt.required' => 'اختر ملف الإيصال أولاً',
'receipt.max' => 'حجم الملف لا يتجاوز 5 ميجابايت', 'receipt.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'receipt.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp) أو PDF', 'receipt.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp) أو PDF',
]; ];
} }
......
...@@ -47,7 +47,7 @@ protected function rules(): array ...@@ -47,7 +47,7 @@ protected function rules(): array
// A medical certificate without an expiry is a certificate nobody // A medical certificate without an expiry is a certificate nobody
// can act on: the nightly job has nothing to compare against. // can act on: the nightly job has nothing to compare against.
'expiresAt' => ['nullable', 'date', 'after:today'], 'expiresAt' => ['nullable', 'date', 'after:today'],
'file' => ['required', 'file', 'max:6144', 'mimes:png,jpg,jpeg,webp,pdf'], 'file' => ['required', 'file', 'max:' . config('uploads.max_kb'), 'mimes:png,jpg,jpeg,webp,pdf'],
]; ];
} }
...@@ -56,7 +56,7 @@ protected function messages(): array ...@@ -56,7 +56,7 @@ protected function messages(): array
return [ return [
'file.required' => __('اختر الملف أولاً'), 'file.required' => __('اختر الملف أولاً'),
'file.mimes' => __('الملف يجب أن يكون صورة أو PDF'), 'file.mimes' => __('الملف يجب أن يكون صورة أو PDF'),
'file.max' => __('حجم الملف يتجاوز ٦ ميجابايت'), 'file.max' => __('حجم الملف يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'expiresAt.after' => __('تاريخ الانتهاء يجب أن يكون في المستقبل'), 'expiresAt.after' => __('تاريخ الانتهاء يجب أن يكون في المستقبل'),
]; ];
} }
......
...@@ -63,7 +63,7 @@ protected function rules(): array ...@@ -63,7 +63,7 @@ protected function rules(): array
'transferredAt' => ['required', 'date', 'before_or_equal:today'], 'transferredAt' => ['required', 'date', 'before_or_equal:today'],
// Images and PDFs only, and small enough that a phone can send it // Images and PDFs only, and small enough that a phone can send it
// on a bad connection. // on a bad connection.
'proof' => ['required', 'file', 'max:4096', 'mimes:png,jpg,jpeg,webp,pdf'], 'proof' => ['required', 'file', 'max:' . config('uploads.max_kb'), 'mimes:png,jpg,jpeg,webp,pdf'],
]; ];
} }
...@@ -79,7 +79,7 @@ protected function messages(): array ...@@ -79,7 +79,7 @@ protected function messages(): array
'transferredAt.before_or_equal' => __('لا يمكن أن يكون تاريخ التحويل في المستقبل'), 'transferredAt.before_or_equal' => __('لا يمكن أن يكون تاريخ التحويل في المستقبل'),
'proof.required' => __('صورة إثبات التحويل مطلوبة'), 'proof.required' => __('صورة إثبات التحويل مطلوبة'),
'proof.mimes' => __('الملف يجب أن يكون صورة أو PDF'), 'proof.mimes' => __('الملف يجب أن يكون صورة أو PDF'),
'proof.max' => __('حجم الملف يتجاوز ٤ ميجابايت'), 'proof.max' => __('حجم الملف يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
]; ];
} }
......
...@@ -74,7 +74,7 @@ protected function rules(): array ...@@ -74,7 +74,7 @@ protected function rules(): array
->where('is_active', true) ->where('is_active', true)
->whereNull('deleted_at'), ->whereNull('deleted_at'),
], ],
'attachment' => ['nullable', 'file', 'max:4096', 'mimes:png,jpg,jpeg,webp,pdf'], 'attachment' => ['nullable', 'file', 'max:' . config('uploads.max_kb'), 'mimes:png,jpg,jpeg,webp,pdf'],
]; ];
} }
...@@ -85,7 +85,7 @@ protected function messages(): array ...@@ -85,7 +85,7 @@ protected function messages(): array
'reason.required' => __('اكتب سبب الطلب'), 'reason.required' => __('اكتب سبب الطلب'),
'reason.min' => __('السبب قصير جداً'), 'reason.min' => __('السبب قصير جداً'),
'attachment.mimes' => __('المرفق يجب أن يكون صورة أو PDF'), 'attachment.mimes' => __('المرفق يجب أن يكون صورة أو PDF'),
'attachment.max' => __('حجم المرفق يتجاوز ٤ ميجابايت'), 'attachment.max' => __('حجم المرفق يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'branchId.exists' => __('الفرع المختار غير متاح'), 'branchId.exists' => __('الفرع المختار غير متاح'),
]; ];
} }
......
<?php
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => true, // Options: true, false
'with' => [
'js' => false,
'css' => false,
'test' => false,
],
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
// Livewire validates the temporary upload before the component ever
// sees it, and its undeclared default is 12 MB — so a 20 MB receipt was
// refused here with a message nobody wrote, whatever the component's
// own rules said. One ceiling, config/uploads.php, for all of it.
'rules' => ['required', 'file', 'max:' . (int) env('UPLOAD_MAX_KB', 153600)],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
// A 150 MB file over mobile data does not arrive in five minutes.
'max_upload_time' => 30, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 200, // Maximum components per batch request
],
];
<?php
return [
/*
|--------------------------------------------------------------------------
| The ceiling on any single uploaded file
|--------------------------------------------------------------------------
|
| One number, in kilobytes, that the validation rules, Livewire's temporary
| upload check and the container's own limits all agree on. Before this the
| three disagreed — a receipt could pass `max:5120` in the rules, then be
| refused by Livewire's undeclared 12 MB default, or accepted by both and
| cut off by nginx at 25 MB with a 413 the user could not read.
|
| Raising it means raising all three: docker/php/php.ini
| (upload_max_filesize, post_max_size, max_input_time),
| docker/nginx/default.conf (client_max_body_size, client_body_timeout),
| and this file. The value here must stay BELOW post_max_size, so the file
| is rejected as a file with a message on the field rather than as an
| oversized request with an error page.
|
*/
'max_kb' => (int) env('UPLOAD_MAX_KB', 153600), // 150 MB
/*
|--------------------------------------------------------------------------
| Ceilings for things that have no business being large
|--------------------------------------------------------------------------
|
| A logo is not a video. Letting a 150 MB PNG through would be technically
| fine and practically a bug — every page load carries it.
|
*/
'image_max_kb' => (int) env('UPLOAD_IMAGE_MAX_KB', 5120), // 5 MB
'icon_max_kb' => (int) env('UPLOAD_ICON_MAX_KB', 1024), // 1 MB
];
...@@ -20,7 +20,14 @@ server { ...@@ -20,7 +20,14 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
# Max upload size # Max upload size
client_max_body_size 25M; # Matches post_max_size in php.ini: nginx refuses first, and a 413 from
# nginx is an error page rather than a validation message, so it must be the
# looser of the two.
client_max_body_size 160M;
# A large upload over mobile data is slow, not stalled.
client_body_timeout 300s;
send_timeout 300s;
# Application-served files that LOOK static but are generated by PHP. # Application-served files that LOOK static but are generated by PHP.
# #
...@@ -89,7 +96,7 @@ server { ...@@ -89,7 +96,7 @@ server {
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params; include fastcgi_params;
fastcgi_hide_header X-Powered-By; fastcgi_hide_header X-Powered-By;
fastcgi_read_timeout 60s; fastcgi_read_timeout 300s;
fastcgi_buffer_size 16k; fastcgi_buffer_size 16k;
fastcgi_buffers 4 16k; fastcgi_buffers 4 16k;
} }
......
[PHP] [PHP]
; Memory & execution ; Memory & execution
;
; A 150 MB upload from a phone on Egyptian mobile data takes minutes, and the
; clock that matters is max_input_time — the time PHP allows for receiving the
; request body, not for running the script. Sixty seconds cut those uploads off
; mid-transfer with nothing in the log to explain it.
memory_limit = 256M memory_limit = 256M
max_execution_time = 60 max_execution_time = 300
max_input_time = 60 max_input_time = 300
max_input_vars = 5000 max_input_vars = 5000
; Upload ; Upload
upload_max_filesize = 20M ;
post_max_size = 25M ; 150 MB is the platform ceiling. post_max_size sits above it because the POST
; carries the file plus Livewire's snapshot and the form's own fields; if the
; two were equal, a file at exactly the limit would be rejected as an oversized
; request instead of an oversized file, which is a far more confusing error.
upload_max_filesize = 150M
post_max_size = 160M
; Error handling (production) ; Error handling (production)
display_errors = Off display_errors = Off
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
<h1 class="text-xl sm:text-2xl font-bold text-gray-900">{{ __('حالات تحتاج تسوية') }}</h1> <h1 class="text-xl sm:text-2xl font-bold text-gray-900">{{ __('حالات تحتاج تسوية') }}</h1>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{{ __('حسابات لا تتفق أرقامها مع الواقع: مال حُصِّل ولم يُسجَّل، شهور بلا فواتير، منتجات بيعت خارج السيستم.') }} {{ __('حسابات لا تتفق أرقامها مع الواقع: مال حُصِّل ولم يُسجَّل، شهور بلا فواتير، منتجات بيعت خارج السيستم.') }}
<span class="block mt-0.5">{{ __('الشهر الجاري لا يُحتسب هنا — تحصيله من شاشة «تحصيل دفعة». وكذلك الأقساط المنتظمة حتى الشهر الجاري.') }}</span>
</p> </p>
</div> </div>
<button type="button" wire:click="export" <button type="button" wire:click="export"
......
...@@ -596,11 +596,67 @@ public function test_this_months_renewal_is_not_an_anomaly(): void ...@@ -596,11 +596,67 @@ public function test_this_months_renewal_is_not_an_anomaly(): void
$after = app(AccountAnomalyScanner::class)->forParticipant($participant)['money']; $after = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];
$this->assertSame($before['overdue'], $after['overdue'], 'A renewal that is not due yet is not late.'); $this->assertSame($before['overdue'], $after['overdue'], 'A month that is still running is never late.');
$this->assertSame($before['unpaid'] + 1, $after['unpaid'], 'It is still shown as outstanding.'); $this->assertSame($before['unpaid'] + 1, $after['unpaid'], 'It is still shown as outstanding.');
$this->assertGreaterThan(0, $invoice->id); $this->assertGreaterThan(0, $invoice->id);
} }
public function test_the_month_that_has_ended_is_what_makes_an_account_late(): void
{
// Same invoice, last month instead of this one. Due date is deliberately
// in the future to prove the rule is the MONTH, not the due date: a club
// collects all month, so nothing inside the current month counts, and
// everything from a month that has closed does.
$participant = Participant::withoutGlobalScopes()
->whereHas('enrollments', fn ($q) => $q->where('status', 'active'))
->firstOrFail();
$before = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];
Invoice::withoutGlobalScopes()->create([
'academy_id' => $participant->academy_id,
'branch_id' => $participant->branch_id,
'number' => 'INV-TEST-' . uniqid(),
'billable_type' => $participant->getMorphClass(),
'billable_id' => $participant->id,
'subtotal_amount' => 90000,
'total_amount' => 90000,
'paid_amount' => 0,
'due_amount' => 90000,
'issue_date' => now()->subMonth()->startOfMonth()->toDateString(),
'due_date' => now()->addWeeks(2)->toDateString(),
'status' => InvoiceStatus::Sent,
'currency' => 'EGP',
'metadata' => ['month' => now()->subMonth()->format('Y-m')],
]);
$after = app(AccountAnomalyScanner::class)->forParticipant($participant)['money'];
$this->assertSame($before['overdue'] + 1, $after['overdue'], 'Last month going uncollected is the anomaly.');
}
public function test_a_card_being_paid_on_schedule_keeps_a_player_off_the_list(): void
{
// The rule the operator asked for: someone who owes only the month we
// are in, and whose bundled product is bought or paid up to date, must
// not appear at all.
$rows = app(AccountAnomalyScanner::class)->scan(limit: 500);
foreach ($rows as $row) {
foreach ($row['detail']['bundle_status'] ?? [] as $bundle) {
if ($bundle['status'] === 'paid' || $bundle['on_schedule']) {
$this->assertNotContains(
'partial_bundle',
$row['cases'],
'A card paid, or being paid on an agreed plan, is not an anomaly.'
);
}
}
}
$this->addToAssertionCount(1);
}
public function test_a_card_paid_in_a_combined_invoice_reads_as_part_paid_not_missing(): void public function test_a_card_paid_in_a_combined_invoice_reads_as_part_paid_not_missing(): void
{ {
// Production participant 219: 2,500 toward the federation card, typed // Production participant 219: 2,500 toward the federation card, typed
......
<?php
namespace Tests\Feature;
use Tests\TestCase;
/**
* One upload ceiling, agreed on by everything in the path.
*
* A file crossing this system passes four separate size checks — nginx,
* PHP, Livewire's own temporary-upload rule, and the component's validation —
* and before this they disagreed: the rules said 5 MB, Livewire's undeclared
* default said 12 MB, nginx said 25 MB. Whichever was smallest won, with a
* message written by whoever happened to own that layer, and a 413 from nginx
* is an error page rather than something a receptionist can act on.
*
* These assertions are about the ordering that keeps the error readable:
* nginx must be the most generous, then PHP, then the application. Get it
* backwards and a file at the limit is refused as a malformed request instead
* of an oversized file.
*/
class UploadLimitsTest extends TestCase
{
private function iniBytes(string $value): int
{
$value = trim($value);
$unit = strtolower(substr($value, -1));
$number = (int) $value;
return match ($unit) {
'g' => $number * 1024 * 1024 * 1024,
'm' => $number * 1024 * 1024,
'k' => $number * 1024,
default => $number,
};
}
private function phpIni(): string
{
return file_get_contents(base_path('docker/php/php.ini'));
}
private function iniValue(string $key): string
{
preg_match('/^' . preg_quote($key, '/') . '\s*=\s*(\S+)/m', $this->phpIni(), $m);
$this->assertNotEmpty($m, "{$key} is not set in docker/php/php.ini");
return $m[1];
}
public function test_the_application_ceiling_is_the_one_the_client_asked_for(): void
{
$this->assertSame(153600, (int) config('uploads.max_kb'), '150 MB, in kilobytes.');
}
public function test_php_accepts_a_file_at_the_ceiling(): void
{
$ceiling = ((int) config('uploads.max_kb')) * 1024;
$this->assertGreaterThanOrEqual(
$ceiling,
$this->iniBytes($this->iniValue('upload_max_filesize')),
'upload_max_filesize must not be the thing that refuses a file the app allows.'
);
}
public function test_the_whole_request_is_allowed_to_be_bigger_than_the_file(): void
{
// The POST carries the file plus Livewire's snapshot and the form
// fields. Equal limits mean a file at exactly the ceiling is rejected
// as an oversized request — a different, and far less useful, error.
$this->assertGreaterThan(
$this->iniBytes($this->iniValue('upload_max_filesize')),
$this->iniBytes($this->iniValue('post_max_size')),
'post_max_size must exceed upload_max_filesize.'
);
}
public function test_nginx_is_the_most_generous_of_the_three(): void
{
$conf = file_get_contents(base_path('docker/nginx/default.conf'));
preg_match('/client_max_body_size\s+(\S+);/', $conf, $m);
$this->assertNotEmpty($m, 'client_max_body_size is not set.');
$this->assertGreaterThanOrEqual(
$this->iniBytes($this->iniValue('post_max_size')),
$this->iniBytes(rtrim($m[1], ';')),
'nginx refuses before PHP does, and its refusal is an error page — so it must be the looser limit.'
);
}
public function test_livewire_does_not_refuse_before_the_application_does(): void
{
// Livewire validates the temporary upload before any component sees
// it. Its undeclared default is 12 MB, which silently capped every
// upload in the system regardless of the rules the component declared.
$rules = config('livewire.temporary_file_upload.rules');
$this->assertIsArray($rules, 'The temporary upload rules must be declared, not left to the package default.');
$this->assertContains(
'max:' . config('uploads.max_kb'),
$rules,
'Livewire must allow what the application allows.'
);
}
public function test_a_slow_upload_is_given_time_to_arrive(): void
{
// 150 MB over Egyptian mobile data is minutes, not seconds. The clock
// that cuts it off is max_input_time — the body being received — not
// max_execution_time.
$this->assertGreaterThanOrEqual(300, (int) $this->iniValue('max_input_time'));
$this->assertGreaterThanOrEqual(
10,
(int) config('livewire.temporary_file_upload.max_upload_time'),
'Livewire invalidates an upload still in flight after this many minutes.'
);
}
public function test_pictures_keep_a_sane_ceiling_of_their_own(): void
{
// A logo is not a video: it is carried on every page load.
$this->assertLessThan((int) config('uploads.max_kb'), (int) config('uploads.image_max_kb'));
$this->assertLessThan((int) config('uploads.image_max_kb'), (int) config('uploads.icon_max_kb'));
}
}
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