Commit e0b69a71 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(financial): let the academy decide where every collected pound lands

Where a collection went in the ledger was a `match` statement: five itemable
classes hardcoded to five revenue codes, identical for every client, and no way
to express tax at all. An accountant who said "no, five pounds of that is a
registration fee and the rest is training, and the kit carries 14% VAT" had no
answer but a deploy.

Revenue routing makes that answer data. Every kind of money the ERP can take is
a source with one rule: a tax treatment, and destination lines that take a flat
amount, a percentage, or the remainder. A rule can be narrowed to a single
product, programme or event, which beats the academy default for that item
alone. financial.revenue-routing edits them, with a preview that runs the
unsaved rule through the real allocation engine rather than a second
implementation that agrees with it until it doesn't.

Three things it will not do:

- Book tax as revenue. VAT collected is owed to the Authority, so it comes out
  first and credits a liability; the service refuses a tax account that is not
  one. Egypt's 14% is a per-source setting because some services are 10% and
  some are exempt.
- Write an entry that does not balance. Integer piasters throughout, floors
  everywhere, one line closes the rounding, and assertBalanced() throws before a
  single row is written — a transaction is immutable, so a wrong one can only be
  reversed, never corrected.
- Change anything on the day it ships. The migration seeds each academy the rule
  that reproduces its current behaviour exactly, so nothing moves until someone
  asks it to. The platform fee ships switched off for the same reason.

Refunds now reverse the collection's own ledger rows in proportion rather than
re-running today's rules — a rule edited last week would otherwise unwind money
into accounts it never touched.

LedgerAccountResolver::splitRevenue() and revenueCodeForItemable() are deleted
rather than deprecated: a second implementation of "where does this money go" is
one a caller would eventually reach for, and it would bypass every rule the
academy wrote.

