Commit cd2faefd authored by Claude's avatar Claude

Stamp every transaction with the branch it happened in

Records were reaching the database with no branch, so they belonged to
no branch and were invisible in every branch view. Three causes:

1. Invoices have no branch_id column, yet three call sites read
   $invoice->branch_id and stored the result. It was always null.
   POSService did this for every point-of-sale payment, which is why the
   walk-in ("عميل عابر") sales had no branch. POS now uses the branch the
   sale was rung up in; the mobile payment controller and InvoiceShow
   take it from the participant being billed.

2. PaymentService::record() only set a branch if its caller happened to
   pass one, and most callers did not.

3. Nothing enforced the rule centrally.

New BelongsToBranch trait stamps the active branch at creation, mirroring
BelongsToAcademy. It is applied to the models that record an action —
Payment, Expense, CashSession, FacilityRentPayment, POSTransaction,
PurchaseOrder, Participant, TrainingGroup — and deliberately not to
catalogue models such as BasePrice, PricingRule, Product and Employee,
where a null branch legitimately means "shared across all branches".

The trait adds no global scope on purpose: branch is a reporting lens,
not an isolation boundary, and scoping globally would break console
commands, cross-branch reports and the switcher's "all branches" mode.
It also returns null rather than guessing when there is no request
context, so scheduled jobs do not misfile academy-wide records.

