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;
}
}
This diff is collapsed.
This diff is collapsed.
<?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);
}
}
This diff is collapsed.
<?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' => [
......
This diff is collapsed.
......@@ -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(
......
This diff is collapsed.
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