Verified on a restored oc_sport tenant copy and on a database built from zero:
521 tests, 0 failures on both the Postgres and SQLite suites. PaymentLedgerTest
builds a schema without the routing tables, which pins the other half of the
safety property — an installation that has not received the migration keeps
taking money on the built-in behaviour instead of failing at the till.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 2b6e6997
......@@ -55,6 +55,13 @@ arithmetic only; when splitting, round down and give the remainder to the last i
`academies`, `permissions`, and framework tables are exempt. SuperAdmin is the
only code path that may bypass the global scope.
**Revenue routing** — never hardcode which revenue account a collection lands
in. `RevenueRouter` reads the academy's own rules (`revenue_mappings`), takes
tax out **first** to a **liability** account, then splits the net: flat amounts,
then percentages, then one `remainder` line. The credits must sum to the money
that arrived — `assertBalanced()` throws before anything is written. Rules are
edited on `financial.revenue-routing`, never by deploy.
**Financial** — a `transactions` row carries **both sides**
(`debit_account_id` + `credit_account_id`); there is no pair of rows and no
`account_id` column. Amounts are always positive; `type` says what kind of
......
<?php
namespace App\Domain\Financial\Enums;
/**
* How one line of a routing rule takes its share.
*
* Applied strictly in this order — fixed, then percentage, then remainder —
* because that is the only order in which "5 جنيه رسوم قيد، و10% إدارة،
* والباقي تدريب" means what an accountant reads it to mean.
*/
enum AllocationType: string
{
/** A flat number of piasters off the top. */
case Fixed = 'fixed';
/** A percentage of the net revenue, in basis points (1400 = 14.00%). */
case Percentage = 'percentage';
/** Everything the lines above did not take. At most one per rule. */
case Remainder = 'remainder';
public function label(): string
{
return match ($this) {
self::Fixed => 'مبلغ ثابت',
self::Percentage => 'نسبة مئوية',
self::Remainder => 'الباقي',
};
}
public function hint(): string
{
return match ($this) {
self::Fixed => 'يُخصم من أعلى المبلغ قبل النسب',
self::Percentage => 'نسبة من صافي الإيراد بعد الضريبة',
self::Remainder => 'كل ما تبقى بعد السطور السابقة — سطر واحد فقط لكل قاعدة',
};
}
/** Fixed first, then percentage, then remainder. Lower runs earlier. */
public function pass(): int
{
return match ($this) {
self::Fixed => 0,
self::Percentage => 1,
self::Remainder => 2,
};
}
}
<?php
namespace App\Domain\Financial\Enums;
/**
* Every kind of money the ERP can take in, as a routing key.
*
* This is the catalogue the revenue routing screen lists. A source is only
* here if some code path can actually produce it — a row that can never fire
* is a rule an accountant will configure and then wonder why it never moved
* anything.
*
* The key is stored in `revenue_mappings.source_key` and is permanent: renaming
* a case orphans every rule a client has written against it.
*/
enum RevenueSource: string
{
/** An invoice line with no itemable — the programme subscription convention. */
case Subscription = 'subscription';
/** A line pointed at a programme explicitly (the reporting path uses this). */
case TrainingProgram = 'training_program';
case Product = 'product';
case Kit = 'kit';
case Event = 'event';
case SpaceReservation = 'space_reservation';
/** The platform fee on an invoice — not a line, a column on the invoice. */
case ServiceFee = 'service_fee';
/** Sponsorships and club money, recorded outside the invoice flow. */
case External = 'external';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Subscription => 'اشتراك تدريبي',
self::TrainingProgram => 'اشتراك برنامج مرتبط',
self::Product => 'بيع منتج',
self::Kit => 'بيع طقم',
self::Event => 'رسوم إيفنت أو بطولة',
self::SpaceReservation => 'حجز ملعب أو مساحة',
self::ServiceFee => 'رسوم خدمة المنصة',
self::External => 'إيراد خارجي',
self::Other => 'إيرادات أخرى',
};
}
/** Where in the system this money is taken, in the operator's own words. */
public function description(): string
{
return match ($this) {
self::Subscription => 'كل بند فاتورة غير مرتبط بصنف — اشتراكات البرامج التي يكتبها الاستقبال ووحدة التحصيل',
self::TrainingProgram => 'بنود الفواتير المرتبطة ببرنامج تدريبي بعينه',
self::Product => 'مبيعات نقطة البيع وبنود المنتجات في فواتير التسجيل',
self::Kit => 'الأطقم المباعة ضمن التسجيل أو من نقطة البيع',
self::Event => 'رسوم الاشتراك في الإيفنتات والبطولات',
self::SpaceReservation => 'حجوزات الملاعب والمساحات',
self::ServiceFee => 'رسوم المنصة المضافة على الفاتورة حين يتحملها العميل',
self::External => 'الإيرادات الخارجية المسجَّلة يدوياً: رعاة، أندية، دفعات من خارج النظام',
self::Other => 'أي تحصيل لا ينطبق عليه ما سبق — شبكة الأمان حتى لا يسقط مبلغ من الدفاتر',
};
}
/** The account code the hardcoded `match` used before routing existed. */
public function legacyAccountCode(): string
{
return match ($this) {
self::Subscription, self::TrainingProgram => '4000',
self::Product, self::Kit => '4020',
self::Event => '4050',
self::SpaceReservation => '4030',
self::ServiceFee, self::External, self::Other => '4060',
};
}
/**
* The model an invoice line of this source points at, when it points at
* anything. This is what makes a per-item override possible: "this product
* specifically goes somewhere else".
*
* @return class-string|null
*/
public function scopeModel(): ?string
{
return match ($this) {
self::Product => \App\Domain\Inventory\Models\Product::class,
self::Kit => \App\Domain\Inventory\Models\Kit::class,
self::Event => \App\Domain\Event\Models\Event::class,
self::SpaceReservation => \App\Domain\Facility\Models\SpaceReservation::class,
self::TrainingProgram => \App\Domain\Training\Models\TrainingProgram::class,
self::Subscription, self::ServiceFee, self::External, self::Other => null,
};
}
/** Can an accountant narrow this source down to one specific item? */
public function supportsOverrides(): bool
{
return $this->scopeModel() !== null;
}
/**
* Which source an invoice line belongs to.
*
* A null itemable means a subscription: that is the convention
* ParticipantBillingService, the settlement worklist and every revenue
* widget already read, and this must not disagree with them.
*/
public static function forItemable(?string $itemableType): self
{
return match ($itemableType) {
null, '' => self::Subscription,
\App\Domain\Inventory\Models\Product::class => self::Product,
\App\Domain\Inventory\Models\Kit::class => self::Kit,
\App\Domain\Event\Models\Event::class => self::Event,
\App\Domain\Facility\Models\SpaceReservation::class => self::SpaceReservation,
\App\Domain\Training\Models\TrainingProgram::class => self::TrainingProgram,
default => self::Other,
};
}
/** The catalogue in the order the screen shows it. */
public static function catalogue(): array
{
return [
self::Subscription,
self::Product,
self::Kit,
self::Event,
self::SpaceReservation,
self::TrainingProgram,
self::ServiceFee,
self::External,
self::Other,
];
}
}
<?php
namespace App\Domain\Financial\Enums;
/**
* How tax is carried on a revenue source.
*
* The distinction that matters, and the one that gets ERPs failed in an audit:
* VAT is **not** a slice of revenue. It is money collected on behalf of the Tax
* Authority and owed to it, so it credits a liability account, never a revenue
* one. The routing rule therefore takes the tax out first and splits only what
* is left.
*
* Egypt's standard VAT rate is 14%; some services sit at the 10% schedule rate
* and some are exempt outright, which is exactly why this is a per-source
* setting and not one switch for the whole academy.
*/
enum TaxMode: string
{
/** Exempt or out of scope. Everything collected is revenue. */
case None = 'none';
/**
* The price already contains the tax. 114 collected at 14% is 100 revenue
* and 14 owed to the Authority — the customer paid one number.
*/
case Inclusive = 'inclusive';
/**
* The tax is a separate line the invoice already carries, added on top of
* the price. The rule posts the invoice's own tax figure and routes the
* rest; it never invents tax that was not charged.
*/
case Exclusive = 'exclusive';
public function label(): string
{
return match ($this) {
self::None => 'بدون ضريبة',
self::Inclusive => 'السعر شامل الضريبة',
self::Exclusive => 'الضريبة مضافة على السعر',
};
}
public function hint(): string
{
return match ($this) {
self::None => 'الخدمة معفاة أو خارج نطاق الضريبة — كل المحصَّل إيراد',
self::Inclusive => 'المبلغ المحصَّل يحتوي الضريبة بداخله، فتُستخرج منه وتُرحَّل لحساب الضريبة',
self::Exclusive => 'الضريبة محسوبة كسطر مستقل على الفاتورة — يُرحَّل ما احتسبته الفاتورة فقط',
};
}
public function chargesTax(): bool
{
return $this !== self::None;
}
}
<?php
namespace App\Domain\Financial\Models;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* One routing rule: where the money from a revenue source goes, and what tax
* comes off it first.
*
* A rule with no scope is the academy's default for that source. A rule with a
* scope narrows it to one product, programme or event, and beats the default.
* That two-level fallback is the same shape Odoo gives a product versus its
* category and Dynamics gives an item versus its posting group — specific wins,
* and there is always a general answer underneath so nothing is ever unrouted.
*/
class RevenueMapping extends Model
{
use HasUuid, BelongsToAcademy, Auditable;
protected $fillable = [
'academy_id',
'source_key',
'scope_type',
'scope_id',
'label',
'is_active',
'tax_mode',
'tax_rate_bp',
'tax_account_id',
'notes',
'updated_by',
];
protected function casts(): array
{
return [
'source_key' => RevenueSource::class,
'is_active' => 'boolean',
'tax_mode' => TaxMode::class,
'tax_rate_bp' => 'integer',
'scope_id' => 'integer',
];
}
public function lines(): HasMany
{
return $this->hasMany(RevenueMappingLine::class)->orderBy('sort_order')->orderBy('id');
}
public function taxAccount(): BelongsTo
{
return $this->belongsTo(FinancialAccount::class, 'tax_account_id');
}
public function scope(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'scope_type', 'scope_id');
}
public function updatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
public function isDefault(): bool
{
return $this->scope_type === null;
}
/** The tax rate as a percentage, for display only. */
public function taxRatePercent(): float
{
return $this->tax_rate_bp / 100;
}
/**
* Changing where money lands is a financial act and belongs in the audit
* log beside the invoice corrections and the settlements.
*/
public function isFinancialAudit(): bool
{
return true;
}
}
<?php
namespace App\Domain\Financial\Models;
use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One destination inside a routing rule: an account and the share it takes.
*
* No global academy scope: a line is only ever reached through its mapping,
* which is scoped, and adding a second scope here would hide lines from the
* ledger writer whenever it runs outside a request (the queue, a console
* command) where no current academy is bound. `academy_id` is still stamped —
* it is a tenant table and every query that goes near it must be able to say so.
*/
class RevenueMappingLine extends Model
{
use HasUuid;
protected $fillable = [
'academy_id',
'revenue_mapping_id',
'account_id',
'allocation_type',
'percentage_bp',
'fixed_amount',
'label',
'sort_order',
];
protected function casts(): array
{
return [
'allocation_type' => AllocationType::class,
'percentage_bp' => 'integer',
'fixed_amount' => 'integer',
'sort_order' => 'integer',
];
}
public function mapping(): BelongsTo
{
return $this->belongsTo(RevenueMapping::class, 'revenue_mapping_id');
}
public function account(): BelongsTo
{
return $this->belongsTo(FinancialAccount::class, 'account_id');
}
/** The share as a percentage, for display only. */
public function percent(): float
{
return ($this->percentage_bp ?? 0) / 100;
}
/** How this line reads on the rules list: "١٠٪", "٥٫٠٠ ج.م", "الباقي". */
public function shareLabel(): string
{
return match ($this->allocation_type) {
AllocationType::Percentage => rtrim(rtrim(number_format($this->percent(), 2), '0'), '.') . '%',
AllocationType::Fixed => format_money((int) $this->fixed_amount),
AllocationType::Remainder => 'الباقي',
};
}
}
......@@ -3,7 +3,6 @@
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Shared\Exceptions\DomainException;
/**
......@@ -82,77 +81,22 @@ public function assetAccountForMethod(int $academyId, ?string $method): Financia
}
/**
* Split an amount across revenue accounts in proportion to the invoice's
* own line composition, so getRevenueBySource() reports what was sold.
* Deciding WHICH revenue account a collection belongs to no longer lives
* here.
*
* Integer arithmetic only: every share is floored with intdiv() and the
* remainder is given to the last row, per the money rules.
* splitRevenue() and revenueCodeForItemable() used to answer that with a
* `match` over five itemable classes, which meant the answer was the same
* for every client and could only be changed by a deploy — and there was
* nowhere at all to say that part of an amount is tax. RevenueRouter reads
* the academy's own routing rules instead, and falls back to exactly these
* five pairings when a tenant has configured nothing.
*
* @return list<array{account: FinancialAccount, amount: int}>
*/
public function splitRevenue(int $academyId, ?Invoice $invoice, int $amount): array
{
if ($amount <= 0) {
return [];
}
$lines = $invoice
? $invoice->items()->get(['itemable_type', 'total_amount'])
: collect();
$byCode = [];
foreach ($lines as $line) {
$code = $this->revenueCodeForItemable($line->itemable_type);
$byCode[$code] = ($byCode[$code] ?? 0) + max(0, (int) $line->total_amount);
}
$base = array_sum($byCode);
// No invoice, no lines, or a zero-value invoice: everything lands in
// one bucket rather than being silently dropped from the ledger.
if ($base <= 0) {
return [[
'account' => $this->byCode($academyId, $invoice ? self::REVENUE_TRAINING : self::REVENUE_OTHER),
'amount' => $amount,
]];
}
arsort($byCode);
$split = [];
$allocated = 0;
$codes = array_keys($byCode);
$last = array_key_last($codes);
foreach ($codes as $i => $code) {
$share = $i === $last
? $amount - $allocated // remainder to the last row
: intdiv($amount * $byCode[$code], $base);
$allocated += $share;
if ($share > 0) {
$split[] = ['account' => $this->byCode($academyId, $code), 'amount' => $share];
}
}
return $split;
}
/**
* An invoice line with no itemable is a programme subscription — that is
* the convention ParticipantBillingService and every revenue widget read.
* They were deleted rather than deprecated: a second implementation of
* "where does this money go" is one a caller will eventually reach for, and
* it would silently bypass every rule the academy's accountant wrote.
*
* What stays here is what the router still needs and what has no second
* opinion: resolving an account by code, and the asset account a payment
* method settles into.
*/
public function revenueCodeForItemable(?string $itemableType): string
{
return match ($itemableType) {
null => self::REVENUE_TRAINING,
\App\Domain\Inventory\Models\Product::class => self::REVENUE_EQUIPMENT,
\App\Domain\Inventory\Models\Kit::class => self::REVENUE_EQUIPMENT,
\App\Domain\Event\Models\Event::class => self::REVENUE_TOURNAMENT,
\App\Domain\Facility\Models\SpaceReservation::class => self::REVENUE_FACILITY,
default => self::REVENUE_OTHER,
};
}
}
......@@ -18,6 +18,7 @@ class PaymentService
public function __construct(
private InvoiceService $invoiceService,
private LedgerAccountResolver $accounts,
private RevenueRouter $router,
) {}
/**
......@@ -250,6 +251,12 @@ private function assertInvoiceIsPayable(Invoice $invoice): void
* `transactions` is a single row carrying both sides (debit_account_id +
* credit_account_id), so a payment spread across more than one revenue
* account produces one row per account, all linked to the same payment.
*
* Which accounts those are is no longer a decision this class makes. The
* academy's own routing rules decide — including how much of the money was
* tax owed to the Authority rather than revenue at all — and the allocation
* is refused before a single row is written unless the credits add up to
* exactly the money that came in.
*/
private function postToLedger(Payment $payment, ?Invoice $invoice, ?TransactionType $type = null): void
{
......@@ -261,23 +268,30 @@ private function postToLedger(Payment $payment, ?Invoice $invoice, ?TransactionT
$payment->method?->value ?? (string) $payment->method
);
$splits = $this->accounts->splitRevenue($payment->academy_id, $invoice, (int) $payment->amount);
$allocation = $this->router->allocatePayment(
(int) $payment->academy_id,
$invoice,
(int) $payment->amount,
);
foreach ($splits as $split) {
foreach ($allocation->postings as $posting) {
Transaction::create([
'academy_id' => $payment->academy_id,
'branch_id' => $payment->branch_id,
// Money in debits the asset it landed in and credits revenue.
// Money out (refund) reverses both sides.
'debit_account_id' => $isInbound ? $asset->id : $split['account']->id,
'credit_account_id' => $isInbound ? $split['account']->id : $asset->id,
// Money in debits the asset it landed in and credits revenue
// (or the tax liability). Money out reverses both sides.
'debit_account_id' => $isInbound ? $asset->id : $posting->account->id,
'credit_account_id' => $isInbound ? $posting->account->id : $asset->id,
'payment_id' => $payment->id,
'invoice_id' => $payment->invoice_id,
'amount' => $split['amount'],
'amount' => $posting->amount,
'currency' => $payment->currency,
'type' => $type,
'description' => $this->buildDescription($payment, $type),
'transaction_date' => $payment->payment_date,
// Which rule sent this money here, recorded on the row itself:
// the rule can be edited tomorrow, the entry cannot.
'metadata' => $posting->metadata(),
'created_by' => $payment->created_by,
]);
}
......
......@@ -18,6 +18,7 @@ public function __construct(
private readonly InvoiceService $invoiceService,
private readonly CashSessionService $cashSessionService,
private readonly LedgerAccountResolver $accounts,
private readonly RevenueRouter $router,
) {}
/**
......@@ -162,25 +163,28 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor,
$refundPayment->method?->value ?? (string) $refundPayment->method
);
$splits = $this->accounts->splitRevenue(
$refundPayment->academy_id,
$refundPayment->invoice,
$refundAmount
);
foreach ($splits as $split) {
// Unwind the accounts the money actually went into — including the
// tax liability, since refunding VAT to revenue would leave the
// academy owing tax on money it gave back.
//
// Read off the original payment's own ledger rows rather than by
// re-running today's routing rules: a rule the accountant edited
// last week would otherwise reverse a collection into accounts it
// never touched, and no report would ever reconcile again.
foreach ($this->reversalsFor($payment, $refundPayment, $refundAmount) as $reversal) {
Transaction::create([
'academy_id' => $refundPayment->academy_id,
'branch_id' => $refundPayment->branch_id,
'debit_account_id' => $split['account']->id,
'debit_account_id' => $reversal['account_id'],
'credit_account_id' => $asset->id,
'payment_id' => $refundPayment->id,
'invoice_id' => $refundPayment->invoice_id,
'amount' => $split['amount'],
'amount' => $reversal['amount'],
'currency' => $refundPayment->currency ?? 'EGP',
'type' => TransactionType::Refund,
'description' => "استرداد: {$payment->reference}",
'transaction_date' => now()->toDateString(),
'metadata' => $reversal['metadata'],
'created_by' => $actor->id,
]);
}
......@@ -230,4 +234,72 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor,
return $results;
}
/**
* The accounts a refund debits, and how much of each.
*
* A refund is the mirror of a specific collection, so it is read off that
* collection's own ledger rows: whatever was credited then is debited now,
* in the same proportions. A partial refund takes a proportional slice of
* each, floored, with the remainder on the last row so the reversal totals
* exactly the money going back out.
*
* The fallback matters. Payments taken before the account resolver was
* fixed credited cash and bank and never a revenue account, and there are
* hundreds of them; those have nothing to mirror, so the current rules are
* the best available answer and the row says so.
*
* @return list<array{account_id: int, amount: int, metadata: array}>
*/
private function reversalsFor(Payment $payment, Payment $refundPayment, int $refundAmount): array
{
$original = Transaction::withoutGlobalScopes()
->where('payment_id', $payment->id)
->where('type', TransactionType::PaymentReceived)
->orderBy('id')
->get(['id', 'credit_account_id', 'amount', 'metadata']);
$base = (int) $original->sum('amount');
if ($base <= 0) {
$allocation = $this->router->allocatePayment(
(int) $refundPayment->academy_id,
$refundPayment->invoice,
$refundAmount
);
return array_map(fn ($posting) => [
'account_id' => $posting->account->id,
'amount' => $posting->amount,
'metadata' => $posting->metadata() + ['reversal_basis' => 'current_rules'],
], $allocation->postings);
}
$out = [];
$allocated = 0;
$last = $original->count() - 1;
foreach ($original as $i => $row) {
$share = $i === $last
? $refundAmount - $allocated
: intdiv($refundAmount * (int) $row->amount, $base);
$allocated += $share;
if ($share <= 0) {
continue;
}
$out[] = [
'account_id' => (int) $row->credit_account_id,
'amount' => $share,
'metadata' => ((array) $row->metadata) + [
'reversal_basis' => 'original_entry',
'reverses_transaction_id' => (int) $row->id,
],
];
}
return $out;
}
}
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\RevenueMapping;
use App\Domain\Financial\Support\RevenueAllocation;
use App\Domain\Financial\Support\RevenuePosting;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Schema;
/**
* Decides which ledger accounts a collected amount lands in, and how much of it
* was tax rather than revenue.
*
* This replaces a `match` statement. Before it, "where does this money go" was
* five itemable classes hardcoded to five revenue codes with no tax anywhere,
* and the only way to answer a finance manager who said "no, part of that goes
* to 4010" was a deploy. Now it is a rule an accountant edits, and this class
* is the thing that reads it.
*
* Three guarantees, in order of how badly their absence hurts:
*
* 1. The credits always sum to exactly the money that came in. Integer
* piasters throughout, floors everywhere, and one designated line takes
* the leftover — see RevenueAllocation::assertBalanced().
* 2. Tax is taken out first and credited to a liability account, never to
* revenue. VAT collected is owed to the Authority; booking it as income
* overstates revenue and understates a liability, which is the specific
* mistake that fails an audit.
* 3. An academy with no rules configured behaves exactly as the system did
* before this existed. Nothing silently moves the day this ships.
*/
class RevenueRouter
{
/** Where VAT goes when a rule charges tax without naming an account. */
public const VAT_PAYABLE = '2030';
/** @var array<string, RevenueMapping|null> keyed "{academy}:{source}:{scopeType}:{scopeId}" */
private array $cache = [];
/**
* Set to false the first time a lookup finds the routing tables missing.
*
* Rules are configuration. An installation that has not received the
* routing migration yet must keep taking money using the built-in
* behaviour, not refuse a payment at the till because a settings table is
* absent — and it must not pay for that safety with a schema check on
* every single collection either, which is why this is only consulted
* after a query has actually failed.
*/
private ?bool $tablesPresent = null;
public function __construct(private LedgerAccountResolver $accounts) {}
/**
* Route one payment against an invoice.
*
* The payment may be partial, so each invoice line takes a share of it in
* proportion to that line's own value, and each share is then routed by the
* rules for the thing that line sold. A 500-pound payment against an
* invoice that is half kit and half subscription credits both, in the
* proportions the invoice itself says — and each half obeys its own tax
* treatment, which is the whole point.
*/
public function allocatePayment(int $academyId, ?Invoice $invoice, int $amount): RevenueAllocation
{
if ($amount <= 0) {
return RevenueAllocation::empty();
}
$buckets = $this->bucketsFor($invoice);
// No invoice at all, or an invoice with nothing on it. The fallback
// sources match what LedgerAccountResolver::splitRevenue() chose in the
// same two cases, so a tenant that configures nothing sees no change.
if ($buckets === []) {
return $this->allocateSource(
$academyId,
$invoice ? RevenueSource::Subscription : RevenueSource::Other,
$amount,
)->assertBalanced();
}
$base = array_sum(array_column($buckets, 'weight'));
if ($base <= 0) {
return $this->allocateSource($academyId, RevenueSource::Subscription, $amount)->assertBalanced();
}
// Biggest first, remainder piastre to the last row — the money rules'
// rounding convention, and the one splitRevenue() already used.
usort($buckets, fn ($a, $b) => $b['weight'] <=> $a['weight']);
$allocation = RevenueAllocation::empty();
$allocated = 0;
$lastIndex = count($buckets) - 1;
foreach ($buckets as $i => $bucket) {
$share = $i === $lastIndex
? $amount - $allocated
: intdiv($amount * $bucket['weight'], $base);
$allocated += $share;
if ($share <= 0) {
continue;
}
// The tax the invoice itself charged on this bucket, scaled to the
// part of it this payment covers. Only `exclusive` rules read it.
$chargedTax = $bucket['weight'] > 0
? intdiv($bucket['tax'] * $share, $bucket['weight'])
: 0;
$allocation = $allocation->merge($this->allocateSource(
$academyId,
$bucket['source'],
$share,
$bucket['scope_type'],
$bucket['scope_id'],
$chargedTax,
));
}
return $allocation->collapsed()->assertBalanced();
}
/**
* Route a single amount from one revenue source.
*
* @param int|null $chargedTax tax the invoice already charged on this
* amount, for rules whose price excludes tax
*/
public function allocateSource(
int $academyId,
RevenueSource $source,
int $amount,
?string $scopeType = null,
?int $scopeId = null,
?int $chargedTax = null,
): RevenueAllocation {
if ($amount <= 0) {
return RevenueAllocation::empty();
}
$mapping = $this->mappingFor($academyId, $source, $scopeType, $scopeId);
if (! $mapping || $mapping->lines->isEmpty()) {
return $this->legacyAllocation($academyId, $source, $amount);
}
return $this->allocateWith($academyId, $mapping, $source, $amount, $chargedTax);
}
/**
* Route an amount through a rule that was handed in rather than looked up.
*
* This is what makes the editing screen's preview honest: it runs the rule
* the accountant is typing — unsaved, not yet in the database — through the
* same arithmetic that will post the real entry tonight, instead of a
* second implementation that agrees with it right up until it doesn't.
*/
public function allocateWith(
int $academyId,
RevenueMapping $mapping,
RevenueSource $source,
int $amount,
?int $chargedTax = null,
): RevenueAllocation {
if ($amount <= 0) {
return RevenueAllocation::empty();
}
[$tax, $taxAccount, $warnings] = $this->resolveTax($academyId, $mapping, $amount, $chargedTax);
$net = $amount - $tax;
$postings = [];
if ($tax > 0 && $taxAccount) {
$postings[] = new RevenuePosting(
$taxAccount,
$tax,
'tax',
[$this->taxRuleLabel($mapping)],
$mapping->id,
null,
$source->value,
);
}
$postings = [...$postings, ...$this->splitNet($mapping, $net, $source)];
$allocation = new RevenueAllocation($amount, $net, $tax, $postings, $warnings);
return $this->closeRounding($allocation, $academyId, $source);
}
/**
* The rule that governs this source: the one written for this specific
* item if there is one, otherwise the academy's default for the source.
*
* Only active rules count. Deactivating a rule is how an accountant says
* "go back to the built-in behaviour" without deleting their work.
*/
public function mappingFor(
int $academyId,
RevenueSource $source,
?string $scopeType = null,
?int $scopeId = null,
): ?RevenueMapping {
if ($this->tablesPresent === false) {
return null;
}
$key = "{$academyId}:{$source->value}:{$scopeType}:{$scopeId}";
if (array_key_exists($key, $this->cache)) {
return $this->cache[$key];
}
$query = fn () => RevenueMapping::withoutGlobalScopes()
->with(['lines.account', 'taxAccount'])
->where('academy_id', $academyId)
->where('source_key', $source->value)
->where('is_active', true);
try {
$mapping = null;
if ($scopeType !== null && $scopeId !== null) {
$mapping = $query()
->where('scope_type', $scopeType)
->where('scope_id', $scopeId)
->first();
}
$mapping ??= $query()->whereNull('scope_type')->first();
} catch (QueryException $e) {
// Only a missing table degrades to the built-in behaviour. Any
// other database failure is a real one and must still surface,
// rather than quietly sending money to a default account.
if (Schema::hasTable('revenue_mappings')) {
throw $e;
}
$this->tablesPresent = false;
return null;
}
$this->tablesPresent = true;
return $this->cache[$key] = $mapping;
}
/** Forget resolved rules — the editing screen changes them mid-request. */
public function flush(): void
{
$this->cache = [];
}
/**
* One weighted bucket per thing the invoice sold, plus the platform fee
* when the academy has chosen to route it separately.
*
* @return list<array{source: RevenueSource, scope_type: ?string, scope_id: ?int, weight: int, tax: int}>
*/
private function bucketsFor(?Invoice $invoice): array
{
if (! $invoice) {
return [];
}
$buckets = [];
// Always read the lines unscoped rather than trusting a loaded relation.
// `invoice_items` is branch-scoped through its parent, and the ledger
// must post every line an invoice carries regardless of which branch the
// person taking the money happens to be looking at.
$lines = $invoice->items()
->withoutGlobalScopes()
->get(['itemable_type', 'itemable_id', 'total_amount', 'tax_amount']);
foreach ($lines as $line) {
$source = RevenueSource::forItemable($line->itemable_type);
$scopeType = $line->itemable_type ?: null;
$scopeId = $scopeType ? (int) $line->itemable_id : null;
$key = $source->value . ':' . $scopeType . ':' . $scopeId;
$buckets[$key] ??= [
'source' => $source,
'scope_type' => $scopeType,
'scope_id' => $scopeId,
'weight' => 0,
'tax' => 0,
];
$buckets[$key]['weight'] += max(0, (int) $line->total_amount);
$buckets[$key]['tax'] += max(0, (int) $line->tax_amount);
}
// The platform fee is a column on the invoice, not a line, so it has
// always ridden along inside whatever the lines were. It only becomes
// its own bucket once an academy activates the rule and says where it
// belongs — otherwise nothing about today's split changes.
$fee = max(0, (int) ($invoice->service_fee_amount ?? 0));
if ($fee > 0 && $this->mappingFor((int) $invoice->academy_id, RevenueSource::ServiceFee)) {
$buckets['service_fee'] = [
'source' => RevenueSource::ServiceFee,
'scope_type' => null,
'scope_id' => null,
'weight' => $fee,
'tax' => 0,
];
}
return array_values(array_filter($buckets, fn ($b) => $b['weight'] > 0));
}
/**
* How much of this amount is tax, and where it goes.
*
* @return array{0: int, 1: FinancialAccount|null, 2: list<string>}
*/
private function resolveTax(int $academyId, RevenueMapping $mapping, int $amount, ?int $chargedTax): array
{
$warnings = [];
$tax = match ($mapping->tax_mode) {
TaxMode::None => 0,
// The price contains the tax: net = gross × 10000 / (10000 + rate),
// floored, and the tax is whatever is left. Deriving the net first
// and subtracting means the two always add back to the gross, which
// rounding the tax directly does not guarantee.
TaxMode::Inclusive => $amount - intdiv($amount * 10000, 10000 + max(0, (int) $mapping->tax_rate_bp)),
// The invoice added tax as its own figure; post exactly that and
// never more than came in.
TaxMode::Exclusive => min(max(0, (int) $chargedTax), $amount),
};
if ($mapping->tax_mode === TaxMode::Exclusive && $tax === 0 && $mapping->tax_rate_bp > 0) {
$warnings[] = 'القاعدة تحتسب ضريبة مضافة على السعر، لكن الفاتورة لم تحمل سطر ضريبة — لم تُرحَّل ضريبة على هذا المبلغ';
}
if ($tax <= 0) {
return [0, null, $warnings];
}
$account = $mapping->taxAccount;
if (! $account) {
// The table constraint forbids this, but a rule written before the
// constraint, or by a direct SQL edit on a client database, must
// not be allowed to post tax into nowhere.
$account = $this->findAccount($academyId, self::VAT_PAYABLE);
}
if (! $account) {
$warnings[] = 'لا يوجد حساب ضريبة معرَّف — أُدرج المبلغ كاملاً كإيراد';
return [0, null, $warnings];
}
return [$tax, $account, $warnings];
}
/**
* Split the net across the rule's lines: flat amounts off the top, then
* percentages of the net, then one line takes what is left.
*
* Every share is capped at what actually remains, so a rule whose fixed
* amounts exceed a small payment degrades to "the early lines take what
* there is" rather than posting money that never arrived.
*
* @return list<RevenuePosting>
*/
private function splitNet(RevenueMapping $mapping, int $net, RevenueSource $source): array
{
if ($net <= 0) {
return [];
}
// Zero-padded so the sort is by pass, then the accountant's own order,
// then insertion — and never by the accident of string length.
$lines = $mapping->lines
->sortBy(fn ($line) => sprintf(
'%d-%05d-%012d',
$line->allocation_type->pass(),
(int) $line->sort_order,
(int) $line->id,
))
->values();
$remaining = $net;
$postings = [];
foreach ($lines as $line) {
if ($remaining <= 0) {
break;
}
$share = match ($line->allocation_type) {
AllocationType::Fixed => min((int) $line->fixed_amount, $remaining),
AllocationType::Percentage => min(intdiv($net * (int) $line->percentage_bp, 10000), $remaining),
AllocationType::Remainder => $remaining,
};
if ($share <= 0 || ! $line->account) {
continue;
}
$remaining -= $share;
$postings[] = new RevenuePosting(
$line->account,
$share,
'revenue',
[$this->lineRuleLabel($mapping, $line)],
$mapping->id,
$line->id,
$source->value,
);
}
// Percentages that do not reach 100% and no remainder line: the leftover
// has to land somewhere or the entry does not balance. The last line
// takes it, which is the same convention as every other split here.
if ($remaining > 0 && $postings !== []) {
$last = array_key_last($postings);
$postings[$last] = $postings[$last]->withAmount($postings[$last]->amount + $remaining);
}
return $postings;
}
/**
* Last line of defence: if the postings still do not add up to the money
* that came in — a rule with no usable account, a net of zero — put the
* difference somewhere real rather than writing a lopsided entry.
*/
private function closeRounding(RevenueAllocation $allocation, int $academyId, RevenueSource $source): RevenueAllocation
{
$shortfall = $allocation->gross - $allocation->totalPosted();
if ($shortfall === 0) {
return $allocation;
}
$postings = $allocation->postings;
if ($postings !== []) {
$last = array_key_last($postings);
$postings[$last] = $postings[$last]->withAmount($postings[$last]->amount + $shortfall);
return new RevenueAllocation(
$allocation->gross,
$allocation->net,
$allocation->tax,
$postings,
$allocation->warnings,
);
}
$fallback = $this->legacyAllocation($academyId, $source, $shortfall);
return new RevenueAllocation(
$allocation->gross,
$allocation->net,
$allocation->tax,
$fallback->postings,
[...$allocation->warnings, 'لم تُنتج قاعدة التوزيع أي حساب صالح — رُحِّل المبلغ للحساب الافتراضي للمصدر'],
);
}
/**
* What the system did before routing existed: the whole amount to the one
* revenue account the source's class was hardcoded to.
*/
private function legacyAllocation(int $academyId, RevenueSource $source, int $amount): RevenueAllocation
{
$account = $this->accounts->byCode($academyId, $source->legacyAccountCode());
return new RevenueAllocation($amount, $amount, 0, [
new RevenuePosting($account, $amount, 'revenue', ['الافتراضي المدمج'], null, null, $source->value),
]);
}
private function findAccount(int $academyId, string $code): ?FinancialAccount
{
return FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('code', $code)
->first();
}
private function taxRuleLabel(RevenueMapping $mapping): string
{
$rate = rtrim(rtrim(number_format($mapping->taxRatePercent(), 2), '0'), '.');
return "ضريبة {$rate}% — {$mapping->tax_mode->label()}";
}
private function lineRuleLabel(RevenueMapping $mapping, $line): string
{
$scope = $mapping->isDefault() ? 'افتراضي' : 'مخصص';
return "{$mapping->source_key->label()} ({$scope}): {$line->shareLabel()}{$line->account->code}";
}
}
<?php
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\AccountType;
use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\RevenueMapping;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\DB;
/**
* Writing routing rules — the half of revenue routing that has to say no.
*
* Every rule this saves is checked before it can be saved, because a rule is
* read at the till by code that must never stop and ask. The checks are not
* paperwork; each one corresponds to a specific way a plausible-looking rule
* silently produces a wrong ledger:
*
* - a rule with percentages that do not reach 100% and no line to take the
* rest quietly overloads whichever line happened to be last;
* - two "remainder" lines mean the leftover has two homes;
* - a flat amount after the leftover has already been taken can never fire;
* - a tax rate pointed at a revenue account books money owed to the Tax
* Authority as income, which overstates revenue and hides a liability.
*/
class RevenueRoutingService
{
/**
* Where collected money may be sent.
*
* Revenue is the ordinary case. Liability is deliberate and important: it
* is how "this part is not earned yet" (2010 إيرادات مقدمة) and "this part
* is tax I owe" are expressed. Equity covers an owner's contribution booked
* through the till. An asset or an expense account is neither — money
* arriving cannot be a cost, and crediting an asset here would double-count
* the cash that was already debited.
*/
private const POSTABLE_TYPES = [AccountType::Revenue, AccountType::Liability, AccountType::Equity];
public function __construct(private RevenueRouter $router) {}
/**
* Create or replace the rule for one revenue source (optionally narrowed
* to a single product, programme or event).
*
* @param array{is_active?: bool, label?: ?string, tax_mode?: string, tax_rate_bp?: int, tax_account_id?: ?int, notes?: ?string} $attributes
* @param list<array{account_id: int, allocation_type: string, percentage_bp?: ?int, fixed_amount?: ?int, label?: ?string}> $lines
*/
public function save(
int $academyId,
RevenueSource $source,
array $attributes,
array $lines,
?int $actorId = null,
?string $scopeType = null,
?int $scopeId = null,
): RevenueMapping {
$taxMode = TaxMode::from($attributes['tax_mode'] ?? TaxMode::None->value);
$taxRate = (int) ($attributes['tax_rate_bp'] ?? 0);
$taxAccountId = $attributes['tax_account_id'] ?? null;
$lines = $this->normaliseLines($lines);
$this->assertScopeIsValid($academyId, $source, $scopeType, $scopeId);
$this->assertLinesAreCoherent($lines);
$this->assertAccountsArePostable($academyId, $lines);
$this->assertTaxIsCoherent($academyId, $taxMode, $taxRate, $taxAccountId);
return DB::transaction(function () use (
$academyId, $source, $attributes, $lines, $actorId, $scopeType, $scopeId, $taxMode, $taxRate, $taxAccountId
) {
$mapping = RevenueMapping::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('source_key', $source->value)
->when(
$scopeType === null,
fn ($q) => $q->whereNull('scope_type'),
fn ($q) => $q->where('scope_type', $scopeType)->where('scope_id', $scopeId),
)
->lockForUpdate()
->first();
$payload = [
'academy_id' => $academyId,
'source_key' => $source->value,
'scope_type' => $scopeType,
'scope_id' => $scopeId,
'label' => $attributes['label'] ?? null,
'is_active' => (bool) ($attributes['is_active'] ?? true),
'tax_mode' => $taxMode->value,
'tax_rate_bp' => $taxMode === TaxMode::None ? 0 : $taxRate,
'tax_account_id' => $taxMode === TaxMode::None ? null : $taxAccountId,
'notes' => $attributes['notes'] ?? null,
'updated_by' => $actorId,
];
if ($mapping) {
$mapping->fill($payload)->save();
} else {
$mapping = RevenueMapping::withoutGlobalScopes()->create($payload);
}
// Replace rather than reconcile: a rule is small, and a stale line
// that survived an edit is a payment posted to an account nobody
// meant to keep. The old rows are gone but the transactions they
// produced carry their own copy of what they did.
$mapping->lines()->delete();
foreach ($lines as $index => $line) {
$mapping->lines()->create([
'academy_id' => $academyId,
'account_id' => $line['account_id'],
'allocation_type' => $line['allocation_type'],
'percentage_bp' => $line['percentage_bp'],
'fixed_amount' => $line['fixed_amount'],
'label' => $line['label'],
'sort_order' => $index,
]);
}
$this->router->flush();
return $mapping->fresh(['lines.account', 'taxAccount']);
});
}
/**
* Remove a rule written for one specific item. The source's own default
* takes over again — which is why only overrides can be deleted: deleting
* a default would leave the source with nothing to fall back to but the
* built-in behaviour, and "turn it off" already says that more clearly.
*/
public function deleteOverride(RevenueMapping $mapping): void
{
if ($mapping->isDefault()) {
throw new DomainException('لا يمكن حذف القاعدة الافتراضية — أوقف تفعيلها بدلاً من ذلك');
}
DB::transaction(function () use ($mapping) {
$mapping->lines()->delete();
$mapping->delete();
$this->router->flush();
});
}
/**
* Add an account to the chart, so an accountant who wants the money in a
* new account does not need an engineer to make one.
*
* @param array{code: string, name_ar: string, name?: ?string, type: string} $data
*/
public function createAccount(int $academyId, array $data): FinancialAccount
{
$code = trim($data['code']);
$type = AccountType::tryFrom($data['type'] ?? '');
if ($code === '' || ! preg_match('/^[0-9A-Za-z\-]{2,20}$/', $code)) {
throw new DomainException('رقم الحساب يجب أن يكون من 2 إلى 20 حرفاً أو رقماً');
}
if (! $type) {
throw new DomainException('نوع الحساب غير صحيح');
}
return DB::transaction(function () use ($academyId, $code, $type, $data) {
$exists = FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('code', $code)
->exists();
if ($exists) {
throw new DomainException("رقم الحساب {$code} مستخدم بالفعل في شجرة الحسابات");
}
return FinancialAccount::withoutGlobalScopes()->create([
'academy_id' => $academyId,
'code' => $code,
'name' => $data['name'] ?: $data['name_ar'],
'name_ar' => $data['name_ar'],
'type' => $type->value,
'category' => $type === AccountType::Revenue ? 'operating' : null,
'is_system' => false,
'is_active' => true,
]);
});
}
/**
* @param list<array<string, mixed>> $lines
* @return list<array{account_id: int, allocation_type: string, percentage_bp: ?int, fixed_amount: ?int, label: ?string}>
*/
private function normaliseLines(array $lines): array
{
$out = [];
foreach ($lines as $line) {
$type = AllocationType::tryFrom($line['allocation_type'] ?? '');
$accountId = (int) ($line['account_id'] ?? 0);
if (! $type || $accountId <= 0) {
continue;
}
$out[] = [
'account_id' => $accountId,
'allocation_type' => $type->value,
'percentage_bp' => $type === AllocationType::Percentage ? max(0, (int) ($line['percentage_bp'] ?? 0)) : null,
'fixed_amount' => $type === AllocationType::Fixed ? max(0, (int) ($line['fixed_amount'] ?? 0)) : null,
'label' => ($line['label'] ?? null) ?: null,
];
}
return $out;
}
private function assertScopeIsValid(int $academyId, RevenueSource $source, ?string $scopeType, ?int $scopeId): void
{
if ($scopeType === null && $scopeId === null) {
return;
}
if ($scopeType === null || $scopeId === null) {
throw new DomainException('تخصيص القاعدة لصنف بعينه يحتاج الصنف ونوعه معاً');
}
if ($scopeType !== $source->scopeModel()) {
throw new DomainException('هذا المصدر لا يقبل التخصيص لصنف بعينه');
}
// The item has to be this academy's. Without this, an id from another
// tenant saves a rule that can never match anything — a rule the
// accountant wrote, sees in the list, and would reasonably believe is
// in force.
$exists = $scopeType::withoutGlobalScopes()
->where('academy_id', $academyId)
->whereKey($scopeId)
->exists();
if (! $exists) {
throw new DomainException('الصنف المطلوب تخصيص القاعدة له غير موجود في هذه الأكاديمية');
}
}
/** @param list<array<string, mixed>> $lines */
private function assertLinesAreCoherent(array $lines): void
{
if ($lines === []) {
throw new DomainException('أضف سطر توزيع واحداً على الأقل — لا يمكن ترك إيراد بلا حساب');
}
$remainders = 0;
$percentageTotal = 0;
$hasFixed = false;
foreach ($lines as $line) {
switch (AllocationType::from($line['allocation_type'])) {
case AllocationType::Remainder:
$remainders++;
break;
case AllocationType::Percentage:
if ($line['percentage_bp'] <= 0) {
throw new DomainException('النسبة يجب أن تكون أكبر من صفر');
}
$percentageTotal += $line['percentage_bp'];
break;
case AllocationType::Fixed:
if ($line['fixed_amount'] <= 0) {
throw new DomainException('المبلغ الثابت يجب أن يكون أكبر من صفر');
}
$hasFixed = true;
break;
}
}
if ($remainders > 1) {
throw new DomainException('سطر "الباقي" لا يتكرر — لا يمكن أن يذهب المتبقي إلى حسابين');
}
if ($percentageTotal > 10000) {
throw new DomainException(
'مجموع النسب ' . number_format($percentageTotal / 100, 2) . '% — لا يجوز أن يتجاوز 100%'
);
}
if ($remainders === 1) {
return;
}
if ($hasFixed) {
throw new DomainException('قاعدة فيها مبلغ ثابت تحتاج سطر "الباقي" ليستوعب ما تبقى من المبلغ');
}
if ($percentageTotal !== 10000) {
throw new DomainException(
'مجموع النسب ' . number_format($percentageTotal / 100, 2) . '% — أكمله إلى 100% أو أضف سطر "الباقي"'
);
}
}
/** @param list<array<string, mixed>> $lines */
private function assertAccountsArePostable(int $academyId, array $lines): void
{
$ids = array_values(array_unique(array_column($lines, 'account_id')));
$accounts = FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->whereIn('id', $ids)
->get()
->keyBy('id');
foreach ($ids as $id) {
$account = $accounts->get($id);
if (! $account) {
throw new DomainException('حساب غير موجود في شجرة حسابات هذه الأكاديمية');
}
if (! $account->is_active) {
throw new DomainException("الحساب {$account->code} غير مفعَّل");
}
if (! in_array($account->type, self::POSTABLE_TYPES, true)) {
throw new DomainException(
"الحساب {$account->code} ({$account->name_ar}) من نوع أصول أو مصروفات — " .
'الإيراد المحصَّل يُرحَّل لحساب إيراد أو التزام أو حقوق ملكية فقط'
);
}
}
}
private function assertTaxIsCoherent(int $academyId, TaxMode $mode, int $rate, ?int $taxAccountId): void
{
if ($mode === TaxMode::None) {
return;
}
if ($rate <= 0 || $rate > 10000) {
throw new DomainException('نسبة الضريبة يجب أن تكون بين 0.01% و100%');
}
if (! $taxAccountId) {
throw new DomainException('اختر حساب الضريبة — الضريبة المحصَّلة التزام على الأكاديمية ولا يصح تركها بلا حساب');
}
$account = FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('id', $taxAccountId)
->first();
if (! $account) {
throw new DomainException('حساب الضريبة غير موجود في شجرة الحسابات');
}
// The one rule in this file that is accounting rather than arithmetic.
// VAT collected from a customer is money held on behalf of the Tax
// Authority; booking it to a revenue account reports it as the
// academy's own income and leaves the debt it represents invisible.
if ($account->type !== AccountType::Liability) {
throw new DomainException(
"حساب الضريبة يجب أن يكون حساب التزامات — {$account->code} ({$account->name_ar}) ليس كذلك. " .
'الضريبة المحصَّلة مستحقة لمصلحة الضرائب وليست إيراداً للأكاديمية'
);
}
}
}
<?php
namespace App\Domain\Financial\Support;
use App\Domain\Shared\Exceptions\DomainException;
/**
* The full result of routing one collected amount: what was revenue, what was
* tax, and the credit side of the entry it becomes.
*
* The class exists mainly to hold one invariant in one place — the credits sum
* to exactly the money that came in. An allocation that does not balance is not
* a rounding annoyance, it is a ledger that no longer adds up, and it must fail
* at the till rather than quietly a quarter later.
*/
final class RevenueAllocation
{
/**
* @param list<RevenuePosting> $postings
* @param list<string> $warnings things the operator should be told about
* but which do not make the entry wrong
*/
public function __construct(
public readonly int $gross,
public readonly int $net,
public readonly int $tax,
public readonly array $postings,
public readonly array $warnings = [],
) {}
public static function empty(): self
{
return new self(0, 0, 0, []);
}
public function totalPosted(): int
{
return array_sum(array_map(fn (RevenuePosting $p) => $p->amount, $this->postings));
}
public function revenuePostings(): array
{
return array_values(array_filter($this->postings, fn (RevenuePosting $p) => ! $p->isTax()));
}
public function taxPostings(): array
{
return array_values(array_filter($this->postings, fn (RevenuePosting $p) => $p->isTax()));
}
public function isBalanced(): bool
{
return $this->totalPosted() === $this->gross;
}
/**
* Refuse to hand back an entry whose sides disagree.
*
* Called by every writer before it touches `transactions`, because a
* transaction row is immutable: a wrong one can only ever be reversed, not
* corrected, and it will be read as fact by every report in between.
*/
public function assertBalanced(): self
{
if (! $this->isBalanced()) {
throw new DomainException(
'خطأ في توزيع الإيراد: مجموع التوزيع (' . format_money($this->totalPosted()) .
') لا يساوي المبلغ المحصَّل (' . format_money($this->gross) . ') — تمت المراجعة قبل الترحيل ولم يُسجَّل أي قيد'
);
}
return $this;
}
/** Merge two allocations — a payment covering several invoice lines. */
public function merge(self $other): self
{
return new self(
$this->gross + $other->gross,
$this->net + $other->net,
$this->tax + $other->tax,
[...$this->postings, ...$other->postings],
array_values(array_unique([...$this->warnings, ...$other->warnings])),
);
}
/**
* Collapse postings that land in the same account, keeping every rule that
* contributed. One payment should not produce nine ledger rows against the
* same account just because nine invoice lines fed it.
*/
public function collapsed(): self
{
/** @var array<string, RevenuePosting> $byAccount */
$byAccount = [];
$labels = [];
foreach ($this->postings as $posting) {
$key = $posting->kind . ':' . $posting->account->id;
if (! isset($byAccount[$key])) {
$byAccount[$key] = $posting;
$labels[$key] = $posting->ruleLabels;
continue;
}
$byAccount[$key] = $byAccount[$key]->withAmount($byAccount[$key]->amount + $posting->amount);
$labels[$key] = array_values(array_unique([...$labels[$key], ...$posting->ruleLabels]));
}
$merged = [];
foreach ($byAccount as $key => $posting) {
$merged[] = new RevenuePosting(
$posting->account,
$posting->amount,
$posting->kind,
$labels[$key],
$posting->mappingId,
// A collapsed posting no longer belongs to a single rule line.
count($labels[$key]) > 1 ? null : $posting->mappingLineId,
$posting->sourceKey,
);
}
return new self($this->gross, $this->net, $this->tax, $merged, $this->warnings);
}
public function withWarning(string $warning): self
{
return new self(
$this->gross,
$this->net,
$this->tax,
$this->postings,
array_values(array_unique([...$this->warnings, $warning])),
);
}
}
<?php
namespace App\Domain\Financial\Support;
use App\Domain\Financial\Models\FinancialAccount;
/**
* One credit in the entry a collection produces: an account and the piasters
* that land in it, with the rule that put them there.
*
* The provenance is not decoration. When the finance manager asks why five
* pounds of a subscription ended up in 4010, the answer has to be readable off
* the transaction row itself — not reconstructed from whatever the rules happen
* to say today, months after the rule was edited.
*/
final class RevenuePosting
{
/** @param list<string> $ruleLabels */
public function __construct(
public readonly FinancialAccount $account,
public readonly int $amount,
public readonly string $kind, // 'revenue' | 'tax'
public readonly array $ruleLabels = [],
public readonly ?int $mappingId = null,
public readonly ?int $mappingLineId = null,
public readonly ?string $sourceKey = null,
) {}
public function isTax(): bool
{
return $this->kind === 'tax';
}
public function withAmount(int $amount): self
{
return new self(
$this->account,
$amount,
$this->kind,
$this->ruleLabels,
$this->mappingId,
$this->mappingLineId,
$this->sourceKey,
);
}
/** What gets written into `transactions.metadata` for the audit trail. */
public function metadata(): array
{
return array_filter([
'routing_kind' => $this->kind,
'routing_source' => $this->sourceKey,
'routing_mapping_id' => $this->mappingId,
'routing_line_id' => $this->mappingLineId,
'routing_rules' => $this->ruleLabels ?: null,
], fn ($v) => $v !== null);
}
}
<?php
namespace App\Livewire\Financial;
use App\Domain\Financial\Enums\AccountType;
use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\RevenueMapping;
use App\Domain\Financial\Models\RevenueMappingLine;
use App\Domain\Financial\Services\RevenueRouter;
use App\Domain\Financial\Services\RevenueRoutingService;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Revenue routing — the screen that answers "which account does this money go
* into", for every kind of money the system takes.
*
* Everything an accountant can say here is said in one place: the destination
* accounts and their shares, the tax treatment, and a preview that runs the
* rule being typed through the very same engine that will post tonight's
* collections. The preview matters more than it looks — a routing rule is
* abstract until you watch a real number split, and a rule nobody can check is
* a rule nobody will trust.
*/
#[Layout('layouts.app')]
#[Title('توجيه الإيرادات')]
class RevenueRouting extends Component
{
/** Which source's rule is open in the editor. */
#[Locked]
public ?string $editingSource = null;
/** Set only by chooseOverride()/editRule(); never from the browser. */
#[Locked]
public ?string $scopeType = null;
#[Locked]
public ?int $scopeId = null;
public string $scopeLabel = '';
public bool $isActive = true;
public string $taxMode = 'none';
public string $taxRateDisplay = '14';
public ?int $taxAccountId = null;
public string $notes = '';
/** @var list<array{account_id: ?int, allocation_type: string, value: string, label: string}> */
public array $lines = [];
public string $previewAmount = '1000';
public bool $showAccountForm = false;
public string $newAccountCode = '';
public string $newAccountName = '';
public string $newAccountType = 'revenue';
public bool $showOverridePicker = false;
public string $overrideSearch = '';
public function mount(): void
{
$this->authorize('revenue_routing.view');
// `?source=product` opens that rule directly, so a link in a message
// lands on the rule being argued about rather than on a list of nine.
// editRule() re-validates the key; an unknown one simply opens nothing.
if ($source = request()->query('source')) {
$this->editRule((string) $source);
}
}
/* ---------------------------------------------------------------- edit */
/**
* Open a rule for editing.
*
* The scope class is derived from the source rather than accepted from the
* caller: this method is reachable from the browser, and a class name that
* arrives over the wire is not something to hand to `::withoutGlobalScopes()`.
* An id is all the caller gets to choose, and only for sources that can be
* narrowed at all.
*/
public function editRule(string $sourceKey, ?int $scopeId = null): void
{
$this->authorize('revenue_routing.view');
$source = RevenueSource::tryFrom($sourceKey);
if (! $source) {
return;
}
$scopeType = $scopeId !== null ? $source->scopeModel() : null;
if ($scopeId !== null && ! $scopeType) {
return;
}
$this->resetValidation();
$this->editingSource = $source->value;
$this->scopeType = $scopeType;
$this->scopeId = $scopeType ? $scopeId : null;
$this->showOverridePicker = false;
$this->showAccountForm = false;
$mapping = $this->findMapping($source, $scopeType, $scopeId);
$this->scopeLabel = $this->labelForScope($scopeType, $scopeId);
$this->isActive = $mapping?->is_active ?? true;
$this->taxMode = $mapping?->tax_mode->value ?? TaxMode::None->value;
$this->taxRateDisplay = $mapping && $mapping->tax_rate_bp > 0
? rtrim(rtrim(number_format($mapping->taxRatePercent(), 2, '.', ''), '0'), '.')
: '14';
$this->taxAccountId = $mapping?->tax_account_id;
$this->notes = (string) ($mapping?->notes ?? '');
$this->lines = $mapping
? $mapping->lines->map(fn (RevenueMappingLine $line) => [
'account_id' => $line->account_id,
'allocation_type' => $line->allocation_type->value,
'value' => match ($line->allocation_type) {
AllocationType::Percentage => rtrim(rtrim(number_format($line->percent(), 2, '.', ''), '0'), '.'),
AllocationType::Fixed => number_format($line->fixed_amount / 100, 2, '.', ''),
AllocationType::Remainder => '',
},
'label' => (string) $line->label,
])->values()->all()
: [];
// An override starts as a copy of the default it is overriding, so the
// accountant edits a working rule instead of building one from nothing.
if ($this->lines === []) {
$this->lines = $this->linesFromDefault($source);
}
}
public function closeEditor(): void
{
$this->editingSource = null;
$this->scopeType = null;
$this->scopeId = null;
$this->scopeLabel = '';
$this->lines = [];
$this->showOverridePicker = false;
$this->showAccountForm = false;
$this->resetValidation();
}
public function addLine(): void
{
$this->lines[] = [
'account_id' => null,
'allocation_type' => AllocationType::Percentage->value,
'value' => '',
'label' => '',
];
}
public function removeLine(int $index): void
{
unset($this->lines[$index]);
$this->lines = array_values($this->lines);
}
public function save(RevenueRoutingService $service): void
{
$this->authorize('revenue_routing.manage');
$source = RevenueSource::tryFrom((string) $this->editingSource);
if (! $source) {
session()->flash('error', __('مصدر الإيراد غير معروف'));
return;
}
try {
$service->save(
$this->academyId(),
$source,
[
'is_active' => $this->isActive,
'label' => $source->label(),
'tax_mode' => $this->taxMode,
'tax_rate_bp' => $this->taxRateBasisPoints(),
'tax_account_id' => $this->taxMode === TaxMode::None->value ? null : $this->taxAccountId,
'notes' => $this->notes ?: null,
],
$this->linesForService(),
auth()->id(),
$this->scopeType,
$this->scopeId,
);
session()->flash('success', __('تم حفظ قاعدة التوجيه — تسري على كل تحصيل من الآن'));
$this->closeEditor();
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function deleteOverride(string $sourceKey, int $scopeId, RevenueRoutingService $service): void
{
$this->authorize('revenue_routing.manage');
$source = RevenueSource::tryFrom($sourceKey);
$scopeType = $source?->scopeModel();
if (! $source || ! $scopeType) {
return;
}
$mapping = $this->findMapping($source, $scopeType, $scopeId);
if (! $mapping) {
return;
}
try {
$service->deleteOverride($mapping);
session()->flash('success', __('تم حذف التخصيص — عاد الصنف للقاعدة الافتراضية'));
$this->closeEditor();
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
/* ----------------------------------------------------------- overrides */
public function openOverridePicker(): void
{
$this->authorize('revenue_routing.manage');
$this->overrideSearch = '';
$this->showOverridePicker = true;
}
public function chooseOverride(int $id): void
{
$this->authorize('revenue_routing.manage');
$source = RevenueSource::tryFrom((string) $this->editingSource);
$model = $source?->scopeModel();
if (! $source || ! $model) {
return;
}
// Confirmed against this academy before it is accepted: an id typed
// into the request must not be able to attach a rule to another
// tenant's product.
$exists = $model::withoutGlobalScopes()
->where('academy_id', $this->academyId())
->whereKey($id)
->exists();
if (! $exists) {
return;
}
$this->editRule($source->value, $id);
}
/* ------------------------------------------------------- chart account */
public function createAccount(RevenueRoutingService $service): void
{
$this->authorize('revenue_routing.manage');
$this->validate([
'newAccountCode' => 'required|string|max:20',
'newAccountName' => 'required|string|max:120',
'newAccountType' => 'required|in:revenue,liability,equity',
], [
'newAccountCode.required' => 'رقم الحساب مطلوب',
'newAccountName.required' => 'اسم الحساب مطلوب',
'newAccountType.in' => 'نوع الحساب غير مسموح هنا',
]);
try {
$account = $service->createAccount($this->academyId(), [
'code' => $this->newAccountCode,
'name_ar' => $this->newAccountName,
'name' => $this->newAccountName,
'type' => $this->newAccountType,
]);
$this->showAccountForm = false;
$this->newAccountCode = '';
$this->newAccountName = '';
session()->flash('success', __('تم إنشاء الحساب ') . $account->code . ' — ' . $account->name_ar);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
/* ------------------------------------------------------------- reading */
public function render(RevenueRouter $router)
{
$academyId = $this->academyId();
$accounts = $this->postableAccounts($academyId);
return view('livewire.financial.revenue-routing', [
'sources' => $this->sourceRows($academyId),
'accounts' => $accounts,
'taxAccounts' => $accounts->where('type', AccountType::Liability)->values(),
'preview' => $this->preview($router, $academyId),
'overrideCandidates' => $this->overrideCandidates($academyId),
'canManage' => auth()->user()?->can('revenue_routing.manage') ?? false,
]);
}
/**
* One row per revenue source: the rule, what it currently does, how many
* items override it, and how much money has actually come through it.
*
* The money column is not decoration either. A rule with no history behind
* it is a rule nobody needs to argue about; a rule sitting on two million
* pounds is where the conversation starts.
*/
private function sourceRows(int $academyId): array
{
$mappings = RevenueMapping::withoutGlobalScopes()
->with(['lines.account', 'taxAccount'])
->where('academy_id', $academyId)
->get()
->groupBy(fn (RevenueMapping $m) => $m->source_key->value);
$volume = $this->volumeBySource($academyId);
$rows = [];
foreach (RevenueSource::catalogue() as $source) {
$all = $mappings->get($source->value, collect());
$overrides = $all->filter(fn (RevenueMapping $m) => $m->scope_type !== null)->values();
$rows[] = [
'source' => $source,
'mapping' => $all->firstWhere('scope_type', null),
'overrides' => $overrides,
'scopeNames' => $this->scopeNames($source, $overrides),
'volume' => $volume[$source->value] ?? ['count' => 0, 'total' => 0],
];
}
return $rows;
}
/**
* Names for the items an override is written against, read in one query
* and without the global scopes.
*
* A morphTo in the view would resolve each row separately and through the
* branch scope, so a product from another branch would render as a rule
* against nothing — which reads as a bug in the rule rather than in the
* lookup.
*
* @return array<int, string>
*/
private function scopeNames(RevenueSource $source, $overrides): array
{
$model = $source->scopeModel();
if (! $model || $overrides->isEmpty()) {
return [];
}
return $model::withoutGlobalScopes()
->where('academy_id', $this->academyId())
->whereIn('id', $overrides->pluck('scope_id'))
->get(['id', 'name', 'name_ar'])
->mapWithKeys(fn ($item) => [$item->id => (string) ($item->name_ar ?: $item->name)])
->all();
}
/**
* What each source has actually collected, read off the invoice lines
* themselves rather than the ledger — the ledger only started recording
* revenue accounts after the account resolver was fixed, so it is silent
* about everything sold before that.
*
* @return array<string, array{count: int, total: int}>
*/
private function volumeBySource(int $academyId): array
{
$rows = DB::table('invoice_items')
->where('academy_id', $academyId)
->selectRaw('itemable_type, count(*) as line_count, coalesce(sum(total_amount), 0) as total')
->groupBy('itemable_type')
->get();
$out = [];
foreach ($rows as $row) {
$key = RevenueSource::forItemable($row->itemable_type)->value;
$out[$key] ??= ['count' => 0, 'total' => 0];
$out[$key]['count'] += (int) $row->line_count;
$out[$key]['total'] += (int) $row->total;
}
$fee = DB::table('invoices')
->where('academy_id', $academyId)
->whereNull('deleted_at')
->where('service_fee_amount', '>', 0)
->selectRaw('count(*) as line_count, coalesce(sum(service_fee_amount), 0) as total')
->first();
if ($fee && (int) $fee->line_count > 0) {
$out[RevenueSource::ServiceFee->value] = [
'count' => (int) $fee->line_count,
'total' => (int) $fee->total,
];
}
return $out;
}
/**
* The entry the rule currently being edited would produce for the amount in
* the preview box — built from the unsaved form state, run through the real
* allocation engine.
*/
private function preview(RevenueRouter $router, int $academyId): ?array
{
$source = RevenueSource::tryFrom((string) $this->editingSource);
if (! $source) {
return null;
}
$amount = (int) round(((float) str_replace(',', '', $this->previewAmount)) * 100);
if ($amount <= 0) {
return null;
}
$lines = $this->linesForService();
if ($lines === []) {
return null;
}
$accounts = FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->whereIn('id', array_column($lines, 'account_id'))
->get()
->keyBy('id');
// A line pointing at nothing cannot be previewed; the save path will
// reject it with a message that explains why.
foreach ($lines as $line) {
if (! $accounts->has($line['account_id'])) {
return null;
}
}
$mapping = new RevenueMapping([
'academy_id' => $academyId,
'source_key' => $source->value,
'is_active' => $this->isActive,
'tax_mode' => $this->taxMode,
'tax_rate_bp' => $this->taxRateBasisPoints(),
'tax_account_id' => $this->taxAccountId,
]);
$mapping->setRelation('lines', collect($lines)->map(function (array $line, int $i) use ($accounts, $academyId) {
$model = new RevenueMappingLine([
'academy_id' => $academyId,
'account_id' => $line['account_id'],
'allocation_type' => $line['allocation_type'],
'percentage_bp' => $line['percentage_bp'],
'fixed_amount' => $line['fixed_amount'],
'label' => $line['label'],
'sort_order' => $i,
]);
$model->setRelation('account', $accounts->get($line['account_id']));
return $model;
}));
$mapping->setRelation('taxAccount', $this->taxMode === TaxMode::None->value
? null
: FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->whereKey($this->taxAccountId)
->first());
try {
// Exclusive rules post the tax the invoice charged; in a preview
// there is no invoice, so show what a correctly-taxed invoice
// would have added on top of this amount.
$chargedTax = $this->taxMode === TaxMode::Exclusive->value
? intdiv($amount * $this->taxRateBasisPoints(), 10000)
: null;
$allocation = $router->allocateWith($academyId, $mapping, $source, $amount, $chargedTax);
} catch (DomainException $e) {
return ['error' => $e->getMessage()];
}
return [
'allocation' => $allocation->collapsed(),
'balanced' => $allocation->isBalanced(),
];
}
private function overrideCandidates(int $academyId)
{
$source = RevenueSource::tryFrom((string) $this->editingSource);
$model = $source?->scopeModel();
if (! $this->showOverridePicker || ! $model) {
return collect();
}
$taken = RevenueMapping::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('source_key', $source->value)
->where('scope_type', $model)
->pluck('scope_id');
$query = $model::withoutGlobalScopes()
->where('academy_id', $academyId)
->whereNotIn('id', $taken);
if (trim($this->overrideSearch) !== '') {
$term = '%' . trim($this->overrideSearch) . '%';
$query->where(fn ($q) => $q->where('name_ar', 'like', $term)->orWhere('name', 'like', $term));
}
return $query->orderBy('name_ar')->limit(25)->get(['id', 'name', 'name_ar']);
}
private function postableAccounts(int $academyId)
{
return FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('is_active', true)
->whereIn('type', [
AccountType::Revenue->value,
AccountType::Liability->value,
AccountType::Equity->value,
])
->orderBy('code')
->get(['id', 'code', 'name', 'name_ar', 'type']);
}
/* --------------------------------------------------------------- utils */
private function findMapping(RevenueSource $source, ?string $scopeType, ?int $scopeId): ?RevenueMapping
{
return RevenueMapping::withoutGlobalScopes()
->with(['lines.account', 'taxAccount'])
->where('academy_id', $this->academyId())
->where('source_key', $source->value)
->when(
$scopeType === null,
fn ($q) => $q->whereNull('scope_type'),
fn ($q) => $q->where('scope_type', $scopeType)->where('scope_id', $scopeId),
)
->first();
}
/** @return list<array{account_id: ?int, allocation_type: string, value: string, label: string}> */
private function linesFromDefault(RevenueSource $source): array
{
$default = $this->findMapping($source, null, null);
if ($default && $default->lines->isNotEmpty()) {
return $default->lines->map(fn (RevenueMappingLine $line) => [
'account_id' => $line->account_id,
'allocation_type' => $line->allocation_type->value,
'value' => match ($line->allocation_type) {
AllocationType::Percentage => rtrim(rtrim(number_format($line->percent(), 2, '.', ''), '0'), '.'),
AllocationType::Fixed => number_format($line->fixed_amount / 100, 2, '.', ''),
AllocationType::Remainder => '',
},
'label' => (string) $line->label,
])->values()->all();
}
$account = FinancialAccount::withoutGlobalScopes()
->where('academy_id', $this->academyId())
->where('code', $source->legacyAccountCode())
->first();
return [[
'account_id' => $account?->id,
'allocation_type' => AllocationType::Remainder->value,
'value' => '',
'label' => '',
]];
}
/**
* Form state converted to what the service takes: percentages as basis
* points, money as piasters. Nothing here decides anything — every rule
* about what is allowed lives in the service, where the console and the
* queue reach it too.
*
* @return list<array{account_id: int, allocation_type: string, percentage_bp: ?int, fixed_amount: ?int, label: ?string}>
*/
private function linesForService(): array
{
$out = [];
foreach ($this->lines as $line) {
$type = AllocationType::tryFrom($line['allocation_type'] ?? '');
$accountId = (int) ($line['account_id'] ?? 0);
if (! $type || $accountId <= 0) {
continue;
}
$value = (float) str_replace(',', '', (string) ($line['value'] ?? ''));
$out[] = [
'account_id' => $accountId,
'allocation_type' => $type->value,
'percentage_bp' => $type === AllocationType::Percentage ? (int) round($value * 100) : null,
'fixed_amount' => $type === AllocationType::Fixed ? (int) round($value * 100) : null,
'label' => ($line['label'] ?? null) ?: null,
];
}
return $out;
}
private function taxRateBasisPoints(): int
{
return (int) round(((float) str_replace(',', '', $this->taxRateDisplay)) * 100);
}
private function labelForScope(?string $scopeType, ?int $scopeId): string
{
if (! $scopeType || ! $scopeId) {
return '';
}
$model = $scopeType::withoutGlobalScopes()
->where('academy_id', $this->academyId())
->whereKey($scopeId)
->first();
return (string) ($model?->name_ar ?: $model?->name ?: '');
}
private function academyId(): int
{
return (int) app('current_academy')->id;
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Revenue routing — which ledger account every collected piastre lands in.
*
* Until now that answer was a `match` statement inside LedgerAccountResolver:
* five itemable classes hardcoded to five revenue codes, no tax anywhere, and
* no way for the academy's own accountant to say "no, half of that goes to
* 4010" without a deploy. Every ERP that survives an audit has this screen —
* SAP calls it revenue account determination, Dynamics calls it the general
* posting setup, Odoo puts an income account on the product category. This is
* ours.
*
* Two tables:
*
* revenue_mappings one rule per revenue source, optionally narrowed to
* a single product / programme / event, carrying the
* tax treatment for that source.
* revenue_mapping_lines how the net splits: a flat number of piasters, a
* percentage, or "whatever is left".
*
* The seed below reproduces today's behaviour exactly — same account for the
* same itemable, no tax anywhere — so this migration changes where nothing
* lands. What it changes is that the routing is now data an accountant can
* edit, instead of a `match` an engineer has to redeploy.
*/
return new class extends Migration
{
/**
* source key => [Arabic label, default account code, active on install].
*
* `service_fee` ships inactive on purpose. The platform fee is not an
* invoice line: today it rides along inside the proportional split of the
* lines that are there, and carving it out to its own account on the night
* of a deploy would move money between accounts in every client's books
* without anyone asking for it. Inactive means "keep folding it in"; an
* accountant who wants it separated turns it on and says where.
*/
private const SOURCES = [
['subscription', 'اشتراك تدريبي', '4000', true],
['training_program', 'اشتراك برنامج مرتبط', '4000', true],
['product', 'بيع منتج', '4020', true],
['kit', 'بيع طقم', '4020', true],
['event', 'رسوم إيفنت أو بطولة', '4050', true],
['space_reservation', 'حجز ملعب أو مساحة', '4030', true],
['external', 'إيراد خارجي', '4060', true],
['other', 'إيرادات أخرى', '4060', true],
['service_fee', 'رسوم خدمة المنصة', '4060', false],
];
/**
* VAT collected from a customer is money held for the Tax Authority, so it
* is a liability — never a slice of revenue. The chart had nowhere to put
* it, which is why the tax question could not even be asked before now.
*/
private const TAX_ACCOUNTS = [
['code' => '2030', 'name' => 'VAT Payable', 'name_ar' => 'ضريبة القيمة المضافة المستحقة', 'type' => 'liability', 'category' => 'current_liability'],
['code' => '2040', 'name' => 'Withholding Tax Payable', 'name_ar' => 'ضرائب خصم وإضافة مستحقة', 'type' => 'liability', 'category' => 'current_liability'],
];
public function up(): void
{
if (! Schema::hasTable('financial_accounts') || ! Schema::hasTable('academies')) {
return;
}
$this->createMappings();
$this->createLines();
$this->seedTaxAccounts();
$this->seedDefaults();
}
private function createMappings(): void
{
if (Schema::hasTable('revenue_mappings')) {
return;
}
Schema::create('revenue_mappings', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->string('source_key', 40);
// A rule narrowed to one product / programme / event. Null is the
// academy-wide default for the source.
$table->string('scope_type', 255)->nullable();
$table->unsignedBigInteger('scope_id')->nullable();
$table->string('label', 160)->nullable();
$table->boolean('is_active')->default(true);
$table->string('tax_mode', 20)->default('none');
$table->integer('tax_rate_bp')->default(0);
$table->foreignId('tax_account_id')->nullable()->constrained('financial_accounts')->nullOnDelete();
$table->text('notes')->nullable();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['academy_id', 'source_key']);
$table->index(['scope_type', 'scope_id']);
});
if (DB::getDriverName() === 'pgsql') {
DB::statement("ALTER TABLE revenue_mappings ADD CONSTRAINT revenue_mappings_tax_mode_check CHECK (tax_mode IN ('none', 'inclusive', 'exclusive'))");
DB::statement('ALTER TABLE revenue_mappings ADD CONSTRAINT revenue_mappings_tax_rate_check CHECK (tax_rate_bp >= 0 AND tax_rate_bp <= 10000)');
// A scope is a pair or it is nothing; half of one is a rule that
// matches everything or nothing depending on which half survived.
DB::statement('ALTER TABLE revenue_mappings ADD CONSTRAINT revenue_mappings_scope_pair_check CHECK ((scope_type IS NULL) = (scope_id IS NULL))');
// A tax rate with no account to hold it would post nowhere and
// silently unbalance the entry.
DB::statement("ALTER TABLE revenue_mappings ADD CONSTRAINT revenue_mappings_tax_account_check CHECK (tax_mode = 'none' OR tax_account_id IS NOT NULL)");
}
// Two partial uniques rather than one composite: in SQL, NULL is not
// equal to NULL, so a plain UNIQUE(academy_id, source_key, scope_type,
// scope_id) would happily accept a hundred academy-wide defaults for
// the same source and the resolver would pick whichever came back first.
DB::statement('CREATE UNIQUE INDEX revenue_mappings_default_unique ON revenue_mappings (academy_id, source_key) WHERE scope_type IS NULL');
DB::statement('CREATE UNIQUE INDEX revenue_mappings_scoped_unique ON revenue_mappings (academy_id, source_key, scope_type, scope_id) WHERE scope_type IS NOT NULL');
}
private function createLines(): void
{
if (Schema::hasTable('revenue_mapping_lines')) {
return;
}
Schema::create('revenue_mapping_lines', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->foreignId('revenue_mapping_id')->constrained('revenue_mappings')->cascadeOnDelete();
$table->foreignId('account_id')->constrained('financial_accounts');
$table->string('allocation_type', 20);
$table->integer('percentage_bp')->nullable();
$table->bigInteger('fixed_amount')->nullable();
$table->string('label', 160)->nullable();
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['revenue_mapping_id', 'sort_order']);
$table->index('account_id');
});
if (DB::getDriverName() === 'pgsql') {
DB::statement("ALTER TABLE revenue_mapping_lines ADD CONSTRAINT revenue_mapping_lines_type_check CHECK (allocation_type IN ('percentage', 'fixed', 'remainder'))");
DB::statement('ALTER TABLE revenue_mapping_lines ADD CONSTRAINT revenue_mapping_lines_percentage_check CHECK (percentage_bp IS NULL OR (percentage_bp >= 0 AND percentage_bp <= 10000))');
DB::statement('ALTER TABLE revenue_mapping_lines ADD CONSTRAINT revenue_mapping_lines_fixed_check CHECK (fixed_amount IS NULL OR fixed_amount >= 0)');
// Each allocation type carries exactly the value it needs and no
// other, so a line can never be read two ways.
DB::statement(<<<'SQL'
ALTER TABLE revenue_mapping_lines ADD CONSTRAINT revenue_mapping_lines_shape_check CHECK (
(allocation_type = 'percentage' AND percentage_bp IS NOT NULL AND fixed_amount IS NULL)
OR (allocation_type = 'fixed' AND fixed_amount IS NOT NULL AND percentage_bp IS NULL)
OR (allocation_type = 'remainder' AND fixed_amount IS NULL AND percentage_bp IS NULL)
)
SQL);
// At most one "whatever is left" per rule, or the leftover has two
// homes and the entry stops balancing.
DB::statement("CREATE UNIQUE INDEX revenue_mapping_lines_one_remainder ON revenue_mapping_lines (revenue_mapping_id) WHERE allocation_type = 'remainder'");
}
}
private function seedTaxAccounts(): void
{
$academyIds = DB::table('academies')->pluck('id');
$now = now();
foreach ($academyIds as $academyId) {
foreach (self::TAX_ACCOUNTS as $account) {
$exists = DB::table('financial_accounts')
->where('academy_id', $academyId)
->where('code', $account['code'])
->exists();
if ($exists) {
continue;
}
DB::table('financial_accounts')->insert($account + [
'uuid' => (string) Str::uuid(),
'academy_id' => $academyId,
'is_system' => true,
'is_active' => true,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
/**
* One default rule per academy per source, each a single "everything that
* is left" line pointing at the account the old `match` would have chosen.
*
* Written with firstOrCreate semantics so a container restarting for the
* fifth time neither duplicates a rule nor overwrites one an accountant has
* since edited.
*/
private function seedDefaults(): void
{
$academyIds = DB::table('academies')->pluck('id');
$now = now();
foreach ($academyIds as $academyId) {
$accounts = DB::table('financial_accounts')
->where('academy_id', $academyId)
->pluck('id', 'code');
foreach (self::SOURCES as [$key, $label, $code, $active]) {
$accountId = $accounts[$code] ?? null;
// No chart for this academy yet: the accounts migration runs
// before this one, so this only happens on a tenant whose chart
// was deliberately pruned. Skipping is right — the resolver
// falls back to the legacy behaviour and nothing breaks.
if (! $accountId) {
continue;
}
$existing = DB::table('revenue_mappings')
->where('academy_id', $academyId)
->where('source_key', $key)
->whereNull('scope_type')
->value('id');
if ($existing) {
continue;
}
$mappingId = DB::table('revenue_mappings')->insertGetId([
'uuid' => (string) Str::uuid(),
'academy_id' => $academyId,
'source_key' => $key,
'scope_type' => null,
'scope_id' => null,
'label' => $label,
'is_active' => $active,
'tax_mode' => 'none',
'tax_rate_bp' => 0,
'tax_account_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('revenue_mapping_lines')->insert([
'uuid' => (string) Str::uuid(),
'academy_id' => $academyId,
'revenue_mapping_id' => $mappingId,
'account_id' => $accountId,
'allocation_type' => 'remainder',
'percentage_bp' => null,
'fixed_amount' => null,
'label' => null,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
public function down(): void
{
Schema::dropIfExists('revenue_mapping_lines');
Schema::dropIfExists('revenue_mappings');
// The two tax accounts stay. Once a single transaction has credited
// 2030 it cannot be removed without orphaning the ledger, and a rolled
// back migration is not a reason to lose a VAT liability.
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Permissions for the revenue routing screen.
*
* Split in two on purpose: an auditor or a reviewer may need to read the rules
* to explain a figure without being able to move next month's money. Both go to
* the four roles that answer for the books — deliberately not to a branch
* manager, since routing is an academy-wide setting and a branch-scoped grant
* would be a rule that only half applies. PermissionSeeder grants the same four
* on a fresh install; the two must not drift.
*
* Delivered by migration rather than by PermissionSeeder because db:seed only
* runs when RUN_SEED_ON_FIRST_DEPLOY is true, so a client installed outside the
* one-click template would never receive it. Idempotent throughout.
*/
return new class extends Migration
{
private const PERMISSIONS = [
'revenue_routing.view' => [
'action' => 'view',
'description' => 'View how collected revenue is routed to ledger accounts',
'description_ar' => 'عرض توجيه الإيرادات على الحسابات',
'roles' => ['super_admin', 'academy_owner', 'academy_admin', 'accountant'],
],
'revenue_routing.manage' => [
'action' => 'manage',
'description' => 'Change revenue routing rules and tax treatment',
'description_ar' => 'تعديل قواعد توجيه الإيرادات والمعالجة الضريبية',
'roles' => ['super_admin', 'academy_owner', 'academy_admin', 'accountant'],
],
];
public function up(): void
{
if (! Schema::hasTable('permissions') || ! Schema::hasTable('permission_role') || ! Schema::hasTable('roles')) {
return;
}
foreach (self::PERMISSIONS as $name => $spec) {
$permissionId = DB::table('permissions')->where('name', $name)->value('id');
if (! $permissionId) {
$permissionId = DB::table('permissions')->insertGetId([
'name' => $name,
'module' => 'revenue_routing',
'action' => $spec['action'],
'description' => $spec['description'],
'description_ar' => $spec['description_ar'],
'created_at' => now(),
]);
}
// Roles are per-academy rows, so this reaches every tenant.
$roleIds = DB::table('roles')->whereIn('slug', $spec['roles'])->pluck('id');
foreach ($roleIds as $roleId) {
$exists = DB::table('permission_role')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->exists();
if (! $exists) {
DB::table('permission_role')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
// Routing is an academy-wide setting; a branch-scoped
// grant would be a rule that only half applies.
'scope' => 'all',
'created_at' => now(),
]);
}
}
}
}
public function down(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
$ids = DB::table('permissions')->whereIn('name', array_keys(self::PERMISSIONS))->pluck('id');
if ($ids->isEmpty()) {
return;
}
if (Schema::hasTable('permission_role')) {
DB::table('permission_role')->whereIn('permission_id', $ids)->delete();
}
DB::table('permissions')->whereIn('id', $ids)->delete();
}
};
......@@ -51,6 +51,9 @@ public function run(): void
}
$this->call(FinancialAccountsSeeder::class);
// After the chart: a routing rule points at an account by id, so the
// accounts have to exist before the rules can be written.
$this->call(RevenueRoutingSeeder::class);
$this->call(RolesAndPermissionsSeeder::class);
$this->call(PermissionSeeder::class);
$this->call(PaymentNotificationTemplateSeeder::class);
......
......@@ -45,6 +45,10 @@ public function run(): void
['code' => '2000', 'name' => 'Accounts Payable', 'name_ar' => 'الدائنون', 'type' => 'liability', 'category' => 'current_liability', 'is_system' => true],
['code' => '2010', 'name' => 'Unearned Revenue', 'name_ar' => 'إيرادات مقدمة', 'type' => 'liability', 'category' => 'current_liability', 'is_system' => true],
['code' => '2020', 'name' => 'Refunds Payable', 'name_ar' => 'مستردات مستحقة', 'type' => 'liability', 'category' => 'current_liability', 'is_system' => true],
// Tax collected from a customer is held for the Authority, not
// earned — it credits a liability, never a revenue account.
['code' => '2030', 'name' => 'VAT Payable', 'name_ar' => 'ضريبة القيمة المضافة المستحقة', 'type' => 'liability', 'category' => 'current_liability', 'is_system' => true],
['code' => '2040', 'name' => 'Withholding Tax Payable', 'name_ar' => 'ضرائب خصم وإضافة مستحقة', 'type' => 'liability', 'category' => 'current_liability', 'is_system' => true],
];
foreach ($accounts as $account) {
......
......@@ -140,6 +140,10 @@ public static function getPermissionsList(): array
// Settling an account writes payments, waives balances and bills
// months nobody billed — the academy's decision, not the desk's.
'settlements.manage',
// Where every collected pound lands in the ledger, and what part of
// it was tax. Viewing explains a figure; managing moves every
// future collection in the academy.
'revenue_routing.view', 'revenue_routing.manage',
// Pricing
'pricing.list', 'pricing.create', 'pricing.update', 'pricing.delete',
......@@ -401,6 +405,7 @@ private function accountantPermissions(): array
// stops it being a one-person act.
'payments.approve_proof',
'settlements.manage',
'revenue_routing.view', 'revenue_routing.manage',
'daily_closing.create', 'daily_closing.view',
'expenses.create', 'expenses.list', 'expenses.view',
'reports.financial', 'reports.view', 'reports.export_pdf', 'reports.export_excel',
......
<?php
namespace Database\Seeders;
use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\RevenueMapping;
use App\Domain\Shared\Models\Academy;
use Illuminate\Database\Seeder;
/**
* A starting routing rule per revenue source, for every academy.
*
* Each one reproduces exactly what the system did before revenue routing
* existed — the account the old hardcoded `match` would have chosen, no tax —
* so installing this changes where nothing lands. What it gives a new tenant is
* a screen that shows the truth about its own books on day one, instead of nine
* rows saying "not configured".
*
* The install migration does the same for tenants that already exist. This
* covers the fresh-database case, where `academies` is still empty while
* migrations run. Both are idempotent, and neither overwrites a rule an
* accountant has since edited.
*/
class RevenueRoutingSeeder extends Seeder
{
/** Sources that ship switched off; see the migration for why. */
private const INACTIVE = [RevenueSource::ServiceFee->value];
public function run(): void
{
$academies = Academy::withoutGlobalScopes()->get();
if ($academies->isEmpty()) {
$this->command?->warn('No academy exists yet — skipping revenue routing seeder.');
return;
}
$created = 0;
foreach ($academies as $academy) {
$accounts = FinancialAccount::withoutGlobalScopes()
->where('academy_id', $academy->id)
->pluck('id', 'code');
foreach (RevenueSource::catalogue() as $source) {
$accountId = $accounts[$source->legacyAccountCode()] ?? null;
if (! $accountId) {
continue;
}
$exists = RevenueMapping::withoutGlobalScopes()
->where('academy_id', $academy->id)
->where('source_key', $source->value)
->whereNull('scope_type')
->exists();
if ($exists) {
continue;
}
$mapping = RevenueMapping::withoutGlobalScopes()->create([
'academy_id' => $academy->id,
'source_key' => $source->value,
'label' => $source->label(),
'is_active' => ! in_array($source->value, self::INACTIVE, true),
'tax_mode' => TaxMode::None->value,
'tax_rate_bp' => 0,
]);
$mapping->lines()->create([
'academy_id' => $academy->id,
'account_id' => $accountId,
'allocation_type' => AllocationType::Remainder->value,
'sort_order' => 0,
]);
$created++;
}
}
$this->command?->info("Seeded {$created} revenue routing rules.");
}
}
......@@ -38,6 +38,50 @@ A payment covering several kinds of item produces one row per revenue account,
split in proportion to the invoice's own lines with `intdiv()` and the remainder
on the last row.
## Revenue Routing — WHICH account, and how much of it was tax
Never hardcode a revenue account and never write a second `match` over itemable
types. `RevenueRouter` is the only thing that decides where collected money
lands; `PaymentService` and `RefundService` both go through it, and anything new
that credits revenue must too.
```php
$allocation = $router->allocatePayment($academyId, $invoice, $amount);
foreach ($allocation->postings as $posting) {
Transaction::create([
'debit_account_id' => $asset->id,
'credit_account_id' => $posting->account->id,
'amount' => $posting->amount,
'metadata' => $posting->metadata(), // which rule sent it here
// …
]);
}
```
The rules live in `revenue_mappings` + `revenue_mapping_lines`, one per revenue
source, optionally narrowed to a single product / programme / event (specific
beats the academy default). Editing them is a screen — `financial.revenue-routing`
— not a deploy.
Non-negotiable:
- **Tax comes out first, and credits a LIABILITY.** VAT collected is owed to the
Tax Authority, not earned. `RevenueRoutingService` refuses a tax account that
is not `liability`. Egypt's standard rate is 14%; some services are 10% or
exempt, which is why it is per-source and not one academy-wide switch.
- **The entry must balance before anything is written.** `RevenueAllocation::assertBalanced()`
throws rather than posting credits that do not sum to the money that arrived.
A wrong `transactions` row can only ever be reversed, never corrected.
- **Order is fixed:** flat amounts off the top, then percentages of the net,
then one `remainder` line takes what is left. At most one remainder per rule
(a partial unique index enforces it).
- **Integer piasters only,** floors everywhere, leftover to the closing line.
- **History does not move.** Rules apply to what is collected after they are
saved; posted transactions keep the rule that produced them in `metadata`.
- An academy that has configured nothing behaves exactly as the system did
before routing existed — the install migration seeds that behaviour as data.
## Transactions Are IMMUTABLE
- Never update a transaction row; corrections are NEW reversing entries.
......
......@@ -58,6 +58,22 @@ the direction.)
### AccountType
`'asset', 'liability', 'equity', 'revenue', 'expense'`
### TaxMode — `revenue_mappings.tax_mode`
`'none', 'inclusive', 'exclusive'`
### AllocationType — `revenue_mapping_lines.allocation_type`
`'percentage', 'fixed', 'remainder'`
### RevenueSource — `revenue_mappings.source_key`
`'subscription', 'training_program', 'product', 'kit', 'event', 'space_reservation', 'service_fee', 'external', 'other'`
Deliberately **not** CHECK-constrained: a source key is added by shipping code
that can produce that kind of money, and a constraint would make every such
addition a schema migration on every tenant. `RevenueSource::forItemable()` is
total — an unrecognised itemable falls to `other` rather than to nothing — so an
unknown key can never leave money unrouted. The key is permanent once shipped:
renaming a case orphans every rule a client wrote against it.
### ParticipantStatus
`'registered', 'active', 'frozen', 'suspended', 'inactive', 'graduated', 'transferred', 'withdrawn', 'blacklisted'`
......
......@@ -51,6 +51,7 @@
['label' => 'إيجارات المنشآت', 'route' => 'expenses.rent.list', 'icon' => 'building-office', 'permission' => 'facilities.update'],
['label' => 'المحافظ', 'route' => 'wallets.list', 'icon' => 'wallet', 'permission' => 'wallets.list'],
['label' => 'جلسات الكاشير', 'route' => 'cash-sessions.list', 'icon' => 'calculator', 'permission' => 'cash_sessions.list'],
['label' => 'توجيه الإيرادات', 'route' => 'financial.revenue-routing', 'icon' => 'adjustments-horizontal', 'permission' => 'revenue_routing.view'],
]],
['section' => 'نقطة البيع', 'items' => [
......
@php
use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\TaxMode;
@endphp
<div>
{{-- ───────────────────────────────────────────── header --}}
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-3 mb-4">
<div>
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('توجيه الإيرادات') }}</h1>
<p class="text-sm text-gray-500 mt-1">
{{ __('كل مبلغ يُحصَّل في النظام، وإلى أي حساب في شجرة الحسابات يذهب.') }}
</p>
</div>
<a href="{{ route('financial.overview') }}" wire:navigate
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 shrink-0">
<svg class="w-4 h-4 rtl:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/></svg>
{{ __('النظرة المالية') }}
</a>
</div>
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-800 text-sm">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{{ session('error') }}</div>
@endif
{{-- ───────────────────────────────────────────── how it works --}}
<div class="mb-5 rounded-xl border border-blue-200 bg-blue-50/60 p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-sm text-blue-900 leading-relaxed">
<p class="font-semibold mb-1">{{ __('كيف يُقرأ التوزيع') }}</p>
<p>
{{ __('لكل مصدر إيراد قاعدة واحدة. القاعدة تخصم الضريبة أولاً — لأن الضريبة المحصَّلة مستحقة لمصلحة الضرائب وليست إيراداً — ثم توزّع الصافي على السطور بالترتيب:') }}
<span class="font-semibold">{{ __('المبالغ الثابتة، ثم النسب، ثم سطر «الباقي» يأخذ ما تبقى.') }}</span>
</p>
<p class="mt-1">
{{ __('يمكن تخصيص قاعدة لصنف بعينه (منتج، برنامج، إيفنت) فتسبق القاعدة الافتراضية له وحده. القيود المُرحَّلة سابقاً لا تتغير — القاعدة تسري على ما يُحصَّل بعد الحفظ.') }}
</p>
</div>
</div>
</div>
{{-- ───────────────────────────────────────────── sources --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs">
<tr>
<th class="px-4 py-3 text-start font-medium">{{ __('مصدر الإيراد') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('يذهب إلى') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الضريبة') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('المحصَّل تاريخياً') }}</th>
<th class="px-4 py-3 text-start font-medium"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($sources as $row)
@php
$source = $row['source'];
$mapping = $row['mapping'];
$inactive = ! $mapping || ! $mapping->is_active;
@endphp
<tr class="hover:bg-gray-50/70 align-top">
<td class="px-4 py-3">
<div class="font-semibold text-gray-800">{{ $source->label() }}</div>
<div class="text-xs text-gray-500 mt-0.5 max-w-md leading-relaxed">{{ $source->description() }}</div>
@if($row['overrides']->isNotEmpty())
<div class="mt-1.5 text-xs text-indigo-700">
{{ __('تخصيصات لأصناف بعينها:') }} {{ $row['overrides']->count() }}
</div>
@endif
</td>
<td class="px-4 py-3">
@if($inactive)
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-gray-100 text-gray-600 text-xs">
{{ __('غير مفعَّلة — يُطبَّق السلوك المدمج') }}
</span>
@else
<div class="flex flex-col gap-1">
@foreach($mapping->lines as $line)
<div class="inline-flex items-center gap-2 text-xs">
<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 font-medium shrink-0" dir="ltr">{{ $line->shareLabel() }}</span>
<svg class="w-3 h-3 text-gray-400 shrink-0 rtl:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
<span class="text-gray-700">
<span class="font-mono text-gray-500" dir="ltr">{{ $line->account?->code }}</span>
{{ $line->account?->name_ar ?: $line->account?->name }}
</span>
</div>
@endforeach
</div>
@endif
</td>
<td class="px-4 py-3">
@if($mapping && $mapping->tax_mode !== TaxMode::None)
<span class="inline-flex items-center px-2 py-1 rounded-md bg-amber-50 text-amber-800 border border-amber-200 text-xs font-medium" dir="ltr">
{{ rtrim(rtrim(number_format($mapping->taxRatePercent(), 2), '0'), '.') }}%
</span>
<div class="text-[11px] text-gray-500 mt-1">{{ $mapping->tax_mode->label() }}</div>
<div class="text-[11px] text-gray-500">
<span class="font-mono" dir="ltr">{{ $mapping->taxAccount?->code }}</span>
{{ $mapping->taxAccount?->name_ar }}
</div>
@else
<span class="text-xs text-gray-400">{{ __('بدون') }}</span>
@endif
</td>
<td class="px-4 py-3">
@if($row['volume']['count'] > 0)
<div class="font-semibold text-gray-800" dir="ltr">{{ format_money($row['volume']['total']) }}</div>
<div class="text-xs text-gray-500">{{ $row['volume']['count'] }} {{ __('بند') }}</div>
@else
<span class="text-xs text-gray-400">{{ __('لا يوجد بعد') }}</span>
@endif
</td>
<td class="px-4 py-3 text-end">
<button type="button" wire:click="editRule('{{ $source->value }}')"
wire:loading.attr="disabled"
class="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-xs font-medium disabled:opacity-50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
{{ $canManage ? __('تعديل') : __('عرض') }}
</button>
</td>
</tr>
@foreach($row['overrides'] as $override)
<tr class="bg-indigo-50/30">
<td class="px-4 py-2 ps-10">
<div class="text-xs text-indigo-900 font-medium">
{{ __('تخصيص:') }}
{{ $row['scopeNames'][$override->scope_id] ?? __('صنف محذوف') }}
</div>
</td>
<td class="px-4 py-2">
<div class="flex flex-col gap-0.5">
@foreach($override->lines as $line)
<div class="text-xs text-gray-700">
<span class="font-medium" dir="ltr">{{ $line->shareLabel() }}</span>
<span class="text-gray-400">→</span>
<span class="font-mono text-gray-500" dir="ltr">{{ $line->account?->code }}</span>
{{ $line->account?->name_ar }}
</div>
@endforeach
</div>
</td>
<td class="px-4 py-2 text-xs text-gray-500">
{{ $override->tax_mode === TaxMode::None ? __('بدون') : rtrim(rtrim(number_format($override->taxRatePercent(), 2), '0'), '.') . '%' }}
</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-end">
<button type="button"
wire:click="editRule('{{ $source->value }}', {{ (int) $override->scope_id }})"
class="text-xs text-indigo-700 hover:underline">{{ __('تعديل') }}</button>
</td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
</div>
{{-- ───────────────────────────────────────────── editor --}}
@if($editingSource)
@php $editingEnum = \App\Domain\Financial\Enums\RevenueSource::from($editingSource); @endphp
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50" wire:key="editor-{{ $editingSource }}-{{ $scopeId ?? 0 }}">
<div class="min-h-full flex items-start justify-center p-3 sm:p-6">
<div class="bg-gray-50 rounded-2xl shadow-xl w-full max-w-6xl">
{{-- editor header --}}
<div class="flex items-start justify-between gap-3 p-4 sm:p-5 bg-white rounded-t-2xl border-b border-gray-200">
<div>
<h2 class="text-lg font-bold text-gray-800">{{ $editingEnum->label() }}</h2>
@if($scopeLabel)
<p class="text-xs text-indigo-700 mt-0.5">{{ __('قاعدة خاصة بـ') }} «{{ $scopeLabel }}» {{ __('وحده') }}</p>
@else
<p class="text-xs text-gray-500 mt-0.5">{{ __('القاعدة الافتراضية لهذا المصدر') }}</p>
@endif
</div>
<button type="button" wire:click="closeEditor"
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100" aria-label="{{ __('إغلاق') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-4 p-4 sm:p-5">
{{-- ── left: the rule ────────────────────────────── --}}
<div class="lg:col-span-3 space-y-4">
{{-- active --}}
<div class="bg-white rounded-xl border border-gray-200 p-4">
<label class="flex items-start gap-3 cursor-pointer">
{{-- Every control on this screen renders its own value server-side.
Livewire would otherwise only apply it once its JavaScript
initialises, and a select that shows "اختر الحساب" for a rule
that in fact points at 4010 is the one mistake this screen
cannot afford to make. --}}
<input type="checkbox" wire:model.live="isActive" @checked($isActive) @disabled(! $canManage)
class="mt-0.5 rounded border-gray-300 text-green-600 focus:ring-green-500">
<span>
<span class="text-sm font-medium text-gray-800">{{ __('القاعدة مفعَّلة') }}</span>
<span class="block text-xs text-gray-500 mt-0.5">
{{ __('إيقافها يعيد هذا المصدر للسلوك المدمج في النظام دون حذف ما كتبته.') }}
</span>
</span>
</label>
</div>
{{-- tax --}}
<div class="bg-white rounded-xl border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-800 mb-1">{{ __('المعالجة الضريبية') }}</h3>
<p class="text-xs text-gray-500 mb-3">
{{ __('تُخصم الضريبة من المبلغ قبل التوزيع، وتُرحَّل لحساب التزامات — لا لحساب إيراد.') }}
</p>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
@foreach(TaxMode::cases() as $mode)
<label class="cursor-pointer">
<input type="radio" wire:model.live="taxMode" value="{{ $mode->value }}"
@checked($taxMode === $mode->value) class="peer sr-only" @disabled(! $canManage)>
<div class="peer-checked:ring-2 peer-checked:ring-amber-500 peer-checked:border-amber-500 peer-checked:bg-amber-50 h-full p-3 rounded-lg border-2 border-gray-200 hover:border-amber-300 transition-all">
<div class="text-xs font-semibold text-gray-800">{{ $mode->label() }}</div>
<div class="text-[11px] text-gray-500 mt-1 leading-snug">{{ $mode->hint() }}</div>
</div>
</label>
@endforeach
</div>
@if($taxMode !== TaxMode::None->value)
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3 pt-3 border-t border-gray-100">
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('النسبة %') }}</label>
<input type="number" wire:model.live.debounce.500ms="taxRateDisplay" dir="ltr" step="0.01" min="0" max="100"
value="{{ $taxRateDisplay }}" @disabled(! $canManage)
class="w-full rounded-lg border-gray-300 text-sm py-2">
<p class="text-[11px] text-gray-400 mt-1">{{ __('القيمة المضافة في مصر 14% — وبعض الخدمات 10% أو معفاة.') }}</p>
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('حساب الضريبة') }}</label>
<select wire:model.live="taxAccountId" @disabled(! $canManage)
class="w-full rounded-lg border-gray-300 text-sm py-2">
<option value="">{{ __('اختر حساباً…') }}</option>
@foreach($taxAccounts as $account)
<option value="{{ $account->id }}" @selected((int) $taxAccountId === (int) $account->id)>{{ $account->code }} — {{ $account->name_ar ?: $account->name }}</option>
@endforeach
</select>
<p class="text-[11px] text-gray-400 mt-1">{{ __('حسابات الالتزامات فقط.') }}</p>
</div>
</div>
@endif
</div>
{{-- lines --}}
<div class="bg-white rounded-xl border border-gray-200 p-4">
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-semibold text-gray-800">{{ __('توزيع الصافي على الحسابات') }}</h3>
@can('revenue_routing.manage')
<button type="button" wire:click="$toggle('showAccountForm')"
class="text-xs text-gray-600 hover:text-gray-800 hover:underline">
{{ __('+ حساب جديد في شجرة الحسابات') }}
</button>
@endcan
</div>
<p class="text-xs text-gray-500 mb-3">
{{ __('الترتيب المحاسبي ثابت: المبالغ الثابتة أولاً، ثم النسب من الصافي، ثم «الباقي».') }}
</p>
@if($showAccountForm)
<div class="mb-3 p-3 rounded-lg bg-gray-50 border border-gray-200">
<div class="grid grid-cols-1 sm:grid-cols-4 gap-2">
<div>
<label class="block text-[11px] text-gray-600 mb-1">{{ __('رقم الحساب') }}</label>
<input type="text" wire:model="newAccountCode" dir="ltr" placeholder="4070"
class="w-full rounded-lg border-gray-300 text-sm py-1.5">
@error('newAccountCode') <p class="text-red-500 text-[11px] mt-0.5">{{ $message }}</p> @enderror
</div>
<div class="sm:col-span-2">
<label class="block text-[11px] text-gray-600 mb-1">{{ __('اسم الحساب') }}</label>
<input type="text" wire:model="newAccountName" placeholder="{{ __('مثال: رسوم إدارية') }}"
class="w-full rounded-lg border-gray-300 text-sm py-1.5">
@error('newAccountName') <p class="text-red-500 text-[11px] mt-0.5">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-[11px] text-gray-600 mb-1">{{ __('النوع') }}</label>
<select wire:model="newAccountType" class="w-full rounded-lg border-gray-300 text-sm py-1.5">
<option value="revenue" @selected($newAccountType === 'revenue')>{{ __('إيراد') }}</option>
<option value="liability" @selected($newAccountType === 'liability')>{{ __('التزام') }}</option>
<option value="equity" @selected($newAccountType === 'equity')>{{ __('حقوق ملكية') }}</option>
</select>
</div>
</div>
<div class="flex gap-2 mt-2">
<button type="button" wire:click="createAccount" wire:loading.attr="disabled"
class="px-3 py-1.5 rounded-lg bg-gray-800 text-white text-xs font-medium disabled:opacity-50">
<span wire:loading.remove wire:target="createAccount">{{ __('إنشاء') }}</span>
<span wire:loading wire:target="createAccount">{{ __('جارٍ…') }}</span>
</button>
<button type="button" wire:click="$set('showAccountForm', false)"
class="px-3 py-1.5 rounded-lg border border-gray-300 text-gray-600 text-xs">{{ __('إلغاء') }}</button>
</div>
</div>
@endif
<div class="space-y-2">
@foreach($lines as $i => $line)
<div class="grid grid-cols-12 gap-2 items-start" wire:key="line-{{ $i }}">
<div class="col-span-12 sm:col-span-5">
<select wire:model.live="lines.{{ $i }}.account_id" @disabled(! $canManage)
class="w-full rounded-lg border-gray-300 text-sm py-2">
<option value="">{{ __('اختر الحساب…') }}</option>
@foreach($accounts as $account)
<option value="{{ $account->id }}" @selected((int) ($line['account_id'] ?? 0) === (int) $account->id)>{{ $account->code }} — {{ $account->name_ar ?: $account->name }}</option>
@endforeach
</select>
</div>
<div class="col-span-5 sm:col-span-3">
<select wire:model.live="lines.{{ $i }}.allocation_type" @disabled(! $canManage)
class="w-full rounded-lg border-gray-300 text-sm py-2">
@foreach(AllocationType::cases() as $type)
<option value="{{ $type->value }}" @selected(($line['allocation_type'] ?? '') === $type->value)>{{ $type->label() }}</option>
@endforeach
</select>
</div>
<div class="col-span-5 sm:col-span-3">
@if(($line['allocation_type'] ?? '') === AllocationType::Remainder->value)
<div class="text-xs text-gray-500 py-2.5 px-2 rounded-lg bg-gray-50 border border-gray-200 text-center">
{{ __('ما تبقى') }}
</div>
@else
<div class="relative">
<input type="number" wire:model.live.debounce.500ms="lines.{{ $i }}.value"
dir="ltr" step="0.01" min="0" @disabled(! $canManage)
value="{{ $line['value'] ?? '' }}"
class="w-full rounded-lg border-gray-300 text-sm py-2 pe-10"
placeholder="0.00">
<span class="absolute inset-y-0 end-2 flex items-center text-[11px] text-gray-400 pointer-events-none">
{{ ($line['allocation_type'] ?? '') === AllocationType::Percentage->value ? '%' : __('ج.م') }}
</span>
</div>
@endif
</div>
<div class="col-span-2 sm:col-span-1 flex justify-end">
@can('revenue_routing.manage')
<button type="button" wire:click="removeLine({{ $i }})"
class="w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50"
aria-label="{{ __('حذف السطر') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
@endcan
</div>
</div>
@endforeach
</div>
@can('revenue_routing.manage')
<button type="button" wire:click="addLine"
class="mt-3 inline-flex items-center gap-1 text-sm text-green-700 hover:text-green-800 font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('إضافة سطر توزيع') }}
</button>
@endcan
</div>
{{-- notes --}}
<div class="bg-white rounded-xl border border-gray-200 p-4">
<label class="block text-xs text-gray-600 mb-1">{{ __('ملاحظة للمراجع') }}</label>
<textarea wire:model="notes" rows="2" @disabled(! $canManage)
class="w-full rounded-lg border-gray-300 text-sm"
placeholder="{{ __('مثال: قرار الجمعية العمومية بتاريخ … بتخصيص 10% لصندوق التطوير') }}">{{ $notes }}</textarea>
</div>
{{-- overrides --}}
@if($editingEnum->supportsOverrides() && ! $scopeType)
<div class="bg-white rounded-xl border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-800 mb-1">{{ __('تخصيص لصنف بعينه') }}</h3>
<p class="text-xs text-gray-500 mb-3">
{{ __('قاعدة تخص صنفاً واحداً وتسبق القاعدة الافتراضية له — مثل رسوم القيد التي يجب أن تذهب لحساب مستقل.') }}
</p>
@can('revenue_routing.manage')
@if(! $showOverridePicker)
<button type="button" wire:click="openOverridePicker"
class="inline-flex items-center gap-1 text-sm text-indigo-700 hover:text-indigo-800 font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('إضافة تخصيص') }}
</button>
@else
<input type="text" wire:model.live.debounce.300ms="overrideSearch"
class="w-full rounded-lg border-gray-300 text-sm py-2 mb-2"
placeholder="{{ __('ابحث بالاسم…') }}">
<div class="max-h-56 overflow-y-auto divide-y divide-gray-100 border border-gray-200 rounded-lg">
@forelse($overrideCandidates as $candidate)
<button type="button" wire:click="chooseOverride({{ $candidate->id }})"
class="w-full text-start px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50">
{{ $candidate->name_ar ?: $candidate->name }}
</button>
@empty
<p class="px-3 py-3 text-xs text-gray-400">{{ __('لا توجد أصناف مطابقة') }}</p>
@endforelse
</div>
<button type="button" wire:click="$set('showOverridePicker', false)"
class="mt-2 text-xs text-gray-500 hover:underline">{{ __('إلغاء') }}</button>
@endif
@endcan
</div>
@endif
</div>
{{-- ── right: the preview ────────────────────────── --}}
<div class="lg:col-span-2">
<div class="bg-white rounded-xl border border-gray-200 p-4 lg:sticky lg:top-4">
<h3 class="text-sm font-semibold text-gray-800 mb-1">{{ __('القيد الناتج') }}</h3>
<p class="text-xs text-gray-500 mb-3">
{{ __('محسوب بنفس المحرك الذي يُرحِّل التحصيل فعلياً — لا محاكاة منفصلة.') }}
</p>
<label class="block text-xs text-gray-600 mb-1">{{ __('لو حصَّلنا الآن (ج.م)') }}</label>
<input type="number" wire:model.live.debounce.500ms="previewAmount" dir="ltr" step="0.01" min="0"
value="{{ $previewAmount }}"
class="w-full rounded-lg border-gray-300 text-sm py-2 mb-3">
@if($preview === null)
<p class="text-xs text-gray-400 py-4 text-center">{{ __('أكمل السطور لعرض القيد') }}</p>
@elseif(isset($preview['error']))
<div class="p-3 rounded-lg bg-red-50 border border-red-200 text-xs text-red-700">{{ $preview['error'] }}</div>
@else
@php $allocation = $preview['allocation']; @endphp
{{-- Laid out with flex and min-w-0 rather than a grid: an account
name like "ضريبة القيمة المضافة المستحقة" is long, and in a
narrow column a grid cell wraps it into four lines that push
the figure out of alignment with the row above it. --}}
<div class="rounded-lg border border-gray-200 overflow-hidden">
<div class="flex items-center bg-gray-50 text-[11px] text-gray-500 font-medium">
<div class="flex-1 px-2 py-1.5 text-start">{{ __('الحساب') }}</div>
<div class="w-24 px-2 py-1.5 text-end">{{ __('مدين') }}</div>
<div class="w-24 px-2 py-1.5 text-end">{{ __('دائن') }}</div>
</div>
{{-- the debit side: the money that arrived --}}
<div class="flex items-center text-xs border-b border-gray-100 bg-green-50/40">
<div class="flex-1 min-w-0 px-2 py-2 text-gray-800 font-medium truncate">{{ __('النقدية / البنك') }}</div>
<div class="w-24 px-2 py-2 text-end font-semibold text-gray-900" dir="ltr">{{ format_money($allocation->gross) }}</div>
<div class="w-24"></div>
</div>
@foreach($allocation->postings as $posting)
<div class="flex items-center text-xs border-b border-gray-100 {{ $posting->isTax() ? 'bg-amber-50/50' : '' }}">
<div class="flex-1 min-w-0 px-2 py-2 ps-5">
<div class="flex items-center gap-1.5">
<span class="font-mono text-gray-500 shrink-0" dir="ltr">{{ $posting->account->code }}</span>
<span class="text-gray-800 truncate" title="{{ $posting->account->name_ar ?: $posting->account->name }}">{{ $posting->account->name_ar ?: $posting->account->name }}</span>
@if($posting->isTax())
<span class="shrink-0 px-1 py-0.5 rounded bg-amber-100 text-amber-800 text-[10px]">{{ __('ضريبة') }}</span>
@endif
</div>
</div>
<div class="w-24"></div>
<div class="w-24 px-2 py-2 text-end font-semibold text-gray-900" dir="ltr">{{ format_money($posting->amount) }}</div>
</div>
@endforeach
<div class="flex items-center text-xs bg-gray-50 font-semibold">
<div class="flex-1 min-w-0 px-2 py-2 text-gray-700">{{ __('الإجمالي') }}</div>
<div class="w-24 px-2 py-2 text-end text-gray-900" dir="ltr">{{ format_money($allocation->gross) }}</div>
<div class="w-24 px-2 py-2 text-end text-gray-900" dir="ltr">{{ format_money($allocation->totalPosted()) }}</div>
</div>
</div>
<div class="mt-2 flex items-center gap-1.5 text-xs {{ $preview['balanced'] ? 'text-green-700' : 'text-red-700' }}">
@if($preview['balanced'])
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ __('القيد متوازن — المدين يساوي الدائن') }}
@else
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5 19h14a2 2 0 001.84-2.75L13.74 4a2 2 0 00-3.48 0l-7.1 12.25A2 2 0 004.99 19z"/></svg>
{{ __('القيد غير متوازن — لن يُسمح بالترحيل') }}
@endif
</div>
@if($allocation->tax > 0)
<div class="mt-2 text-[11px] text-gray-600 leading-relaxed">
{{ __('صافي الإيراد') }} <span dir="ltr" class="font-semibold">{{ format_money($allocation->net) }}</span>
· {{ __('ضريبة مستحقة') }} <span dir="ltr" class="font-semibold">{{ format_money($allocation->tax) }}</span>
</div>
@endif
@foreach($allocation->warnings as $warning)
<div class="mt-2 p-2 rounded-lg bg-amber-50 border border-amber-200 text-[11px] text-amber-800">{{ $warning }}</div>
@endforeach
@endif
</div>
</div>
</div>
{{-- editor footer --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 sm:p-5 bg-white rounded-b-2xl border-t border-gray-200">
<div>
@if($scopeType)
@can('revenue_routing.manage')
<button type="button"
wire:click="deleteOverride('{{ $editingSource }}', {{ (int) $scopeId }})"
wire:confirm="{{ __('حذف هذا التخصيص وإعادة الصنف للقاعدة الافتراضية؟') }}"
class="text-sm text-red-600 hover:text-red-700">
{{ __('حذف التخصيص') }}
</button>
@endcan
@endif
</div>
<div class="flex items-center gap-2">
<button type="button" wire:click="closeEditor"
class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm hover:bg-gray-50">
{{ __('إغلاق') }}
</button>
@can('revenue_routing.manage')
<button type="button" wire:click="save" wire:loading.attr="disabled" wire:target="save"
class="px-5 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 disabled:opacity-60 inline-flex items-center gap-2">
<svg wire:loading wire:target="save" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span wire:loading.remove wire:target="save">{{ __('حفظ القاعدة') }}</span>
<span wire:loading wire:target="save">{{ __('جارٍ الحفظ…') }}</span>
</button>
@endcan
</div>
</div>
</div>
</div>
</div>
@endif
</div>
......@@ -252,6 +252,12 @@
Route::get('/financial-overview', FinancialOverview::class)->name('financial.overview')
->middleware('permission:invoices.list');
// Revenue routing — which ledger account every collected pound lands in,
// and what part of it was tax. Gated on view; the screen itself hides every
// control that writes unless the user also holds revenue_routing.manage.
Route::get('/revenue-routing', \App\Livewire\Financial\RevenueRouting::class)->name('financial.revenue-routing')
->middleware('permission:revenue_routing.view');
// Expenses
Route::get('/expenses', \App\Livewire\Financial\ExpenseList::class)->name('expenses.list')
->middleware('permission:expenses.create');
......
......@@ -36,6 +36,7 @@ public function test_the_new_staff_screens_render_for_an_owner(): void
'service-requests.index',
'reports.transfer-reconciliation',
'participants.pending',
'financial.revenue-routing',
] as $name) {
$response = $this->actingAs($owner)->get(route($name));
......
......@@ -9,6 +9,7 @@
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Financial\Services\LedgerAccountResolver;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\RevenueRouter;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
......@@ -63,7 +64,9 @@ protected function setUp(): void
private function payments(): PaymentService
{
return new PaymentService(new InvoiceService(), new LedgerAccountResolver());
$accounts = new LedgerAccountResolver();
return new PaymentService(new InvoiceService(), $accounts, new RevenueRouter($accounts));
}
private function invoiceWithLines(array $lines, int $total): Invoice
......@@ -78,6 +81,40 @@ private function invoiceWithLines(array $lines, int $total): Invoice
// ---- the ledger -------------------------------------------------------
/**
* The minimal schema above deliberately has no `revenue_mappings`, so every
* case in this file also proves the property this one names: routing rules
* are configuration, and an installation that has not received them yet
* keeps taking money using the built-in behaviour rather than refusing a
* payment at the till because a settings table is absent.
*/
public function test_the_till_still_works_when_the_routing_tables_are_not_there(): void
{
$this->assertFalse(Schema::hasTable('revenue_mappings'));
$invoice = $this->invoiceWithLines(
[['description' => 'اشتراك', 'quantity' => 1, 'unit_price' => 15000]],
15000
);
$payment = $this->payments()->recordPayment([
'academy_id' => 1,
'branch_id' => 3,
'invoice_id' => $invoice->id,
'amount' => 15000,
'method' => 'cash',
'direction' => 'inbound',
'currency' => 'EGP',
'payment_date' => '2026-09-01',
], $this->actor);
$rows = Transaction::where('payment_id', $payment->id)->get();
$this->assertCount(1, $rows);
$this->assertSame('4000', $this->code($rows[0]->credit_account_id));
$this->assertSame(15000, (int) $rows[0]->amount);
}
public function test_a_payment_credits_revenue_not_the_bank_account(): void
{
$invoice = $this->invoiceWithLines(
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Enums\AllocationType;
use App\Domain\Financial\Enums\RevenueSource;
use App\Domain\Financial\Enums\TaxMode;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Financial\Services\PaymentService;
use App\Domain\Financial\Services\RevenueRouter;
use App\Domain\Financial\Services\RevenueRoutingService;
use App\Domain\Inventory\Models\Product;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Revenue routing: where a collected pound lands, and what part of it was tax.
*
* Runs against a restored Postgres tenant rather than the SQLite suite. The
* seeded chart of accounts and the CHECK constraints on the routing tables are
* the fixture — rebuilding them by hand would be testing a different system,
* and the constraints are half of what makes a rule safe:
*
* DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_DATABASE=oc_sport_test \
* DB_USERNAME=mahmoudaglan DB_PASSWORD= ./vendor/bin/phpunit --filter RevenueRoutingTest
*
* Every case runs inside a transaction that is rolled back, so the copy is left
* exactly as it was found.
*/
class RevenueRoutingTest extends TestCase
{
private int $academyId;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
DB::beginTransaction();
$academy = DB::table('academies')->orderBy('id')->first();
$this->academyId = (int) $academy->id;
app()->instance('current_academy', \App\Domain\Shared\Models\Academy::withoutGlobalScopes()->find($this->academyId));
}
protected function tearDown(): void
{
if (config('database.default') === 'pgsql') {
DB::rollBack();
}
parent::tearDown();
}
private function router(): RevenueRouter
{
// A fresh instance per call: the router memoises resolved rules, and a
// test that edits a rule must not read the copy from before the edit.
return new RevenueRouter(app(\App\Domain\Financial\Services\LedgerAccountResolver::class));
}
private function service(): RevenueRoutingService
{
return app(RevenueRoutingService::class);
}
private function account(string $code): FinancialAccount
{
return FinancialAccount::withoutGlobalScopes()
->where('academy_id', $this->academyId)
->where('code', $code)
->firstOrFail();
}
private function actor(): User
{
return User::query()->withoutGlobalScopes()->firstOrFail();
}
// ---- the seed changes nothing ------------------------------------------
public function test_the_installed_defaults_reproduce_the_old_hardcoded_behaviour(): void
{
foreach ([
[RevenueSource::Subscription, '4000'],
[RevenueSource::Product, '4020'],
[RevenueSource::Kit, '4020'],
[RevenueSource::Event, '4050'],
[RevenueSource::SpaceReservation, '4030'],
[RevenueSource::External, '4060'],
] as [$source, $expectedCode]) {
$allocation = $this->router()->allocateSource($this->academyId, $source, 50_000);
$this->assertTrue($allocation->isBalanced());
$this->assertCount(1, $allocation->postings, "{$source->value} should post to exactly one account out of the box");
$this->assertSame($expectedCode, $allocation->postings[0]->account->code);
$this->assertSame(50_000, $allocation->postings[0]->amount);
$this->assertSame(0, $allocation->tax);
}
}
public function test_the_platform_fee_is_left_folded_in_until_someone_asks_for_it(): void
{
// Shipped inactive: carving it out on the night of a deploy would move
// money between accounts in every client's books unasked.
$this->assertNull(
$this->router()->mappingFor($this->academyId, RevenueSource::ServiceFee),
'service_fee must ship inactive so today\'s split is untouched'
);
}
// ---- the arithmetic ----------------------------------------------------
public function test_a_flat_amount_a_percentage_and_the_remainder_split_exactly(): void
{
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::None->value,
], [
['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 500],
['account_id' => $this->account('4060')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 1000],
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
// 1,000.00 EGP: 5.00 flat, 10% of the whole (100.00), 895.00 left.
$allocation = $this->router()->allocateSource($this->academyId, RevenueSource::Subscription, 100_000);
$this->assertTrue($allocation->isBalanced());
$byCode = $this->byCode($allocation);
$this->assertSame(500, $byCode['4010']);
$this->assertSame(10_000, $byCode['4060']);
$this->assertSame(89_500, $byCode['4000']);
}
public function test_vat_is_carved_out_of_an_inclusive_price_and_credited_to_a_liability(): void
{
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::Inclusive->value,
'tax_rate_bp' => 1400,
'tax_account_id' => $this->account('2030')->id,
], [
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
// 1,140.00 collected at 14% inclusive is exactly 1,000.00 + 140.00.
$allocation = $this->router()->allocateSource($this->academyId, RevenueSource::Subscription, 114_000);
$this->assertTrue($allocation->isBalanced());
$this->assertSame(14_000, $allocation->tax);
$this->assertSame(100_000, $allocation->net);
$byCode = $this->byCode($allocation);
$this->assertSame(100_000, $byCode['4000']);
$this->assertSame(14_000, $byCode['2030']);
$tax = $allocation->taxPostings();
$this->assertCount(1, $tax);
$this->assertSame('liability', $tax[0]->account->type->value, 'VAT is owed, not earned');
}
public function test_tax_and_split_together_still_balance_on_an_amount_that_does_not_divide(): void
{
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::Inclusive->value,
'tax_rate_bp' => 1400,
'tax_account_id' => $this->account('2030')->id,
], [
['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 333],
['account_id' => $this->account('4060')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 3333],
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
$router = $this->router();
// Piaster-level fuzz. Nothing here may ever post more or less than came
// in — an unbalanced entry is a ledger that stops adding up, and it can
// only ever be reversed afterwards, never corrected.
foreach ([1, 7, 99, 101, 333, 1_000, 12_345, 99_999, 100_001, 7_777_777] as $amount) {
$allocation = $router->allocateSource($this->academyId, RevenueSource::Subscription, $amount);
$this->assertSame(
$amount,
$allocation->totalPosted(),
"Allocating {$amount} piasters did not post {$amount} piasters"
);
$this->assertSame($amount, $allocation->net + $allocation->tax);
foreach ($allocation->postings as $posting) {
$this->assertGreaterThan(0, $posting->amount, 'No zero or negative postings');
}
}
}
public function test_a_fixed_amount_larger_than_the_payment_cannot_post_money_that_never_arrived(): void
{
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::None->value,
], [
['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 50_000],
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
// Someone pays 100.00 against a rule that wants 500.00 off the top.
$allocation = $this->router()->allocateSource($this->academyId, RevenueSource::Subscription, 10_000);
$this->assertTrue($allocation->isBalanced());
$this->assertSame(10_000, $this->byCode($allocation)['4010']);
$this->assertArrayNotHasKey('4000', $this->byCode($allocation));
}
// ---- what the rules refuse ---------------------------------------------
public function test_percentages_that_do_not_reach_a_hundred_are_refused(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionMessageMatches('/100%/u');
$this->service()->save($this->academyId, RevenueSource::Subscription, [], [
['account_id' => $this->account('4000')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 6000],
['account_id' => $this->account('4010')->id, 'allocation_type' => 'percentage', 'percentage_bp' => 3000],
]);
}
public function test_two_remainder_lines_are_refused(): void
{
$this->expectException(DomainException::class);
$this->service()->save($this->academyId, RevenueSource::Subscription, [], [
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder'],
]);
}
public function test_tax_may_not_be_credited_to_a_revenue_account(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionMessageMatches('/التزامات/u');
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::Inclusive->value,
'tax_rate_bp' => 1400,
'tax_account_id' => $this->account('4000')->id,
], [
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
}
public function test_collected_money_may_not_be_routed_to_a_cash_or_expense_account(): void
{
$this->expectException(DomainException::class);
$this->service()->save($this->academyId, RevenueSource::Subscription, [], [
['account_id' => $this->account('1000')->id, 'allocation_type' => 'remainder'],
]);
}
public function test_a_rule_with_no_lines_is_refused(): void
{
$this->expectException(DomainException::class);
$this->service()->save($this->academyId, RevenueSource::Subscription, [], []);
}
// ---- specific beats general --------------------------------------------
public function test_a_rule_written_for_one_product_beats_the_default(): void
{
$product = Product::withoutGlobalScopes()->where('academy_id', $this->academyId)->firstOrFail();
$this->service()->save(
$this->academyId,
RevenueSource::Product,
['tax_mode' => TaxMode::None->value],
[['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder']],
null,
Product::class,
$product->id,
);
$router = $this->router();
$specific = $router->allocateSource($this->academyId, RevenueSource::Product, 20_000, Product::class, $product->id);
$general = $router->allocateSource($this->academyId, RevenueSource::Product, 20_000, Product::class, $product->id + 999_999);
$this->assertSame('4010', $specific->postings[0]->account->code);
$this->assertSame('4020', $general->postings[0]->account->code, 'Other products keep the default');
}
// ---- end to end: money actually moves ----------------------------------
public function test_recording_a_payment_writes_the_configured_accounts_to_the_ledger(): void
{
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::Inclusive->value,
'tax_rate_bp' => 1400,
'tax_account_id' => $this->account('2030')->id,
], [
['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'fixed_amount' => 1_000],
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
$invoice = Invoice::withoutGlobalScopes()
->where('academy_id', $this->academyId)
->whereColumn('total_amount', '>', 'paid_amount')
->whereNull('deleted_at')
->whereHas('items', fn ($q) => $q->withoutGlobalScopes()->whereNull('itemable_type'))
->orderBy('id')
->firstOrFail();
$amount = min(114_000, (int) $invoice->due_amount);
$before = Transaction::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count();
app(PaymentService::class)->recordPayment([
'academy_id' => $this->academyId,
'branch_id' => $invoice->branch_id,
'invoice_id' => $invoice->id,
'amount' => $amount,
'method' => 'cash',
'direction' => 'inbound',
'payment_date' => now()->toDateString(),
], $this->actor());
$written = Transaction::withoutGlobalScopes()
->where('invoice_id', $invoice->id)
->orderByDesc('id')
->limit(Transaction::withoutGlobalScopes()->where('invoice_id', $invoice->id)->count() - $before)
->get();
$this->assertGreaterThan(0, $written->count(), 'The payment must reach the ledger');
// Every row debits the money that arrived and credits where it went,
// and the credits add back up to the payment.
$this->assertSame($amount, (int) $written->sum('amount'), 'The ledger rows must sum to the payment');
$cash = $this->account('1000')->id;
foreach ($written as $row) {
$this->assertSame($cash, (int) $row->debit_account_id, 'Cash was debited');
}
$credited = $written->pluck('credit_account_id')->map(
fn ($id) => FinancialAccount::withoutGlobalScopes()->find($id)->code
)->all();
$this->assertContains('2030', $credited, 'The VAT portion must land in the liability account');
$this->assertContains('4010', $credited, 'The flat portion must land where the rule says');
// The rule that produced each row is written on the row itself, because
// the rule can be edited tomorrow and the entry cannot.
$this->assertNotEmpty($written->first()->metadata['routing_source'] ?? null);
}
public function test_a_refund_unwinds_the_accounts_the_money_went_into_even_after_the_rule_changed(): void
{
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::Inclusive->value,
'tax_rate_bp' => 1400,
'tax_account_id' => $this->account('2030')->id,
], [
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
$invoice = Invoice::withoutGlobalScopes()
->where('academy_id', $this->academyId)
->whereColumn('total_amount', '>', 'paid_amount')
->whereNull('deleted_at')
->whereHas('items', fn ($q) => $q->withoutGlobalScopes()->whereNull('itemable_type'))
->orderBy('id')
->firstOrFail();
$amount = min(114_000, (int) $invoice->due_amount);
$payment = app(PaymentService::class)->recordPayment([
'academy_id' => $this->academyId,
'branch_id' => $invoice->branch_id,
'invoice_id' => $invoice->id,
'amount' => $amount,
'method' => 'cash',
'direction' => 'inbound',
'payment_date' => now()->toDateString(),
], $this->actor());
$creditedOnCollection = Transaction::withoutGlobalScopes()
->where('payment_id', $payment->id)
->pluck('amount', 'credit_account_id');
// The accountant changes their mind after the money was taken.
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::None->value,
], [
['account_id' => $this->account('4060')->id, 'allocation_type' => 'remainder'],
]);
app(\App\Domain\Financial\Services\RefundService::class)
->processRefunds([$payment->uuid], 'اختبار', $this->actor());
$refund = \App\Domain\Financial\Models\Payment::withoutGlobalScopes()
->where('metadata->refund_of_payment_id', $payment->id)
->latest('id')
->firstOrFail();
$debited = Transaction::withoutGlobalScopes()
->where('payment_id', $refund->id)
->pluck('amount', 'debit_account_id');
$this->assertSame(
$creditedOnCollection->keys()->sort()->values()->all(),
$debited->keys()->sort()->values()->all(),
'A refund debits the accounts the collection credited, not the ones today\'s rule names'
);
foreach ($creditedOnCollection as $accountId => $credited) {
$this->assertSame((int) $credited, (int) $debited[$accountId]);
}
$this->assertSame($amount, (int) $debited->sum());
}
public function test_an_untouched_academy_still_posts_exactly_where_it_always_did(): void
{
$invoice = Invoice::withoutGlobalScopes()
->where('academy_id', $this->academyId)
->whereColumn('total_amount', '>', 'paid_amount')
->whereNull('deleted_at')
->orderBy('id')
->firstOrFail();
$allocation = $this->router()->allocatePayment($this->academyId, $invoice, (int) $invoice->due_amount);
$this->assertTrue($allocation->isBalanced());
$this->assertSame(0, $allocation->tax, 'No tax until an accountant configures one');
foreach ($allocation->postings as $posting) {
$this->assertSame('revenue', $posting->account->type->value);
}
}
// ---- one invoice, two kinds of money, two tax treatments ---------------
public function test_each_line_of_a_mixed_invoice_obeys_its_own_rule_and_its_own_tax(): void
{
// Training exempt, merchandise at 14% inclusive — the ordinary Egyptian
// case, and the one the old hardcoded split could not express at all.
$this->service()->save($this->academyId, RevenueSource::Subscription, [
'tax_mode' => TaxMode::None->value,
], [
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder'],
]);
$this->service()->save($this->academyId, RevenueSource::Product, [
'tax_mode' => TaxMode::Inclusive->value,
'tax_rate_bp' => 1400,
'tax_account_id' => $this->account('2030')->id,
], [
['account_id' => $this->account('4020')->id, 'allocation_type' => 'remainder'],
]);
$invoice = $this->mixedInvoice();
$lines = DB::table('invoice_items')->where('invoice_id', $invoice->id)
->selectRaw("coalesce(sum(total_amount) FILTER (WHERE itemable_type IS NULL), 0) AS subs,
coalesce(sum(total_amount) FILTER (WHERE itemable_type IS NOT NULL), 0) AS products")
->first();
$base = (int) $lines->subs + (int) $lines->products;
$allocation = $this->router()->allocatePayment($this->academyId, $invoice, $base);
$this->assertTrue($allocation->isBalanced());
$byCode = $this->byCode($allocation);
// The subscription half is untaxed and lands whole in 4000.
$this->assertSame((int) $lines->subs, $byCode['4000']);
// The merchandise half is 14% inclusive, so it splits into net + VAT.
$productShare = (int) $lines->products;
$expectedNet = intdiv($productShare * 10000, 11400);
$this->assertSame($expectedNet, $byCode['4020']);
$this->assertSame($productShare - $expectedNet, $byCode['2030']);
$this->assertSame($productShare - $expectedNet, $allocation->tax);
}
public function test_a_partial_payment_splits_across_the_lines_in_the_invoice_s_own_proportions(): void
{
$invoice = $this->mixedInvoice();
$half = intdiv((int) $invoice->total_amount, 2);
$allocation = $this->router()->allocatePayment($this->academyId, $invoice, $half);
$this->assertSame($half, $allocation->totalPosted(), 'A part payment posts exactly what was paid');
$this->assertGreaterThan(1, count($allocation->postings), 'Both kinds of line take a share');
}
public function test_the_platform_fee_becomes_its_own_line_once_the_academy_turns_it_on(): void
{
$invoice = $this->mixedInvoice();
// A fee on an invoice that has none, so the assertion is about the rule
// rather than about this tenant's data.
DB::table('invoices')->where('id', $invoice->id)->update([
'service_fee_amount' => 5_000,
'total_amount' => (int) $invoice->total_amount + 5_000,
]);
$invoice->refresh();
$before = $this->router()->allocatePayment($this->academyId, $invoice, (int) $invoice->total_amount);
$this->assertArrayNotHasKey('4060', $this->byCode($before), 'Inactive: the fee stays folded into the lines');
$this->service()->save($this->academyId, RevenueSource::ServiceFee, [
'is_active' => true,
'tax_mode' => TaxMode::None->value,
], [
['account_id' => $this->account('4060')->id, 'allocation_type' => 'remainder'],
]);
$after = $this->router()->allocatePayment($this->academyId, $invoice, (int) $invoice->total_amount);
$this->assertTrue($after->isBalanced());
$this->assertSame(5_000, $this->byCode($after)['4060'], 'Active: the fee lands in its own account');
}
private function mixedInvoice(): Invoice
{
$id = DB::table('invoices as i')
->join('invoice_items as ii', 'ii.invoice_id', '=', 'i.id')
->where('i.academy_id', $this->academyId)
->whereNull('i.deleted_at')
->groupBy('i.id')
->havingRaw('count(*) FILTER (WHERE ii.itemable_type IS NULL) > 0')
->havingRaw('count(*) FILTER (WHERE ii.itemable_type IS NOT NULL) > 0')
->orderBy('i.id')
->value('i.id');
if (! $id) {
$this->markTestSkipped('No invoice mixing a subscription line and a product line on this tenant.');
}
return Invoice::withoutGlobalScopes()->findOrFail($id);
}
public function test_an_override_for_an_item_from_another_tenant_is_refused(): void
{
$this->expectException(DomainException::class);
$this->service()->save(
$this->academyId,
RevenueSource::Product,
[],
[['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder']],
null,
Product::class,
999_999_999,
);
}
// ---- the screen --------------------------------------------------------
public function test_the_list_renders_an_override_next_to_the_rule_it_narrows(): void
{
$owner = User::withoutGlobalScopes()
->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
->first();
if (! $owner) {
$this->markTestSkipped('No academy_owner in the restored tenant.');
}
$product = Product::withoutGlobalScopes()->where('academy_id', $this->academyId)->firstOrFail();
$this->service()->save(
$this->academyId,
RevenueSource::Product,
['tax_mode' => TaxMode::None->value],
[['account_id' => $this->account('4010')->id, 'allocation_type' => 'remainder']],
null,
Product::class,
$product->id,
);
$this->actingAs($owner);
\Livewire\Livewire::test(\App\Livewire\Financial\RevenueRouting::class)
->assertOk()
->assertSee('تخصيص')
->assertSee((string) ($product->name_ar ?: $product->name))
// And it opens, which is the markup that only exists for overrides.
->call('editRule', RevenueSource::Product->value, $product->id)
->assertSet('scopeId', $product->id)
->assertSee('وحده');
}
public function test_the_editor_renders_and_previews_the_entry_a_rule_would_produce(): void
{
$owner = User::withoutGlobalScopes()
->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
->first();
if (! $owner) {
$this->markTestSkipped('No academy_owner in the restored tenant.');
}
$this->actingAs($owner);
\Livewire\Livewire::test(\App\Livewire\Financial\RevenueRouting::class)
->assertOk()
->assertSee('توجيه الإيرادات')
->call('editRule', RevenueSource::Subscription->value)
->assertSet('editingSource', RevenueSource::Subscription->value)
->set('taxMode', TaxMode::Inclusive->value)
->set('taxRateDisplay', '14')
->set('taxAccountId', $this->account('2030')->id)
->set('lines', [
['account_id' => $this->account('4010')->id, 'allocation_type' => 'fixed', 'value' => '5', 'label' => ''],
['account_id' => $this->account('4000')->id, 'allocation_type' => 'remainder', 'value' => '', 'label' => ''],
])
->set('previewAmount', '1140')
// 1,140.00 inclusive of 14% = 1,000.00 net + 140.00 tax; the flat
// 5.00 comes off the net, leaving 995.00.
->assertSee('140.00')
->assertSee('995.00')
->assertSee('القيد متوازن')
->call('save')
->assertHasNoErrors()
->assertSet('editingSource', null);
$mapping = $this->router()->mappingFor($this->academyId, RevenueSource::Subscription);
$this->assertSame(TaxMode::Inclusive, $mapping->tax_mode);
$this->assertSame(1400, $mapping->tax_rate_bp);
$this->assertSame('2030', $mapping->taxAccount->code);
$this->assertCount(2, $mapping->lines);
$this->assertSame(500, (int) $mapping->lines->firstWhere('allocation_type', AllocationType::Fixed)->fixed_amount);
}
public function test_a_rule_the_screen_refuses_never_reaches_the_database(): void
{
$owner = User::withoutGlobalScopes()
->whereHas('primaryRole', fn ($q) => $q->where('slug', 'academy_owner'))
->first();
if (! $owner) {
$this->markTestSkipped('No academy_owner in the restored tenant.');
}
$this->actingAs($owner);
\Livewire\Livewire::test(\App\Livewire\Financial\RevenueRouting::class)
->call('editRule', RevenueSource::Subscription->value)
->set('lines', [
['account_id' => $this->account('4000')->id, 'allocation_type' => 'percentage', 'value' => '60', 'label' => ''],
['account_id' => $this->account('4010')->id, 'allocation_type' => 'percentage', 'value' => '30', 'label' => ''],
])
->call('save')
// The editor stays open on the rule that was refused rather than
// closing over a change that did not happen.
->assertSet('editingSource', RevenueSource::Subscription->value);
$mapping = $this->router()->mappingFor($this->academyId, RevenueSource::Subscription);
$this->assertCount(1, $mapping->lines, 'The refused rule must not have been written');
$this->assertSame(AllocationType::Remainder, $mapping->lines->first()->allocation_type);
}
/** @return array<string, int> */
private function byCode($allocation): array
{
$out = [];
foreach ($allocation->collapsed()->postings as $posting) {
$out[$posting->account->code] = ($out[$posting->account->code] ?? 0) + $posting->amount;
}
return $out;
}
}
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