Also adds a migration trimming stray whitespace — including the
non-breaking space U+00A0 that survives copy-paste — from names shown to
users. Those characters are invisible in forms but render as a gap in
page titles and receipts, and break exact-match lookups.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 99c1d2b6
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
namespace App\Domain\Financial\Models; namespace App\Domain\Financial\Models;
use App\Domain\Shared\Traits\Auditable; use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use App\Models\User; use App\Models\User;
...@@ -12,7 +13,7 @@ ...@@ -12,7 +13,7 @@
class CashSession extends Model class CashSession extends Model
{ {
use BelongsToAcademy, HasUuid, Auditable; use BelongsToAcademy, BelongsToBranch, HasUuid, Auditable;
protected $fillable = [ protected $fillable = [
'academy_id', 'branch_id', 'user_id', 'academy_id', 'branch_id', 'user_id',
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Domain\Financial\Enums\ExpenseCategory; use App\Domain\Financial\Enums\ExpenseCategory;
use App\Domain\Financial\Enums\PaymentMethod; use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use App\Models\User; use App\Models\User;
...@@ -13,7 +14,7 @@ ...@@ -13,7 +14,7 @@
class Expense extends Model class Expense extends Model
{ {
use HasUuid, BelongsToAcademy, SoftDeletes; use HasUuid, BelongsToAcademy, BelongsToBranch, SoftDeletes;
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Domain\Facility\Models\Facility; use App\Domain\Facility\Models\Facility;
use App\Domain\Financial\Enums\PaymentMethod; use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use App\Models\User; use App\Models\User;
...@@ -13,7 +14,7 @@ ...@@ -13,7 +14,7 @@
class FacilityRentPayment extends Model class FacilityRentPayment extends Model
{ {
use HasUuid, BelongsToAcademy, SoftDeletes; use HasUuid, BelongsToAcademy, BelongsToBranch, SoftDeletes;
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
use App\Domain\Financial\Enums\PaymentMethod; use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus; use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Shared\Traits\Auditable; use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use App\Models\User; use App\Models\User;
...@@ -16,7 +17,7 @@ ...@@ -16,7 +17,7 @@
class Payment extends Model class Payment extends Model
{ {
use HasUuid, BelongsToAcademy, SoftDeletes, Auditable; use HasUuid, BelongsToAcademy, BelongsToBranch, SoftDeletes, Auditable;
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Domain\Inventory\Enums\PurchaseOrderStatus; use App\Domain\Inventory\Enums\PurchaseOrderStatus;
use App\Domain\Shared\Traits\Auditable; use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
...@@ -13,7 +14,7 @@ ...@@ -13,7 +14,7 @@
class PurchaseOrder extends Model class PurchaseOrder extends Model
{ {
use BelongsToAcademy, HasUuid, SoftDeletes, Auditable; use BelongsToAcademy, BelongsToBranch, HasUuid, SoftDeletes, Auditable;
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use App\Domain\POS\Enums\POSPaymentStatus; use App\Domain\POS\Enums\POSPaymentStatus;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Models\Promotion; use App\Domain\Pricing\Models\Promotion;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use App\Models\User; use App\Models\User;
...@@ -18,7 +19,7 @@ ...@@ -18,7 +19,7 @@
class POSTransaction extends Model class POSTransaction extends Model
{ {
use BelongsToAcademy, HasUuid; use BelongsToAcademy, BelongsToBranch, HasUuid;
protected $table = 'pos_transactions'; protected $table = 'pos_transactions';
......
...@@ -176,10 +176,10 @@ public function processTransaction( ...@@ -176,10 +176,10 @@ public function processTransaction(
// Record payment(s) on the invoice (deposit pays only the deposit amount) // Record payment(s) on the invoice (deposit pays only the deposit amount)
if ($paymentMethodEnum === POSPaymentMethod::Split && $splitPayments) { if ($paymentMethodEnum === POSPaymentMethod::Split && $splitPayments) {
foreach ($splitPayments as $sp) { foreach ($splitPayments as $sp) {
$this->recordSinglePayment($invoice, $sp['method'], (int) $sp['amount'], $cashier, $participant); $this->recordSinglePayment($invoice, $sp['method'], (int) $sp['amount'], $cashier, $participant, $branchId);
} }
} else { } else {
$this->recordSinglePayment($invoice, $paymentMethod, $amountToPayNow, $cashier, $participant); $this->recordSinglePayment($invoice, $paymentMethod, $amountToPayNow, $cashier, $participant, $branchId);
} }
// Handle enrollment for program items + inventory deduction for tracked products // Handle enrollment for program items + inventory deduction for tracked products
...@@ -257,7 +257,7 @@ private function validatePayment(POSPaymentMethod $method, int $total, ?Particip ...@@ -257,7 +257,7 @@ private function validatePayment(POSPaymentMethod $method, int $total, ?Particip
/** /**
* Record a single payment against the invoice (handles wallet deduction). * Record a single payment against the invoice (handles wallet deduction).
*/ */
private function recordSinglePayment($invoice, string $method, int $amount, User $cashier, ?Participant $participant): void private function recordSinglePayment($invoice, string $method, int $amount, User $cashier, ?Participant $participant, int $branchId): void
{ {
// Wallet deduction // Wallet deduction
if ($method === 'wallet' && $participant) { if ($method === 'wallet' && $participant) {
...@@ -267,7 +267,10 @@ private function recordSinglePayment($invoice, string $method, int $amount, User ...@@ -267,7 +267,10 @@ private function recordSinglePayment($invoice, string $method, int $amount, User
$this->paymentService->recordPayment([ $this->paymentService->recordPayment([
'academy_id' => $invoice->academy_id, 'academy_id' => $invoice->academy_id,
'branch_id' => $invoice->branch_id, // The branch of the sale. Invoices carry no branch_id, so reading
// it off the invoice always produced null and left POS payments
// unattributed to any branch.
'branch_id' => $branchId,
'invoice_id' => $invoice->id, 'invoice_id' => $invoice->id,
'amount' => $amount, 'amount' => $amount,
'method' => $method, 'method' => $method,
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
use App\Domain\Participant\Enums\RegistrationSource; use App\Domain\Participant\Enums\RegistrationSource;
use App\Domain\Participant\Enums\SkillLevel; use App\Domain\Participant\Enums\SkillLevel;
use App\Domain\Shared\Traits\Auditable; use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Training\Models\Activity; use App\Domain\Training\Models\Activity;
...@@ -23,7 +24,7 @@ ...@@ -23,7 +24,7 @@
class Participant extends Model class Participant extends Model
{ {
use BelongsToAcademy, HasUuid, SoftDeletes, Auditable; use BelongsToAcademy, BelongsToBranch, HasUuid, SoftDeletes, Auditable;
protected $fillable = [ protected $fillable = [
'academy_id', 'academy_id',
......
<?php
namespace App\Domain\Shared\Traits;
use App\Domain\Identity\Models\Branch;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Stamp the active branch onto transactional records at creation.
*
* Mirrors BelongsToAcademy's creating hook, but deliberately adds **no**
* global scope: branch is a reporting lens, not an isolation boundary.
* Console commands, cross-branch reports and the switcher's "all branches"
* mode all need to read across branches, and a global scope would silently
* break them.
*
* Use this only on records that represent an action — a payment, a sale, an
* expense, an enrolment. Catalogue and configuration models (base prices,
* pricing rules, products, employees) legitimately use a null branch to mean
* "shared across every branch", and must not get this trait.
*/
trait BelongsToBranch
{
public static function bootBelongsToBranch(): void
{
static::creating(function ($model) {
if (! $model->branch_id) {
$model->branch_id = static::resolveActiveBranchId();
}
});
}
/**
* The branch the current actor is working in.
*
* Returns null rather than guessing when there is no request context —
* queued jobs and scheduled commands run for the whole academy, and
* inventing a branch there would misfile their records.
*/
public static function resolveActiveBranchId(): ?int
{
if (app()->runningInConsole()) {
return null;
}
if (app()->bound('session') && session()->has('active_branch_id')) {
$sessionBranch = session('active_branch_id');
// A null in the session is the deliberate "all branches" mode, so
// fall through to the user's own branch rather than storing null.
if ($sessionBranch) {
return (int) $sessionBranch;
}
}
return auth()->user()?->branch_id;
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
}
}
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
use App\Domain\Identity\Models\Branch; use App\Domain\Identity\Models\Branch;
use App\Domain\Shared\Traits\Auditable; use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToBranch;
use App\Domain\Shared\Traits\BelongsToAcademy; use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid; use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Shared\Traits\ManglesUniqueOnDelete; use App\Domain\Shared\Traits\ManglesUniqueOnDelete;
...@@ -16,7 +17,7 @@ ...@@ -16,7 +17,7 @@
class TrainingGroup extends Model class TrainingGroup extends Model
{ {
use BelongsToAcademy, HasUuid, SoftDeletes, Auditable, ManglesUniqueOnDelete; use BelongsToAcademy, BelongsToBranch, HasUuid, SoftDeletes, Auditable, ManglesUniqueOnDelete;
protected array $uniqueFieldsToMangle = ['code']; protected array $uniqueFieldsToMangle = ['code'];
......
...@@ -47,7 +47,9 @@ public function initiate(Request $request): JsonResponse ...@@ -47,7 +47,9 @@ public function initiate(Request $request): JsonResponse
Payment::create([ Payment::create([
'academy_id' => $invoice->academy_id, 'academy_id' => $invoice->academy_id,
'branch_id' => $invoice->branch_id ?? null, // Invoices have no branch_id column; take it from the
// participant being billed so the payment is attributable.
'branch_id' => $invoice->billable?->branch_id,
'invoice_id' => $invoice->id, 'invoice_id' => $invoice->id,
'reference' => 'PMB-' . $result['order_id'], 'reference' => 'PMB-' . $result['order_id'],
'direction' => 'inbound', 'direction' => 'inbound',
......
...@@ -65,7 +65,7 @@ public function recordPayment(PaymentService $service): void ...@@ -65,7 +65,7 @@ public function recordPayment(PaymentService $service): void
try { try {
$service->recordPayment([ $service->recordPayment([
'academy_id' => $this->invoice->academy_id, 'academy_id' => $this->invoice->academy_id,
'branch_id' => $this->getActiveBranchId() ?? auth()->user()->branch_id ?? $this->invoice->branch_id ?? null, 'branch_id' => $this->getActiveBranchId() ?? auth()->user()->branch_id ?? $this->invoice->billable?->branch_id,
'invoice_id' => $this->invoice->id, 'invoice_id' => $this->invoice->id,
'reference' => 'PAY-' . now()->format('YmdHis') . '-' . rand(100, 999), 'reference' => 'PAY-' . now()->format('YmdHis') . '-' . rand(100, 999),
'direction' => 'inbound', 'direction' => 'inbound',
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Strip stray leading/trailing whitespace — including the non-breaking space
* U+00A0 that survives a copy-paste out of Word or a browser — from the names
* users actually see.
*
* These characters are invisible in the admin forms but render as a gap in
* page titles, receipts and the mobile app, and they break exact-match
* lookups and sorting.
*
* Safe to re-run: trimming an already-trimmed value is a no-op.
*/
return new class extends Migration
{
private const TARGETS = [
'academies' => ['name', 'name_ar'],
'branches' => ['name', 'name_ar'],
'training_programs' => ['name', 'name_ar'],
'training_groups' => ['name', 'name_ar'],
'people' => ['name', 'name_ar'],
'products' => ['name', 'name_ar'],
'facilities' => ['name', 'name_ar'],
];
public function up(): void
{
// E'...' is Postgres-specific; this project is Postgres-only, but guard
// anyway so a non-pgsql connection cannot fail the whole deploy.
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
foreach (self::TARGETS as $table => $columns) {
if (! Schema::hasTable($table)) {
continue;
}
foreach ($columns as $column) {
if (! Schema::hasColumn($table, $column)) {
continue;
}
DB::statement(
"UPDATE {$table}
SET {$column} = btrim({$column}, E' \\u00a0\\t\\n\\r')
WHERE {$column} IS NOT NULL
AND {$column} <> btrim({$column}, E' \\u00a0\\t\\n\\r')"
);
}
}
}
public function down(): void
{
// Irreversible: the original padding is not recorded, and restoring it
// would only reintroduce the display bug.
}
};
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