Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
Clubphp
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Clubphp
Commits
c0419dbd
Commit
c0419dbd
authored
Apr 15, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
test
parent
cbf7de3f
Changes
14
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
388 additions
and
34 deletions
+388
-34
AccountingIntegrationService.php
...ules/Accounting/Services/AccountingIntegrationService.php
+201
-0
bootstrap.php
app/Modules/Accounting/bootstrap.php
+40
-2
ActivitySubscriptionController.php
...scriptions/Controllers/ActivitySubscriptionController.php
+29
-12
DeathController.php
app/Modules/Death/Controllers/DeathController.php
+11
-6
MemberController.php
app/Modules/Members/Controllers/MemberController.php
+9
-1
BillingService.php
app/Modules/Members/Services/BillingService.php
+4
-1
PaymentController.php
app/Modules/Payments/Controllers/PaymentController.php
+9
-1
BalanceCalculator.php
app/Modules/Payments/Services/BalanceCalculator.php
+2
-1
InventoryPaymentService.php
app/Modules/Sales/Services/InventoryPaymentService.php
+12
-0
ServicePrice.php
app/Modules/ServiceCatalog/Models/ServicePrice.php
+47
-0
Spouse.php
app/Modules/Spouses/Models/Spouse.php
+8
-0
SpouseFeeCalculator.php
app/Modules/Spouses/Services/SpouseFeeCalculator.php
+2
-1
SeparationFeeCalculator.php
app/Modules/Transfers/Services/SeparationFeeCalculator.php
+2
-1
SubscriptionGeneratorJob.php
cron/jobs/SubscriptionGeneratorJob.php
+12
-8
No files found.
app/Modules/Accounting/Services/AccountingIntegrationService.php
View file @
c0419dbd
...
...
@@ -609,6 +609,207 @@ final class AccountingIntegrationService
}
}
// ────────────────────────────────────────────────────────────
// INSTALLMENT PAYMENTS
// ────────────────────────────────────────────────────────────
/**
* Update AR when an installment is paid.
* The cash/revenue side is already handled by payment.completed.
* This reduces the AR balance for the installment plan.
*/
public
static
function
onInstallmentPaid
(
array
$data
)
:
void
{
$db
=
App
::
getInstance
()
->
db
();
$planId
=
(
int
)
(
$data
[
'plan_id'
]
??
0
);
$scheduleId
=
(
int
)
(
$data
[
'schedule_id'
]
??
0
);
$memberId
=
(
int
)
(
$data
[
'member_id'
]
??
0
);
if
(
$planId
<=
0
||
$scheduleId
<=
0
)
{
return
;
}
$scheduleItem
=
$db
->
selectOne
(
"SELECT amount FROM installment_schedule WHERE id = ?"
,
[
$scheduleId
]);
$amount
=
(
string
)
(
$scheduleItem
[
'amount'
]
??
'0.00'
);
if
(
bccomp
(
$amount
,
'0.00'
,
2
)
<=
0
)
{
return
;
}
$ar
=
\App\Modules\Accounting\Models\AccountReceivable
::
findByDocument
(
'installment'
,
$planId
);
if
(
$ar
)
{
$newPaid
=
bcadd
((
string
)
$ar
->
paid_amount
,
$amount
,
2
);
$newBalance
=
bcsub
((
string
)
$ar
->
total_amount
,
$newPaid
,
2
);
if
(
bccomp
(
$newBalance
,
'0.00'
,
2
)
<
0
)
$newBalance
=
'0.00'
;
$status
=
bccomp
(
$newBalance
,
'0.00'
,
2
)
<=
0
?
'paid'
:
'partial'
;
$ar
->
update
([
'paid_amount'
=>
$newPaid
,
'balance'
=>
$newBalance
,
'status'
=>
$status
,
]);
}
}
// ────────────────────────────────────────────────────────────
// REFUNDS
// ────────────────────────────────────────────────────────────
/**
* Post GL entry when a sale is refunded.
* Dr. Sales Revenue (reduce revenue) | Cr. Cash/Bank (money out)
*/
public
static
function
onSaleRefunded
(
array
$data
)
:
void
{
$db
=
App
::
getInstance
()
->
db
();
$saleId
=
(
int
)
(
$data
[
'sale_id'
]
??
0
);
$refundId
=
(
int
)
(
$data
[
'refund_id'
]
??
0
);
$refundNumber
=
$data
[
'refund_number'
]
??
''
;
$amount
=
(
string
)
(
$data
[
'amount'
]
??
'0.00'
);
if
(
$saleId
<=
0
||
bccomp
(
$amount
,
'0.00'
,
2
)
<=
0
)
{
return
;
}
$sale
=
$db
->
selectOne
(
"SELECT * FROM sales WHERE id = ?"
,
[
$saleId
]);
if
(
!
$sale
)
{
return
;
}
$salesRevenue
=
self
::
getAccountByCode
(
'4103'
);
$cashAccount
=
self
::
getAccountByCode
(
'1101'
);
if
(
!
$salesRevenue
||
!
$cashAccount
)
{
Logger
::
error
(
"Refund auto-post failed: accounts not found"
,
[
'sale_id'
=>
$saleId
]);
return
;
}
$description
=
'مرتجع مبيعات — فاتورة '
.
(
$sale
[
'invoice_number'
]
??
''
)
.
' — إشعار '
.
$refundNumber
;
$result
=
JournalService
::
createEntry
([
'entry_date'
=>
date
(
'Y-m-d'
),
'description_ar'
=>
$description
,
'description_en'
=>
'Sales refund — '
.
$refundNumber
,
'reference_type'
=>
'sale_refund'
,
'reference_id'
=>
$refundId
,
'reference_number'
=>
$refundNumber
,
'source_module'
=>
'sales'
,
'is_auto_generated'
=>
1
,
],
[
[
'account_id'
=>
(
int
)
$salesRevenue
[
'id'
],
'debit'
=>
$amount
,
'credit'
=>
'0.00'
,
'description_ar'
=>
'مرتجع مبيعات — تخفيض إيراد'
,
],
[
'account_id'
=>
(
int
)
$cashAccount
[
'id'
],
'debit'
=>
'0.00'
,
'credit'
=>
$amount
,
'description_ar'
=>
'مرتجع مبيعات — صرف نقدي'
,
],
],
true
);
if
(
!
$result
[
'success'
])
{
Logger
::
error
(
"Refund auto-post failed"
,
[
'sale_id'
=>
$saleId
,
'error'
=>
$result
[
'error'
]
??
''
]);
}
}
// ────────────────────────────────────────────────────────────
// RENTAL DEPOSITS
// ────────────────────────────────────────────────────────────
/**
* Record deposit liability when collected.
* The cash side is handled by payment.completed.
* This creates a Dr. Cash, Cr. Deposit Liability (2105 Deferred Revenue).
* Since payment.completed already handles Dr. Cash → Cr. Revenue,
* we only need to reclassify from revenue to deposit liability.
*/
public
static
function
onRentalDepositCollected
(
array
$data
)
:
void
{
$db
=
App
::
getInstance
()
->
db
();
$contractId
=
(
int
)
(
$data
[
'contract_id'
]
??
0
);
$paymentId
=
(
int
)
(
$data
[
'payment_id'
]
??
0
);
if
(
$contractId
<=
0
||
$paymentId
<=
0
)
{
return
;
}
$payment
=
$db
->
selectOne
(
"SELECT * FROM payments WHERE id = ?"
,
[
$paymentId
]);
if
(
!
$payment
)
{
return
;
}
$amount
=
(
string
)
(
$payment
[
'amount'
]
??
'0.00'
);
if
(
bccomp
(
$amount
,
'0.00'
,
2
)
<=
0
)
{
return
;
}
// Reclassify: Dr. Service Revenue (4105), Cr. Deferred Revenue / Deposits (2105)
$serviceRevenue
=
self
::
getAccountByCode
(
'4105'
);
$deferredRevenue
=
self
::
getAccountByCode
(
'2105'
);
if
(
!
$serviceRevenue
||
!
$deferredRevenue
)
{
return
;
}
$contract
=
$db
->
selectOne
(
"SELECT * FROM rental_contracts WHERE id = ?"
,
[
$contractId
]);
$contractNum
=
$contract
[
'contract_number'
]
??
$contractId
;
$result
=
JournalService
::
createEntry
([
'entry_date'
=>
date
(
'Y-m-d'
),
'description_ar'
=>
'إعادة تصنيف تأمين إيجار — عقد '
.
$contractNum
,
'description_en'
=>
'Rental deposit reclassification — contract '
.
$contractNum
,
'reference_type'
=>
'rental_deposit'
,
'reference_id'
=>
$contractId
,
'source_module'
=>
'rentals'
,
'is_auto_generated'
=>
1
,
],
[
[
'account_id'
=>
(
int
)
$serviceRevenue
[
'id'
],
'debit'
=>
$amount
,
'credit'
=>
'0.00'
,
'description_ar'
=>
'إعادة تصنيف من إيراد خدمات إلى تأمينات'
,
],
[
'account_id'
=>
(
int
)
$deferredRevenue
[
'id'
],
'debit'
=>
'0.00'
,
'credit'
=>
$amount
,
'description_ar'
=>
'تأمين إيجار مؤجل — عقد '
.
$contractNum
,
],
],
true
);
if
(
!
$result
[
'success'
])
{
Logger
::
error
(
"Rental deposit reclassification failed"
,
[
'contract_id'
=>
$contractId
,
'error'
=>
$result
[
'error'
]
??
''
]);
}
}
/**
* Reverse deposit liability when refunded.
* Dr. Deferred Revenue (2105), Cr. Cash (1101)
*/
public
static
function
onRentalDepositRefunded
(
array
$data
)
:
void
{
$db
=
App
::
getInstance
()
->
db
();
$contractId
=
(
int
)
(
$data
[
'contract_id'
]
??
0
);
$paymentId
=
(
int
)
(
$data
[
'payment_id'
]
??
0
);
if
(
$contractId
<=
0
)
{
return
;
}
// Find the original deposit reclassification entry and reverse it
$entry
=
\App\Modules\Accounting\Models\JournalEntry
::
findByReference
(
'rental_deposit'
,
$contractId
);
if
(
$entry
&&
$entry
->
isPosted
())
{
JournalService
::
reverseEntry
((
int
)
$entry
->
id
,
'رد تأمين إيجار — عقد '
.
$contractId
);
}
}
// ────────────────────────────────────────────────────────────
// HELPERS
// ────────────────────────────────────────────────────────────
...
...
app/Modules/Accounting/bootstrap.php
View file @
c0419dbd
...
...
@@ -160,11 +160,11 @@ EventBus::listen('fine.imposed', function (array $data): void {
},
50
);
// When a fine is collected, update AR
EventBus
::
listen
(
'fine.
collecte
d'
,
function
(
array
$data
)
:
void
{
EventBus
::
listen
(
'fine.
pai
d'
,
function
(
array
$data
)
:
void
{
try
{
AccountingIntegrationService
::
onFineCollected
(
$data
);
}
catch
(
\Throwable
$e
)
{
\App\Core\Logger
::
error
(
'Accounting auto-post failed (fine.
collecte
d): '
.
$e
->
getMessage
());
\App\Core\Logger
::
error
(
'Accounting auto-post failed (fine.
pai
d): '
.
$e
->
getMessage
());
}
},
50
);
...
...
@@ -177,3 +177,41 @@ EventBus::listen('installment.plan_created', function (array $data): void {
\App\Core\Logger
::
error
(
'Accounting auto-post failed (installment.plan_created): '
.
$e
->
getMessage
());
}
},
50
);
// When an installment is paid, update AR
EventBus
::
listen
(
'installment.paid'
,
function
(
array
$data
)
:
void
{
try
{
AccountingIntegrationService
::
onInstallmentPaid
(
$data
);
}
catch
(
\Throwable
$e
)
{
\App\Core\Logger
::
error
(
'Accounting auto-post failed (installment.paid): '
.
$e
->
getMessage
());
}
},
50
);
// ── Refunds ─────────────────────────────────────────────────
// When a sale is refunded, post reversal GL entry
EventBus
::
listen
(
'sale.refunded'
,
function
(
array
$data
)
:
void
{
try
{
AccountingIntegrationService
::
onSaleRefunded
(
$data
);
}
catch
(
\Throwable
$e
)
{
\App\Core\Logger
::
error
(
'Accounting auto-post failed (sale.refunded): '
.
$e
->
getMessage
());
}
},
50
);
// ── Rentals ─────────────────────────────────────────────────
// When a rental deposit is collected, record deposit liability
EventBus
::
listen
(
'rental.deposit_collected'
,
function
(
array
$data
)
:
void
{
try
{
AccountingIntegrationService
::
onRentalDepositCollected
(
$data
);
}
catch
(
\Throwable
$e
)
{
\App\Core\Logger
::
error
(
'Accounting auto-post failed (rental.deposit_collected): '
.
$e
->
getMessage
());
}
},
50
);
// When a rental deposit is refunded, reverse deposit liability
EventBus
::
listen
(
'rental.deposit_refunded'
,
function
(
array
$data
)
:
void
{
try
{
AccountingIntegrationService
::
onRentalDepositRefunded
(
$data
);
}
catch
(
\Throwable
$e
)
{
\App\Core\Logger
::
error
(
'Accounting auto-post failed (rental.deposit_refunded): '
.
$e
->
getMessage
());
}
},
50
);
app/Modules/ActivitySubscriptions/Controllers/ActivitySubscriptionController.php
View file @
c0419dbd
...
...
@@ -12,6 +12,7 @@ use App\Modules\ActivitySubscriptions\Models\ActivityPricing;
use
App\Modules\ActivitySubscriptions\Services\ActivitySubscriptionService
;
use
App\Modules\ActivitySubscriptions\Services\ActivityPricingService
;
use
App\Modules\Payments\Services\PaymentService
;
use
App\Modules\Sales\Services\InventoryPaymentService
;
use
App\Modules\Disciplines\Models\SportDiscipline
;
use
App\Modules\Academies\Models\Academy
;
...
...
@@ -107,18 +108,34 @@ class ActivitySubscriptionController extends Controller
}
$amount
=
(
string
)
(
$sub
[
'total_amount'
]
??
'0.00'
);
$data
=
$request
->
all
();
$data
[
'member_id'
]
=
null
;
$data
[
'player_id'
]
=
(
int
)
$sub
[
'player_id'
];
$data
[
'amount'
]
=
$amount
;
$data
[
'payment_type'
]
=
'activity_subscription'
;
$data
[
'payment_method'
]
=
$data
[
'payment_method'
]
??
'cash'
;
$data
[
'related_entity_type'
]
=
'activity_subscriptions'
;
$data
[
'related_entity_id'
]
=
(
int
)
$id
;
$data
[
'description'
]
=
'اشتراك نشاط '
.
(
$sub
[
'subscription_month'
]
??
''
)
.
' — '
.
(
$sub
[
'player_name'
]
??
''
);
$result
=
PaymentService
::
processPayment
(
$data
);
$paymentMethod
=
$request
->
post
(
'payment_method'
,
'cash'
);
$description
=
'اشتراك نشاط '
.
(
$sub
[
'subscription_month'
]
??
''
)
.
' — '
.
(
$sub
[
'player_name'
]
??
''
);
// Look up player's member_id — member players go through PaymentService,
// non-member players go through InventoryPaymentService (guest path)
$player
=
$db
->
selectOne
(
"SELECT member_id FROM players WHERE id = ?"
,
[(
int
)
$sub
[
'player_id'
]]);
$playerMemberId
=
$player
?
(
int
)
(
$player
[
'member_id'
]
??
0
)
:
0
;
if
(
$playerMemberId
>
0
)
{
$result
=
PaymentService
::
processPayment
([
'member_id'
=>
$playerMemberId
,
'amount'
=>
$amount
,
'payment_type'
=>
'activity_subscription'
,
'payment_method'
=>
$paymentMethod
,
'related_entity_type'
=>
'activity_subscriptions'
,
'related_entity_id'
=>
(
int
)
$id
,
'description'
=>
$description
,
]);
}
else
{
$result
=
InventoryPaymentService
::
processGuestPayment
([
'amount'
=>
$amount
,
'payment_method'
=>
$paymentMethod
,
'guest_name'
=>
$sub
[
'player_name'
]
??
'لاعب'
,
'related_entity_type'
=>
'activity_subscriptions'
,
'related_entity_id'
=>
(
int
)
$id
,
'description'
=>
$description
,
]);
}
if
(
!
$result
[
'success'
])
{
return
$this
->
redirect
(
'/activity-subscriptions/'
.
$id
)
->
withError
(
$result
[
'error'
]);
}
...
...
app/Modules/Death/Controllers/DeathController.php
View file @
c0419dbd
...
...
@@ -12,6 +12,7 @@ use App\Modules\Death\Models\DeathCase;
use
App\Modules\Archive\Services\ArchiveService
;
use
App\Modules\Rules\Services\RuleEngine
;
use
App\Modules\Payments\Services\PaymentService
;
use
App\Modules\ServiceCatalog\Models\ServicePrice
;
class
DeathController
extends
Controller
{
...
...
@@ -31,9 +32,11 @@ class DeathController extends Controller
$spouses
=
$db
->
select
(
"SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'"
,
[(
int
)
$memberId
]);
// Pre-calculate fees to show on form
$formFeeData
=
RuleEngine
::
get
(
'FORM_TRANSFER_FEE'
);
$formFee
=
$formFeeData
[
'amount'
]
??
'570.00'
;
$annualSub
=
'527.00'
;
// 492 + 35 development
$formFee
=
ServicePrice
::
getPrice
(
'SVC_TRANSFER_FORM'
,
'570.00'
);
$annualSubBase
=
ServicePrice
::
getPrice
(
'SVC_ANNUAL_MEMBER'
,
'492.00'
);
$devFeeData
=
RuleEngine
::
get
(
'DEVELOPMENT_FEE'
);
$devFee
=
$devFeeData
[
'amount'
]
??
'35.00'
;
$annualSub
=
bcadd
(
$annualSubBase
,
$devFee
,
2
);
$totalFee
=
bcadd
(
$formFee
,
$annualSub
,
2
);
return
$this
->
view
(
'Death.Views.create'
,
[
...
...
@@ -63,9 +66,11 @@ class DeathController extends Controller
}
// Calculate fee (form fee + annual renewal)
$formFeeData
=
RuleEngine
::
get
(
'FORM_TRANSFER_FEE'
);
$formFee
=
$formFeeData
[
'amount'
]
??
'570.00'
;
$annualSub
=
'527.00'
;
// 492 + 35 development
$formFee
=
ServicePrice
::
getPrice
(
'SVC_TRANSFER_FORM'
,
'570.00'
);
$annualSubBase
=
ServicePrice
::
getPrice
(
'SVC_ANNUAL_MEMBER'
,
'492.00'
);
$devFeeData
=
RuleEngine
::
get
(
'DEVELOPMENT_FEE'
);
$devFee
=
$devFeeData
[
'amount'
]
??
'35.00'
;
$annualSub
=
bcadd
(
$annualSubBase
,
$devFee
,
2
);
$totalFee
=
bcadd
(
$formFee
,
$annualSub
,
2
);
$case
=
DeathCase
::
create
([
...
...
app/Modules/Members/Controllers/MemberController.php
View file @
c0419dbd
...
...
@@ -14,6 +14,7 @@ use App\Modules\Members\Services\MemberNumberGenerator;
use
App\Modules\Members\Services\MemberSearchService
;
use
App\Modules\Members\Services\BillingService
;
use
App\Modules\Payments\Services\PaymentService
;
use
App\Modules\Rules\Services\RuleEngine
;
class
MemberController
extends
Controller
{
...
...
@@ -178,7 +179,8 @@ class MemberController extends Controller
$remaining
=
bcsub
(
$membershipValue
,
$amount
,
2
);
if
(
bccomp
(
$remaining
,
'0'
,
2
)
>
0
)
{
$months
=
min
(
30
,
max
(
1
,
(
int
)
$request
->
post
(
'installment_months'
,
30
)));
$interestRate
=
'22.00'
;
$interestRateData
=
RuleEngine
::
get
(
'INSTALLMENT_INTEREST_RATE'
);
$interestRate
=
$interestRateData
[
'percentage'
]
??
'22.00'
;
$totalInterest
=
bcdiv
(
bcmul
(
$remaining
,
$interestRate
,
4
),
'100'
,
2
);
$totalWithInterest
=
bcadd
(
$remaining
,
$totalInterest
,
2
);
$monthlyPayment
=
bcdiv
(
$totalWithInterest
,
(
string
)
$months
,
2
);
...
...
@@ -208,6 +210,12 @@ class MemberController extends Controller
'created_at'
=>
date
(
'Y-m-d H:i:s'
),
'updated_at'
=>
date
(
'Y-m-d H:i:s'
),
]);
}
EventBus
::
dispatch
(
'installment.plan_created'
,
[
'plan_id'
=>
$planId
,
'member_id'
=>
(
int
)
$id
,
'total_amount'
=>
$totalWithInterest
,
]);
}
}
...
...
app/Modules/Members/Services/BillingService.php
View file @
c0419dbd
...
...
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace
App\Modules\Members\Services
;
use
App\Core\App
;
use
App\Modules\ServiceCatalog\Models\ServicePrice
;
/**
* Calculates the TOTAL bill for a member including all additions.
...
...
@@ -30,10 +31,12 @@ final class BillingService
$formFeePaid
=
(
$ff
!==
null
);
}
catch
(
\Throwable
$e
)
{}
$formFeeAmount
=
ServicePrice
::
getPrice
(
'SVC_NEW_FORM'
,
'505.00'
);
$items
[]
=
[
'type'
=>
'form_fee'
,
'label'
=>
'رسوم استمارة عضوية (500 استمارة + 5 طابع شهداء)'
,
'amount'
=>
'505.00'
,
'amount'
=>
$formFeeAmount
,
'paid'
=>
$formFeePaid
,
'included'
=>
false
,
'category'
=>
'required'
,
...
...
app/Modules/Payments/Controllers/PaymentController.php
View file @
c0419dbd
...
...
@@ -12,6 +12,7 @@ use App\Modules\Payments\Models\Payment;
use
App\Modules\Payments\Services\PaymentService
;
use
App\Modules\Payments\Services\BalanceCalculator
;
use
App\Modules\Members\Services\MemberNumberGenerator
;
use
App\Modules\Rules\Services\RuleEngine
;
class
PaymentController
extends
Controller
{
...
...
@@ -226,7 +227,8 @@ class PaymentController extends Controller
if
(
bccomp
(
$remaining
,
'0'
,
2
)
>
0
)
{
// Create installment plan
$interestRate
=
'22.00'
;
$interestRateData
=
RuleEngine
::
get
(
'INSTALLMENT_INTEREST_RATE'
);
$interestRate
=
$interestRateData
[
'percentage'
]
??
'22.00'
;
$months
=
30
;
// Max default, can be overridden
$totalInterest
=
bcdiv
(
bcmul
(
$remaining
,
$interestRate
,
4
),
'100'
,
2
);
$totalWithInterest
=
bcadd
(
$remaining
,
$totalInterest
,
2
);
...
...
@@ -276,6 +278,12 @@ class PaymentController extends Controller
'updated_at'
=>
date
(
'Y-m-d H:i:s'
),
]);
}
EventBus
::
dispatch
(
'installment.plan_created'
,
[
'plan_id'
=>
$planId
,
'member_id'
=>
$memberId
,
'total_amount'
=>
$totalWithInterest
,
]);
}
// Assign number and activate
...
...
app/Modules/Payments/Services/BalanceCalculator.php
View file @
c0419dbd
...
...
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace
App\Modules\Payments\Services
;
use
App\Core\App
;
use
App\Modules\ServiceCatalog\Models\ServicePrice
;
/**
* Calculates all financial obligations and balances for a member.
...
...
@@ -27,7 +28,7 @@ final class BalanceCalculator
// ── Form Fee ──
$formFeePaid
=
PaymentService
::
hasPaid
(
$memberId
,
'form_fee'
);
$formFee
=
'505.00'
;
$formFee
=
ServicePrice
::
getPrice
(
'SVC_NEW_FORM'
,
'505.00'
)
;
// ── Membership Fee ──
$membershipFeePaid
=
PaymentService
::
hasPaid
(
$memberId
,
'membership_fee'
);
...
...
app/Modules/Sales/Services/InventoryPaymentService.php
View file @
c0419dbd
...
...
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace
App\Modules\Sales\Services
;
use
App\Core\App
;
use
App\Core\EventBus
;
use
App\Core\Logger
;
/**
...
...
@@ -79,6 +80,17 @@ final class InventoryPaymentService
$db
->
commit
();
// Fire payment.completed so Accounting module auto-posts GL entries
EventBus
::
dispatch
(
'payment.completed'
,
[
'payment_id'
=>
$paymentId
,
'receipt_id'
=>
$receiptId
,
'receipt_number'
=>
$receiptNumber
,
'member_id'
=>
0
,
'type'
=>
'inventory_sale'
,
'amount'
=>
$amount
,
'method'
=>
$paymentMethod
,
]);
Logger
::
info
(
"Guest payment #
{
$paymentId
}
processed — receipt:
{
$receiptNumber
}
"
);
return
[
...
...
app/Modules/ServiceCatalog/Models/ServicePrice.php
View file @
c0419dbd
...
...
@@ -28,4 +28,51 @@ class ServicePrice extends Model
ORDER BY sc.service_code
"
);
}
/**
* Get the current price for a service by code.
* Returns base_amount for fixed prices, or the full row for percentage-based.
* Uses effective_from/effective_to to find the currently valid price.
* Falls back to $default if the service code is not found.
*/
public
static
function
getPrice
(
string
$serviceCode
,
?
string
$default
=
null
,
?
int
$branchId
=
null
)
:
?
string
{
$db
=
App
::
getInstance
()
->
db
();
$today
=
date
(
'Y-m-d'
);
// Try branch-specific first, then global
$sql
=
"SELECT base_amount FROM service_catalog
WHERE service_code = ? AND is_active = 1
AND effective_from <= ?
AND (effective_to IS NULL OR effective_to >= ?)"
;
if
(
$branchId
!==
null
)
{
$row
=
$db
->
selectOne
(
$sql
.
" AND branch_id = ? ORDER BY effective_from DESC LIMIT 1"
,
[
$serviceCode
,
$today
,
$today
,
$branchId
]);
if
(
$row
)
return
$row
[
'base_amount'
];
}
$row
=
$db
->
selectOne
(
$sql
.
" AND branch_id IS NULL ORDER BY effective_from DESC LIMIT 1"
,
[
$serviceCode
,
$today
,
$today
]);
return
$row
?
$row
[
'base_amount'
]
:
$default
;
}
/**
* Get the full service catalog row (for percentage-based services).
*/
public
static
function
getServiceRow
(
string
$serviceCode
,
?
int
$branchId
=
null
)
:
?
array
{
$db
=
App
::
getInstance
()
->
db
();
$today
=
date
(
'Y-m-d'
);
$sql
=
"SELECT * FROM service_catalog
WHERE service_code = ? AND is_active = 1
AND effective_from <= ?
AND (effective_to IS NULL OR effective_to >= ?)"
;
if
(
$branchId
!==
null
)
{
$row
=
$db
->
selectOne
(
$sql
.
" AND branch_id = ? ORDER BY effective_from DESC LIMIT 1"
,
[
$serviceCode
,
$today
,
$today
,
$branchId
]);
if
(
$row
)
return
$row
;
}
return
$db
->
selectOne
(
$sql
.
" AND branch_id IS NULL ORDER BY effective_from DESC LIMIT 1"
,
[
$serviceCode
,
$today
,
$today
]);
}
}
\ No newline at end of file
app/Modules/Spouses/Models/Spouse.php
View file @
c0419dbd
...
...
@@ -75,6 +75,14 @@ class Spouse extends Model
return
null
;
}
public
function
getQualificationName
()
:
string
{
if
(
!
$this
->
qualification_id
)
return
'—'
;
$db
=
App
::
getInstance
()
->
db
();
$row
=
$db
->
selectOne
(
"SELECT name_ar FROM qualifications WHERE id = ?"
,
[
$this
->
qualification_id
]);
return
$row
[
'name_ar'
]
??
'—'
;
}
public
function
getClassificationLabel
()
:
string
{
return
match
(
$this
->
classification
)
{
...
...
app/Modules/Spouses/Services/SpouseFeeCalculator.php
View file @
c0419dbd
...
...
@@ -5,6 +5,7 @@ namespace App\Modules\Spouses\Services;
use
App\Core\App
;
use
App\Modules\Spouses\Models\Spouse
;
use
App\Modules\ServiceCatalog\Models\ServicePrice
;
/**
* Spouse Fee Calculator
...
...
@@ -55,7 +56,7 @@ final class SpouseFeeCalculator
// Late addition = member is already active/accepted = must pay form fee
$isLateAddition
=
!
in_array
(
$member
[
'status'
]
??
''
,
[
'potential'
,
'under_review'
]);
$formFee
=
$isLateAddition
?
'570.00'
:
'0.00'
;
$formFee
=
$isLateAddition
?
ServicePrice
::
getPrice
(
'SVC_ADDITION_FORM'
,
'570.00'
)
:
'0.00'
;
// ── Is the member acquired (مكتسب)? ──
$isAcquiredMember
=
self
::
isAcquiredMember
(
$memberId
);
...
...
app/Modules/Transfers/Services/SeparationFeeCalculator.php
View file @
c0419dbd
...
...
@@ -6,6 +6,7 @@ namespace App\Modules\Transfers\Services;
use
App\Core\App
;
use
App\Modules\Rules\Services\RuleEngine
;
use
App\Modules\Pricing\Services\PricingEngine
;
use
App\Modules\ServiceCatalog\Models\ServicePrice
;
final
class
SeparationFeeCalculator
{
...
...
@@ -46,7 +47,7 @@ final class SeparationFeeCalculator
$formFee
=
$formFeeData
[
'amount'
]
??
'570.00'
;
// Annual subscription (basic member subscription)
$annualSub
=
'492.00'
;
// current year rate
$annualSub
=
ServicePrice
::
getPrice
(
'SVC_ANNUAL_MEMBER'
,
'492.00'
);
$devFeeData
=
RuleEngine
::
get
(
'DEVELOPMENT_FEE'
);
$devFee
=
$devFeeData
[
'amount'
]
??
'35.00'
;
$annualSubscriptionFee
=
bcadd
(
$annualSub
,
$devFee
,
2
);
...
...
cron/jobs/SubscriptionGeneratorJob.php
View file @
c0419dbd
...
...
@@ -5,6 +5,8 @@ namespace CronJobs;
use
App\Core\Database
;
use
App\Core\Logger
;
use
App\Modules\ServiceCatalog\Models\ServicePrice
;
use
App\Modules\Rules\Services\RuleEngine
;
class
SubscriptionGeneratorJob
{
...
...
@@ -31,12 +33,15 @@ class SubscriptionGeneratorJob
// Get all active members
$members
=
$this
->
db
->
select
(
"SELECT id, full_name_ar, membership_type FROM members WHERE status = 'active' AND is_archived = 0"
);
$devFee
=
'35.00'
;
$devFeeData
=
RuleEngine
::
get
(
'DEVELOPMENT_FEE'
);
$devFee
=
$devFeeData
[
'amount'
]
??
'35.00'
;
$memberRate
=
ServicePrice
::
getPrice
(
'SVC_ANNUAL_MEMBER'
,
'492.00'
);
$spouseRate
=
ServicePrice
::
getPrice
(
'SVC_ANNUAL_SPOUSE'
,
'492.00'
);
$childRate
=
ServicePrice
::
getPrice
(
'SVC_ANNUAL_CHILD'
,
'222.00'
);
$tempRate
=
ServicePrice
::
getPrice
(
'SVC_ANNUAL_TEMP'
,
'222.00'
);
$ts
=
date
(
'Y-m-d H:i:s'
);
foreach
(
$members
as
$m
)
{
$memberRate
=
'492.00'
;
// Default 2025/2026
// Generate for member
$this
->
db
->
insert
(
'subscriptions'
,
[
'member_id'
=>
(
int
)
$m
[
'id'
],
...
...
@@ -62,9 +67,9 @@ class SubscriptionGeneratorJob
'person_type'
=>
'spouse'
,
'person_id'
=>
(
int
)
$sp
[
'id'
],
'person_name'
=>
$sp
[
'full_name_ar'
],
'base_amount'
=>
$
member
Rate
,
'base_amount'
=>
$
spouse
Rate
,
'development_fee'
=>
$devFee
,
'total_amount'
=>
bcadd
(
$
member
Rate
,
$devFee
,
2
),
'total_amount'
=>
bcadd
(
$
spouse
Rate
,
$devFee
,
2
),
'status'
=>
'pending'
,
'created_at'
=>
$ts
,
'updated_at'
=>
$ts
,
...
...
@@ -74,7 +79,6 @@ class SubscriptionGeneratorJob
// Generate for children
$children
=
$this
->
db
->
select
(
"SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'"
,
[(
int
)
$m
[
'id'
]]);
$childRate
=
'222.00'
;
foreach
(
$children
as
$ch
)
{
$this
->
db
->
insert
(
'subscriptions'
,
[
'member_id'
=>
(
int
)
$m
[
'id'
],
...
...
@@ -101,9 +105,9 @@ class SubscriptionGeneratorJob
'person_type'
=>
'temporary'
,
'person_id'
=>
(
int
)
$tmp
[
'id'
],
'person_name'
=>
$tmp
[
'full_name_ar'
],
'base_amount'
=>
$
child
Rate
,
'base_amount'
=>
$
temp
Rate
,
'development_fee'
=>
$devFee
,
'total_amount'
=>
bcadd
(
$
child
Rate
,
$devFee
,
2
),
'total_amount'
=>
bcadd
(
$
temp
Rate
,
$devFee
,
2
),
'status'
=>
'pending'
,
'created_at'
=>
$ts
,
'updated_at'
=>
$ts
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment