Commit 9f69282f authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(pricing): unified Board Offer system with cash discount + installment terms

- Rebuild board_offers table with unified schema (cash path + installment path in one record)
- Add grace period support (first_n_months and full_free_under_n modes)
- Add offer snapshot to payment_requests for persistence across offer expiry
- Add offer context (board_offer_id, grace_months) to installment_plans
- Refactor BoardOfferService with branch-aware offer resolution
- Enhance InstallmentCalculator with grace period math
- Full admin CRUD (BoardOfferController + views + routes + permissions)
- Integrate offer display in member show page payment section
- Snapshot offer at payment request creation, use snapshot in plan creation
- Fix BillingService to use new BoardOfferService API
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 3902c88f
...@@ -68,6 +68,8 @@ final class PaymentRequestService ...@@ -68,6 +68,8 @@ final class PaymentRequestService
'requested_by' => $employee ? (int) $employee->id : 0, 'requested_by' => $employee ? (int) $employee->id : 0,
'branch_id' => $branch ? (int) $branch['id'] : null, 'branch_id' => $branch ? (int) $branch['id'] : null,
'notes' => $notes, 'notes' => $notes,
'board_offer_id' => $data['board_offer_id'] ?? null,
'offer_snapshot_json' => $data['offer_snapshot_json'] ?? null,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]); ]);
......
...@@ -78,9 +78,11 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -78,9 +78,11 @@ EventBus::listen('payment_request.completed', function (array $data) {
if (in_array($paymentType, ['membership_fee', 'down_payment', 'foreign_membership_fee', 'sports_membership_fee'], true)) { if (in_array($paymentType, ['membership_fee', 'down_payment', 'foreign_membership_fee', 'sports_membership_fee'], true)) {
$requestData = []; $requestData = [];
if ($paymentType === 'down_payment' && $requestId > 0) { if ($paymentType === 'down_payment' && $requestId > 0) {
$request = $db->selectOne("SELECT notes FROM payment_requests WHERE id = ?", [$requestId]); $request = $db->selectOne("SELECT notes, offer_snapshot_json, board_offer_id FROM payment_requests WHERE id = ?", [$requestId]);
$notesData = $request && $request['notes'] ? json_decode($request['notes'], true) : []; $notesData = $request && $request['notes'] ? json_decode($request['notes'], true) : [];
$requestData['installment_months'] = $notesData['installment_months'] ?? 30; $requestData['installment_months'] = $notesData['installment_months'] ?? 30;
$requestData['offer_snapshot_json'] = $request['offer_snapshot_json'] ?? null;
$requestData['board_offer_id'] = $request['board_offer_id'] ?? null;
} }
$result = \App\Modules\Payments\Services\PaymentLifecycleService::onMembershipPaymentCompleted( $result = \App\Modules\Payments\Services\PaymentLifecycleService::onMembershipPaymentCompleted(
......
...@@ -22,6 +22,7 @@ use App\Modules\Forms\Services\FormBridge; ...@@ -22,6 +22,7 @@ use App\Modules\Forms\Services\FormBridge;
use App\Core\Logger; use App\Core\Logger;
use App\Shared\Services\PhotoUploadService; use App\Shared\Services\PhotoUploadService;
use App\Modules\Members\Services\MembershipRulesService; use App\Modules\Members\Services\MembershipRulesService;
use App\Modules\Members\Services\BoardOfferService;
class MemberController extends Controller class MemberController extends Controller
{ {
...@@ -359,6 +360,11 @@ class MemberController extends Controller ...@@ -359,6 +360,11 @@ class MemberController extends Controller
$instRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE'); $instRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS'); $instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
$boardOffers = BoardOfferService::getActiveOffers((int) ($member->branch_id ?? 0) ?: null);
$bestOffer = $boardOffers[0] ?? null;
$cashDiscount = $bestOffer ? BoardOfferService::getCashDiscount($bill['total_pending'], $bestOffer) : null;
$installmentTerms = BoardOfferService::getInstallmentTerms($bestOffer);
return $this->view('Members.Views.show', [ return $this->view('Members.Views.show', [
'member' => $member, 'member' => $member,
'branchName' => $branch['name_ar'] ?? '—', 'branchName' => $branch['name_ar'] ?? '—',
...@@ -385,6 +391,10 @@ class MemberController extends Controller ...@@ -385,6 +391,10 @@ class MemberController extends Controller
: null, : null,
'installInterestRate' => (float) ($instRateData['percentage'] ?? 22), 'installInterestRate' => (float) ($instRateData['percentage'] ?? 22),
'installMaxMonths' => (int) ($instMonthsData['months'] ?? 30), 'installMaxMonths' => (int) ($instMonthsData['months'] ?? 30),
'boardOffers' => $boardOffers,
'bestOffer' => $bestOffer,
'cashDiscount' => $cashDiscount,
'installmentTerms' => $installmentTerms,
'subscriptionStatus' => $subscriptionStatus, 'subscriptionStatus' => $subscriptionStatus,
'overdueSubscriptions' => $overdueSubscriptions, 'overdueSubscriptions' => $overdueSubscriptions,
'transferFeePayment' => $transferFeePayment, 'transferFeePayment' => $transferFeePayment,
...@@ -480,8 +490,19 @@ class MemberController extends Controller ...@@ -480,8 +490,19 @@ class MemberController extends Controller
if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح'); if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح');
$instMaxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS'); // Resolve active board offer (if any) at request time
$instMaxMonths = (int) ($instMaxMonthsData['months'] ?? 30); $offerId = (int) $request->post('board_offer_id', 0);
$activeOffer = null;
if ($offerId > 0) {
$activeOffer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ? AND `is_active` = 1 AND `effective_from` <= ? AND `effective_to` >= ?", [$offerId, date('Y-m-d'), date('Y-m-d')]);
}
if (!$activeOffer) {
$activeOffer = BoardOfferService::getBestOffer((int) ($member->branch_id ?? 0) ?: null);
}
$offerSnapshot = $activeOffer ? BoardOfferService::snapshotOffer($activeOffer) : null;
$instTerms = BoardOfferService::getInstallmentTerms($activeOffer);
$instMaxMonths = $instTerms['max_months'];
$months = ($paymentType === 'down_payment') ? min($instMaxMonths, max(1, (int) $request->post('installment_months', $instMaxMonths))) : null; $months = ($paymentType === 'down_payment') ? min($instMaxMonths, max(1, (int) $request->post('installment_months', $instMaxMonths))) : null;
// Cancel any pending individual addition_fee requests — the collective payment subsumes them // Cancel any pending individual addition_fee requests — the collective payment subsumes them
...@@ -503,8 +524,17 @@ class MemberController extends Controller ...@@ -503,8 +524,17 @@ class MemberController extends Controller
if ($paymentType === 'down_payment' && $months) { if ($paymentType === 'down_payment' && $months) {
$breakdown[] = '💵 مقدم التقسيط: ' . money($amount); $breakdown[] = '💵 مقدم التقسيط: ' . money($amount);
$breakdown[] = '📅 عدد الأشهر: ' . $months; $breakdown[] = '📅 عدد الأشهر: ' . $months;
if ($activeOffer) {
$breakdown[] = '🎯 عرض مجلس الإدارة: ' . ($activeOffer['title_ar'] ?? '');
}
} else { } else {
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount); $breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount);
if ($activeOffer && $activeOffer['cash_discount_type']) {
$cashInfo = BoardOfferService::getCashDiscount($bill['total_pending'], $activeOffer);
if (bccomp($cashInfo['savings'], '0', 2) > 0) {
$breakdown[] = '🎯 خصم عرض مجلس الإدارة: ' . money($cashInfo['savings']);
}
}
} }
$notesData = ['fee_breakdown' => $breakdown]; $notesData = ['fee_breakdown' => $breakdown];
...@@ -527,6 +557,8 @@ class MemberController extends Controller ...@@ -527,6 +557,8 @@ class MemberController extends Controller
'related_entity_id' => (int) $id, 'related_entity_id' => (int) $id,
'description_ar' => $descriptionAr, 'description_ar' => $descriptionAr,
'notes' => json_encode($notesData, JSON_UNESCAPED_UNICODE), 'notes' => json_encode($notesData, JSON_UNESCAPED_UNICODE),
'board_offer_id' => $activeOffer ? (int) $activeOffer['id'] : null,
'offer_snapshot_json' => $offerSnapshot,
]); ]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']); if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
......
...@@ -646,17 +646,23 @@ final class BillingService ...@@ -646,17 +646,23 @@ final class BillingService
} }
} catch (\Throwable $e) {} } catch (\Throwable $e) {}
// ── 6. Board Offers (cash discount) ── // ── 6. Board Offers (informational — actual discount applied at payment time) ──
$cashOffer = BoardOfferService::getCashDiscount(); $bestOffer = BoardOfferService::getBestOffer();
if ($cashOffer && ($cashOffer['applies_to'] === 'membership_fee' || $cashOffer['applies_to'] === 'all')) { if ($bestOffer && ($bestOffer['applies_to'] === 'membership_fee' || $bestOffer['applies_to'] === 'all')) {
$offerLabel = 'عرض مجلس الإدارة: ' . $bestOffer['title_ar'];
if ($bestOffer['cash_discount_type'] === 'percentage') {
$offerLabel .= ' (خصم ' . $bestOffer['cash_discount_value'] . '% كاش)';
} elseif ($bestOffer['cash_discount_type'] === 'fixed_amount') {
$offerLabel .= ' (خصم ' . number_format((float) $bestOffer['cash_discount_value'], 2) . ' ج.م كاش)';
}
$items[] = [ $items[] = [
'type' => 'board_offer', 'type' => 'board_offer',
'label' => 'عرض مجلس الإدارة: ' . $cashOffer['title_ar'] . ' (' . $cashOffer['discount_percentage'] . '%)', 'label' => $offerLabel,
'amount' => '0.00', 'amount' => '0.00',
'paid' => false, 'paid' => false,
'included' => false, 'included' => false,
'category' => 'offer', 'category' => 'offer',
'offer' => $cashOffer, 'offer' => $bestOffer,
]; ];
} }
......
...@@ -4,77 +4,177 @@ declare(strict_types=1); ...@@ -4,77 +4,177 @@ declare(strict_types=1);
namespace App\Modules\Members\Services; namespace App\Modules\Members\Services;
use App\Core\App; use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
final class BoardOfferService final class BoardOfferService
{ {
public static function getActiveOffers(string $type = ''): array public static function getActiveOffers(?int $branchId = null): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$today = date('Y-m-d'); $today = date('Y-m-d');
$where = "is_active = 1 AND effective_from <= ? AND effective_to >= ?"; $where = "`is_active` = 1 AND `effective_from` <= ? AND `effective_to` >= ?";
$params = [$today, $today]; $params = [$today, $today];
if ($type !== '') { if ($branchId !== null) {
$where .= " AND offer_type = ?"; $where .= " AND (`branch_id` IS NULL OR `branch_id` = ?)";
$params[] = $type; $params[] = $branchId;
} }
return $db->select("SELECT * FROM board_offers WHERE {$where} ORDER BY effective_from DESC", $params); return $db->select(
"SELECT * FROM `board_offers` WHERE {$where} ORDER BY `branch_id` DESC, `effective_from` DESC",
$params
);
} }
public static function getCashDiscount(): ?array public static function getBestOffer(?int $branchId = null): ?array
{ {
$offers = self::getActiveOffers('cash_discount'); $offers = self::getActiveOffers($branchId);
return $offers[0] ?? null; if (empty($offers)) {
return null;
}
// Branch-specific first (branch_id DESC puts non-null first)
return $offers[0];
} }
public static function getInstallmentTermsOverride(): ?array public static function getCashDiscount(string $amount, ?array $offer = null): array
{ {
$offers = self::getActiveOffers('installment_terms'); if ($offer === null) {
return $offers[0] ?? null; $offer = self::getBestOffer();
} }
public static function getSubscriptionDiscount(): ?array if (!$offer || !$offer['cash_discount_type'] || !$offer['cash_discount_value']) {
{ return [
$offers = self::getActiveOffers('subscription_discount'); 'original' => $amount,
return $offers[0] ?? null; 'discounted' => $amount,
'savings' => '0.00',
'type' => null,
'value' => null,
'offer' => null,
];
} }
public static function applyCashDiscount(string $amount): array $savings = '0.00';
{ if ($offer['cash_discount_type'] === 'percentage') {
$offer = self::getCashDiscount(); $savings = bcdiv(bcmul($amount, (string) $offer['cash_discount_value'], 4), '100', 2);
if (!$offer) { } else {
return ['amount' => $amount, 'discount' => '0.00', 'offer' => null]; $savings = (string) $offer['cash_discount_value'];
if (bccomp($savings, $amount, 2) > 0) {
$savings = $amount;
}
} }
$pct = $offer['discount_percentage'] ?? '0'; $discounted = bcsub($amount, $savings, 2);
$discount = bcdiv(bcmul($amount, $pct, 4), '100', 2); if (bccomp($discounted, '0', 2) < 0) {
$final = bcsub($amount, $discount, 2); $discounted = '0.00';
}
return [ return [
'amount' => $final, 'original' => $amount,
'discount' => $discount, 'discounted' => $discounted,
'savings' => $savings,
'type' => $offer['cash_discount_type'],
'value' => $offer['cash_discount_value'],
'offer' => $offer, 'offer' => $offer,
]; ];
} }
public static function getInstallmentTerms(?array $offer = null): array
{
$interestData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$minDownData = RuleEngine::get('INSTALLMENT_MIN_DOWN_PAYMENT');
$maxMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
$defaults = [
'down_pct' => $minDownData['percentage'] ?? '25.00',
'max_months' => (int) ($maxMonthsData['months'] ?? 30),
'interest_rate' => $interestData['percentage'] ?? '22.00',
'grace_type' => null,
'grace_months' => 0,
'post_grace_rate' => null,
'offer' => null,
];
if (!$offer) {
return $defaults;
}
$terms = $defaults;
$terms['offer'] = $offer;
if ($offer['inst_down_payment_pct'] !== null) {
$terms['down_pct'] = (string) $offer['inst_down_payment_pct'];
}
if ($offer['inst_months'] !== null) {
$terms['max_months'] = (int) $offer['inst_months'];
}
if ($offer['inst_interest_rate'] !== null) {
$terms['interest_rate'] = (string) $offer['inst_interest_rate'];
}
if ($offer['inst_grace_type'] !== null) {
$terms['grace_type'] = $offer['inst_grace_type'];
$terms['grace_months'] = (int) $offer['inst_grace_months'];
}
if ($offer['inst_post_grace_rate'] !== null) {
$terms['post_grace_rate'] = (string) $offer['inst_post_grace_rate'];
}
return $terms;
}
public static function getInstallmentOverrides(): array public static function getInstallmentOverrides(): array
{ {
$offer = self::getInstallmentTermsOverride(); $offer = self::getBestOffer();
if (!$offer) { if (!$offer) {
return []; return [];
} }
$overrides = []; $overrides = [];
if ($offer['custom_months']) { if ($offer['inst_months'] !== null) {
$overrides['max_months'] = (int) $offer['custom_months']; $overrides['max_months'] = (int) $offer['inst_months'];
}
if ($offer['inst_interest_rate'] !== null) {
$overrides['interest_rate'] = (string) $offer['inst_interest_rate'];
}
if ($offer['inst_down_payment_pct'] !== null) {
$overrides['min_down_pct'] = (string) $offer['inst_down_payment_pct'];
}
if ($offer['inst_grace_type'] !== null) {
$overrides['grace_type'] = $offer['inst_grace_type'];
$overrides['grace_months'] = (int) $offer['inst_grace_months'];
if ($offer['inst_post_grace_rate'] !== null) {
$overrides['post_grace_rate'] = (string) $offer['inst_post_grace_rate'];
} }
if ($offer['custom_interest_rate'] !== null) {
$overrides['interest_rate'] = $offer['custom_interest_rate'];
} }
$overrides['offer'] = $offer; $overrides['offer'] = $offer;
return $overrides; return $overrides;
} }
public static function snapshotOffer(array $offer): string
{
return json_encode([
'id' => $offer['id'],
'title_ar' => $offer['title_ar'],
'cash_discount_type' => $offer['cash_discount_type'],
'cash_discount_value' => $offer['cash_discount_value'],
'inst_down_payment_pct' => $offer['inst_down_payment_pct'],
'inst_months' => $offer['inst_months'],
'inst_interest_rate' => $offer['inst_interest_rate'],
'inst_grace_type' => $offer['inst_grace_type'],
'inst_grace_months' => $offer['inst_grace_months'],
'inst_post_grace_rate' => $offer['inst_post_grace_rate'],
'effective_from' => $offer['effective_from'],
'effective_to' => $offer['effective_to'],
], JSON_UNESCAPED_UNICODE);
}
public static function restoreFromSnapshot(string $json): ?array
{
$data = json_decode($json, true);
if (!is_array($data)) {
return null;
}
return $data;
}
} }
This diff is collapsed.
...@@ -214,47 +214,75 @@ final class PaymentLifecycleService ...@@ -214,47 +214,75 @@ final class PaymentLifecycleService
if (bccomp($remaining, '0', 2) <= 0) return; if (bccomp($remaining, '0', 2) <= 0) return;
$months = min(30, max(1, (int) ($requestData['installment_months'] ?? 30))); // Resolve installment terms from offer snapshot (if any) or defaults
$offerSnapshot = null;
$boardOfferId = null;
$graceMonths = 0;
$postGraceRate = null;
if (!empty($requestData['offer_snapshot_json'])) {
$offerSnapshot = \App\Modules\Members\Services\BoardOfferService::restoreFromSnapshot($requestData['offer_snapshot_json']);
$boardOfferId = (int) ($requestData['board_offer_id'] ?? $offerSnapshot['id'] ?? 0) ?: null;
}
$offerOverrides = [];
if ($offerSnapshot) {
if ($offerSnapshot['inst_months'] !== null) $offerOverrides['max_months'] = (int) $offerSnapshot['inst_months'];
if ($offerSnapshot['inst_interest_rate'] !== null) $offerOverrides['interest_rate'] = (string) $offerSnapshot['inst_interest_rate'];
if ($offerSnapshot['inst_down_payment_pct'] !== null) $offerOverrides['min_down_pct'] = (string) $offerSnapshot['inst_down_payment_pct'];
if ($offerSnapshot['inst_grace_type'] !== null) {
$offerOverrides['grace_type'] = $offerSnapshot['inst_grace_type'];
$offerOverrides['grace_months'] = (int) $offerSnapshot['inst_grace_months'];
$graceMonths = (int) $offerSnapshot['inst_grace_months'];
if ($offerSnapshot['inst_post_grace_rate'] !== null) {
$offerOverrides['post_grace_rate'] = (string) $offerSnapshot['inst_post_grace_rate'];
$postGraceRate = (string) $offerSnapshot['inst_post_grace_rate'];
}
}
}
$interestRateData = \App\Modules\Rules\Services\RuleEngine::get('INSTALLMENT_INTEREST_RATE'); $maxMonths = (int) ($offerOverrides['max_months'] ?? 30);
$interestRate = $interestRateData['percentage'] ?? '22.00'; $months = min($maxMonths, max(1, (int) ($requestData['installment_months'] ?? $maxMonths)));
$totalInterest = bcdiv(bcmul($remaining, $interestRate, 4), '100', 2);
$totalWithInterest = bcadd($remaining, $totalInterest, 2); $calc = \App\Modules\Installments\Services\InstallmentCalculator::calculate(
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2); $membershipValue, $amount, $months, date('Y-m-d'), $offerOverrides
);
if (!($calc['success'] ?? false)) {
Logger::error("PaymentLifecycleService: installment calc failed", ['errors' => $calc['errors'] ?? [], 'member_id' => $memberId]);
return;
}
$planId = $db->insert('installment_plans', [ $planId = $db->insert('installment_plans', [
'member_id' => $memberId, 'member_id' => $memberId,
'related_entity_type'=> 'members', 'related_entity_type' => 'members',
'related_entity_id' => $memberId, 'related_entity_id' => $memberId,
'total_amount' => $membershipValue, 'total_amount' => $membershipValue,
'down_payment' => $amount, 'down_payment' => $amount,
'remaining_balance' => $remaining, 'remaining_balance' => $calc['remaining_balance'],
'interest_rate' => $interestRate, 'interest_rate' => $calc['interest_rate'],
'total_interest' => $totalInterest, 'total_interest' => $calc['total_interest'],
'total_with_interest'=> $totalWithInterest, 'total_with_interest' => $calc['total_with_interest'],
'number_of_months' => $months, 'number_of_months' => $months,
'monthly_payment' => $monthlyPayment, 'monthly_payment' => $calc['monthly_payment'],
'start_date' => date('Y-m-d'), 'start_date' => date('Y-m-d'),
'status' => 'active', 'status' => 'active',
'board_offer_id' => $boardOfferId,
'grace_months' => $graceMonths,
'post_grace_interest_rate' => $postGraceRate,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]); ]);
$rem = $totalWithInterest; foreach ($calc['schedule'] as $row) {
for ($i = 1; $i <= $months; $i++) {
$principal = bcdiv($remaining, (string) $months, 2);
$interest = bcdiv($totalInterest, (string) $months, 2);
$instAmount = bcadd($principal, $interest, 2);
$rem = bcsub($rem, $instAmount, 2);
if (bccomp($rem, '0', 2) < 0) $rem = '0.00';
$db->insert('installment_schedule', [ $db->insert('installment_schedule', [
'installment_plan_id' => $planId, 'installment_plan_id' => $planId,
'installment_number' => $i, 'installment_number' => $row['number'],
'due_date' => date('Y-m-d', strtotime("+{$i} months")), 'due_date' => $row['due_date'],
'amount' => $instAmount, 'amount' => $row['amount'],
'principal' => $principal, 'principal' => $row['principal'],
'interest' => $interest, 'interest' => $row['interest'],
'remaining_after' => $rem, 'remaining_after' => $row['remaining_after'],
'paid_amount' => '0.00', 'paid_amount' => '0.00',
'status' => 'pending', 'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
...@@ -265,9 +293,9 @@ final class PaymentLifecycleService ...@@ -265,9 +293,9 @@ final class PaymentLifecycleService
EventBus::dispatch('installment.plan_created', [ EventBus::dispatch('installment.plan_created', [
'plan_id' => $planId, 'plan_id' => $planId,
'member_id' => $memberId, 'member_id' => $memberId,
'total_amount' => $totalWithInterest, 'total_amount' => $calc['total_with_interest'],
]); ]);
Logger::info("PaymentLifecycleService: installment plan created", ['plan_id' => $planId, 'member_id' => $memberId]); Logger::info("PaymentLifecycleService: installment plan created", ['plan_id' => $planId, 'member_id' => $memberId, 'grace_months' => $graceMonths]);
} }
} }
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Pricing\Models\BoardOffer;
class BoardOfferController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'is_active' => $request->get('is_active', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = BoardOffer::search($filters, 25, $page);
return $this->view('Pricing.Views.board_offers.index', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(Request $request): Response
{
$branches = $this->getBranches();
return $this->view('Pricing.Views.board_offers.form', [
'offer' => null,
'branches' => $branches,
]);
}
public function store(Request $request): Response
{
$data = $this->extractFormData($request);
$error = $this->validateOfferData($data);
if ($error) {
$session = App::getInstance()->session();
$session->flash('_old_input', $data);
return $this->redirect('/pricing/board-offers/create')->withError($error);
}
$employee = App::getInstance()->currentEmployee();
$data['created_by'] = $employee ? (int) $employee->id : null;
BoardOffer::create($data);
return $this->redirect('/pricing/board-offers')->withSuccess('تم إضافة عرض مجلس الإدارة بنجاح');
}
public function edit(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$offer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ?", [(int) $id]);
if (!$offer) {
return $this->redirect('/pricing/board-offers')->withError('العرض غير موجود');
}
$branches = $this->getBranches();
return $this->view('Pricing.Views.board_offers.form', [
'offer' => $offer,
'branches' => $branches,
]);
}
public function update(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$offer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ?", [(int) $id]);
if (!$offer) {
return $this->redirect('/pricing/board-offers')->withError('العرض غير موجود');
}
$data = $this->extractFormData($request);
$data['is_active'] = (int) $request->post('is_active', 1);
$error = $this->validateOfferData($data);
if ($error) {
$session = App::getInstance()->session();
$session->flash('_old_input', $data);
return $this->redirect("/pricing/board-offers/{$id}/edit")->withError($error);
}
$employee = App::getInstance()->currentEmployee();
$data['updated_by'] = $employee ? (int) $employee->id : null;
$data['updated_at'] = date('Y-m-d H:i:s');
$db->update('board_offers', $data, '`id` = ?', [(int) $id]);
return $this->redirect('/pricing/board-offers')->withSuccess('تم تحديث العرض بنجاح');
}
public function toggleActive(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$offer = $db->selectOne("SELECT * FROM `board_offers` WHERE `id` = ?", [(int) $id]);
if (!$offer) {
return $this->redirect('/pricing/board-offers')->withError('العرض غير موجود');
}
$newActive = $offer['is_active'] ? 0 : 1;
$db->update('board_offers', [
'is_active' => $newActive,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/pricing/board-offers')->withSuccess($newActive ? 'تم تفعيل العرض' : 'تم تعطيل العرض');
}
private function extractFormData(Request $request): array
{
$hasCash = (int) $request->post('has_cash_path', 0);
$hasInst = (int) $request->post('has_inst_path', 0);
return [
'title_ar' => trim((string) $request->post('title_ar', '')),
'title_en' => trim((string) $request->post('title_en', '')) ?: null,
'description' => trim((string) $request->post('description', '')) ?: null,
'cash_discount_type' => $hasCash ? ($request->post('cash_discount_type', null) ?: null) : null,
'cash_discount_value' => $hasCash ? (trim((string) $request->post('cash_discount_value', '')) ?: null) : null,
'inst_down_payment_pct' => $hasInst ? (trim((string) $request->post('inst_down_payment_pct', '')) ?: null) : null,
'inst_months' => $hasInst ? ((int) $request->post('inst_months', 0) ?: null) : null,
'inst_interest_rate' => $hasInst ? (trim((string) $request->post('inst_interest_rate', '')) !== '' ? trim((string) $request->post('inst_interest_rate', '')) : null) : null,
'inst_grace_type' => $hasInst ? ($request->post('inst_grace_type', '') ?: null) : null,
'inst_grace_months' => $hasInst ? (int) $request->post('inst_grace_months', 0) : 0,
'inst_post_grace_rate' => $hasInst ? (trim((string) $request->post('inst_post_grace_rate', '')) ?: null) : null,
'branch_id' => ((int) $request->post('branch_id', 0)) ?: null,
'applies_to' => $request->post('applies_to', 'membership_fee'),
'effective_from' => trim((string) $request->post('effective_from', '')),
'effective_to' => trim((string) $request->post('effective_to', '')),
'is_active' => 1,
'board_decision_number' => trim((string) $request->post('board_decision_number', '')) ?: null,
'board_decision_date' => trim((string) $request->post('board_decision_date', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
}
private function validateOfferData(array $data): ?string
{
if ($data['title_ar'] === '') {
return 'عنوان العرض بالعربي مطلوب';
}
if (empty($data['effective_from']) || empty($data['effective_to'])) {
return 'تاريخ البداية والانتهاء مطلوبان';
}
if ($data['effective_from'] > $data['effective_to']) {
return 'تاريخ البداية يجب أن يكون قبل تاريخ الانتهاء';
}
$hasCash = ($data['cash_discount_type'] !== null);
$hasInst = ($data['inst_months'] !== null || $data['inst_interest_rate'] !== null || $data['inst_down_payment_pct'] !== null || $data['inst_grace_type'] !== null);
if (!$hasCash && !$hasInst) {
return 'يجب تحديد مسار واحد على الأقل (كاش أو تقسيط)';
}
if ($hasCash) {
if (!in_array($data['cash_discount_type'], ['percentage', 'fixed_amount'], true)) {
return 'نوع خصم الكاش غير صالح';
}
$val = (float) ($data['cash_discount_value'] ?? 0);
if ($val <= 0) {
return 'قيمة خصم الكاش يجب أن تكون أكبر من صفر';
}
if ($data['cash_discount_type'] === 'percentage' && $val > 100) {
return 'نسبة خصم الكاش لا يمكن أن تتجاوز 100%';
}
}
if ($hasInst) {
if ($data['inst_down_payment_pct'] !== null) {
$dp = (float) $data['inst_down_payment_pct'];
if ($dp < 0 || $dp > 100) {
return 'نسبة المقدم يجب أن تكون بين 0 و 100';
}
}
if ($data['inst_interest_rate'] !== null) {
$ir = (float) $data['inst_interest_rate'];
if ($ir < 0 || $ir > 100) {
return 'نسبة الفائدة يجب أن تكون بين 0 و 100';
}
}
if ($data['inst_grace_type'] !== null) {
if (!in_array($data['inst_grace_type'], ['first_n_months', 'full_free_under_n'], true)) {
return 'نوع فترة السماح غير صالح';
}
if ($data['inst_grace_months'] < 1) {
return 'عدد أشهر السماح يجب أن يكون 1 على الأقل';
}
if ($data['inst_grace_type'] === 'first_n_months' && $data['inst_months'] !== null && $data['inst_grace_months'] >= (int) $data['inst_months']) {
return 'أشهر السماح يجب أن تكون أقل من إجمالي عدد الأقساط';
}
}
}
$validAppliesTo = ['membership_fee', 'all'];
if (!in_array($data['applies_to'], $validAppliesTo, true)) {
return 'مجال التطبيق غير صالح';
}
return null;
}
private function getBranches(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT `id`, `name_ar` FROM `branches` WHERE `is_active` = 1 ORDER BY `name_ar`");
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Models;
use App\Core\Model;
use App\Core\App;
class BoardOffer extends Model
{
protected static string $table = 'board_offers';
protected static bool $softDelete = false;
protected static bool $timestamps = true;
protected static array $fillable = [
'title_ar', 'title_en', 'description',
'cash_discount_type', 'cash_discount_value',
'inst_down_payment_pct', 'inst_months', 'inst_interest_rate',
'inst_grace_type', 'inst_grace_months', 'inst_post_grace_rate',
'branch_id', 'applies_to', 'effective_from', 'effective_to',
'is_active', 'board_decision_number', 'board_decision_date', 'notes',
'created_by', 'updated_by',
];
public static function getActiveOffers(?int $branchId = null): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$where = "`is_active` = 1 AND `effective_from` <= ? AND `effective_to` >= ?";
$params = [$today, $today];
if ($branchId !== null) {
$where .= " AND (`branch_id` IS NULL OR `branch_id` = ?)";
$params[] = $branchId;
}
return $db->select(
"SELECT * FROM `board_offers` WHERE {$where} ORDER BY `branch_id` DESC, `effective_from` DESC",
$params
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['search'])) {
$where .= ' AND (`title_ar` LIKE ? OR `title_en` LIKE ? OR `board_decision_number` LIKE ?)';
$s = '%' . $filters['search'] . '%';
$params[] = $s;
$params[] = $s;
$params[] = $s;
}
if (($filters['is_active'] ?? '') !== '') {
$where .= ' AND `is_active` = ?';
$params[] = (int) $filters['is_active'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM `board_offers` WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$data = $db->select(
"SELECT * FROM `board_offers` WHERE {$where} ORDER BY `effective_from` DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return [
'data' => $data,
'pagination' => \App\Core\Pagination::paginate($total, $perPage, $page),
];
}
}
...@@ -18,6 +18,14 @@ return [ ...@@ -18,6 +18,14 @@ return [
['GET', '/pricing/configs/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'], ['GET', '/pricing/configs/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'],
['POST', '/pricing/configs/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'], ['POST', '/pricing/configs/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'],
// Board Offers
['GET', '/pricing/board-offers', 'Pricing\Controllers\BoardOfferController@index', ['auth'], 'pricing.board_offers.view'],
['GET', '/pricing/board-offers/create', 'Pricing\Controllers\BoardOfferController@create', ['auth'], 'pricing.board_offers.create'],
['POST', '/pricing/board-offers', 'Pricing\Controllers\BoardOfferController@store', ['auth', 'csrf'], 'pricing.board_offers.create'],
['GET', '/pricing/board-offers/{id:\d+}/edit', 'Pricing\Controllers\BoardOfferController@edit', ['auth'], 'pricing.board_offers.edit'],
['POST', '/pricing/board-offers/{id:\d+}', 'Pricing\Controllers\BoardOfferController@update', ['auth', 'csrf'], 'pricing.board_offers.edit'],
['POST', '/pricing/board-offers/{id:\d+}/toggle', 'Pricing\Controllers\BoardOfferController@toggleActive', ['auth', 'csrf'], 'pricing.board_offers.edit'],
// Special Discounts // Special Discounts
['GET', '/pricing/special-discounts', 'Pricing\Controllers\SpecialDiscountController@index', ['auth'], 'pricing.special_discounts.view'], ['GET', '/pricing/special-discounts', 'Pricing\Controllers\SpecialDiscountController@index', ['auth'], 'pricing.special_discounts.view'],
['GET', '/pricing/special-discounts/create', 'Pricing\Controllers\SpecialDiscountController@create', ['auth'], 'pricing.special_discounts.create'], ['GET', '/pricing/special-discounts/create', 'Pricing\Controllers\SpecialDiscountController@create', ['auth'], 'pricing.special_discounts.create'],
......
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>عروض مجلس الإدارة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/pricing/board-offers/create" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:6px;">
<i data-lucide="plus" style="width:15px;height:15px;"></i> إضافة عرض جديد
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="padding:15px;margin-bottom:20px;">
<form method="GET" action="/pricing/board-offers" style="display:flex;gap:12px;align-items:end;flex-wrap:wrap;">
<div class="form-group" style="margin:0;flex:1;min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" class="form-input" placeholder="عنوان العرض أو رقم القرار...">
</div>
<div class="form-group" style="margin:0;width:150px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="is_active" class="form-select">
<option value="">الكل</option>
<option value="1" <?= ($filters['is_active'] ?? '') === '1' ? 'selected' : '' ?>>مفعّل</option>
<option value="0" <?= ($filters['is_active'] ?? '') === '0' ? 'selected' : '' ?>>معطّل</option>
</select>
</div>
<button type="submit" class="btn btn-outline" style="height:38px;">
<i data-lucide="search" style="width:14px;height:14px;"></i>
</button>
</form>
</div>
<!-- Table -->
<div class="card" style="overflow-x:auto;">
<table class="table">
<thead>
<tr>
<th>العرض</th>
<th>مسار الكاش</th>
<th>مسار التقسيط</th>
<th>الفرع</th>
<th>الفترة</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:40px;">لا توجد عروض حالياً</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr>
<td>
<strong style="color:#1F2937;"><?= e($row['title_ar']) ?></strong>
<?php if ($row['board_decision_number']): ?>
<br><small style="color:#6B7280;">قرار رقم: <?= e($row['board_decision_number']) ?></small>
<?php endif; ?>
</td>
<td>
<?php if ($row['cash_discount_type']): ?>
<span style="background:#ECFDF5;color:#065F46;padding:2px 8px;border-radius:4px;font-size:12px;">
<?php if ($row['cash_discount_type'] === 'percentage'): ?>
<?= e($row['cash_discount_value']) ?>%
<?php else: ?>
<?= money($row['cash_discount_value']) ?>
<?php endif; ?>
</span>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td>
<?php if ($row['inst_months'] || $row['inst_interest_rate'] !== null || $row['inst_down_payment_pct'] !== null): ?>
<div style="font-size:12px;line-height:1.6;">
<?php if ($row['inst_down_payment_pct'] !== null): ?>
<span style="background:#EFF6FF;color:#1E40AF;padding:1px 6px;border-radius:3px;">مقدم <?= e($row['inst_down_payment_pct']) ?>%</span>
<?php endif; ?>
<?php if ($row['inst_months']): ?>
<span style="background:#FEF3C7;color:#92400E;padding:1px 6px;border-radius:3px;"><?= (int) $row['inst_months'] ?> شهر</span>
<?php endif; ?>
<?php if ($row['inst_interest_rate'] !== null): ?>
<span style="background:#FEE2E2;color:#991B1B;padding:1px 6px;border-radius:3px;">فائدة <?= e($row['inst_interest_rate']) ?>%</span>
<?php endif; ?>
<?php if ($row['inst_grace_months'] > 0): ?>
<br><span style="background:#F3E8FF;color:#6B21A8;padding:1px 6px;border-radius:3px;margin-top:3px;display:inline-block;">
<?= (int) $row['inst_grace_months'] ?> شهر سماح
<?= $row['inst_grace_type'] === 'full_free_under_n' ? '(إعفاء كامل)' : '' ?>
</span>
<?php endif; ?>
</div>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td>
<?php if ($row['branch_id']): ?>
<span style="font-size:12px;"><?= e($row['branch_name'] ?? 'فرع #' . $row['branch_id']) ?></span>
<?php else: ?>
<span style="color:#6B7280;font-size:12px;">كل الفروع</span>
<?php endif; ?>
</td>
<td style="font-size:12px;white-space:nowrap;">
<?= e($row['effective_from']) ?><br>
<span style="color:#6B7280;">إلى</span> <?= e($row['effective_to']) ?>
</td>
<td>
<?php
$today = date('Y-m-d');
$isExpired = $row['effective_to'] < $today;
$isActive = $row['is_active'] && !$isExpired;
?>
<?php if ($isActive): ?>
<span style="background:#D1FAE5;color:#065F46;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;">مفعّل</span>
<?php elseif ($isExpired): ?>
<span style="background:#FEE2E2;color:#991B1B;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;">منتهي</span>
<?php else: ?>
<span style="background:#F3F4F6;color:#6B7280;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;">معطّل</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<a href="/pricing/board-offers/<?= (int) $row['id'] ?>/edit" class="btn btn-sm btn-outline" title="تعديل">
<i data-lucide="pencil" style="width:13px;height:13px;"></i>
</a>
<form method="POST" action="/pricing/board-offers/<?= (int) $row['id'] ?>/toggle" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" title="<?= $row['is_active'] ? 'تعطيل' : 'تفعيل' ?>">
<i data-lucide="<?= $row['is_active'] ? 'pause' : 'play' ?>" style="width:13px;height:13px;"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (!empty($pagination) && $pagination['total_pages'] > 1): ?>
<div style="display:flex;justify-content:center;gap:6px;margin-top:20px;">
<?php for ($p = 1; $p <= $pagination['total_pages']; $p++): ?>
<a href="?page=<?= $p ?>&q=<?= urlencode($filters['search'] ?? '') ?>&is_active=<?= urlencode($filters['is_active'] ?? '') ?>"
class="btn btn-sm <?= $p === $pagination['current_page'] ? 'btn-primary' : 'btn-outline' ?>">
<?= $p ?>
</a>
<?php endfor; ?>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
...@@ -15,16 +15,20 @@ MenuRegistry::register('pricing', [ ...@@ -15,16 +15,20 @@ MenuRegistry::register('pricing', [
'order' => 850, 'order' => 850,
'children' => [ 'children' => [
['label_ar' => 'لوحة التسعير', 'label_en' => 'Pricing Dashboard', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 1], ['label_ar' => 'لوحة التسعير', 'label_en' => 'Pricing Dashboard', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 1],
['label_ar' => 'الخصومات الخاصة', 'label_en' => 'Special Discounts', 'route' => '/pricing/special-discounts', 'permission' => 'pricing.special_discounts.view', 'order' => 2], ['label_ar' => 'عروض مجلس الإدارة', 'label_en' => 'Board Offers', 'route' => '/pricing/board-offers', 'permission' => 'pricing.board_offers.view', 'order' => 2],
['label_ar' => 'الخصومات الخاصة', 'label_en' => 'Special Discounts', 'route' => '/pricing/special-discounts', 'permission' => 'pricing.special_discounts.view', 'order' => 3],
], ],
]); ]);
PermissionRegistry::register('pricing', [ PermissionRegistry::register('pricing', [
'pricing.view' => ['ar' => 'عرض لوحة التسعير', 'en' => 'View Pricing Dashboard'], 'pricing.view' => ['ar' => 'عرض لوحة التسعير', 'en' => 'View Pricing Dashboard'],
'pricing.edit' => ['ar' => 'تعديل الأسعار والرسوم', 'en' => 'Edit Prices & Fees'], 'pricing.edit' => ['ar' => 'تعديل الأسعار والرسوم', 'en' => 'Edit Prices & Fees'],
'pricing.board_offers.view' => ['ar' => 'عرض عروض مجلس الإدارة', 'en' => 'View Board Offers'],
'pricing.board_offers.create' => ['ar' => 'إنشاء عرض مجلس إدارة', 'en' => 'Create Board Offer'],
'pricing.board_offers.edit' => ['ar' => 'تعديل عروض مجلس الإدارة', 'en' => 'Edit Board Offers'],
'pricing.special_discounts.view' => ['ar' => 'عرض الخصومات الخاصة', 'en' => 'View Special Discounts'], 'pricing.special_discounts.view' => ['ar' => 'عرض الخصومات الخاصة', 'en' => 'View Special Discounts'],
'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'], 'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'],
'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة','en' => 'Edit Special Discounts'], 'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة', 'en' => 'Edit Special Discounts'],
]); ]);
// When a member is activated, check if their discount includes free subscription bonus // When a member is activated, check if their discount includes free subscription bonus
......
<?php
declare(strict_types=1);
return [
'up' => "
DROP TABLE IF EXISTS `board_offers`;
CREATE TABLE `board_offers` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`title_ar` VARCHAR(200) NOT NULL,
`title_en` VARCHAR(200) NULL,
`description` TEXT NULL,
`cash_discount_type` ENUM('percentage','fixed_amount') NULL,
`cash_discount_value` DECIMAL(15,2) NULL,
`inst_down_payment_pct` DECIMAL(5,2) NULL,
`inst_months` INT NULL,
`inst_interest_rate` DECIMAL(5,2) NULL,
`inst_grace_type` ENUM('first_n_months','full_free_under_n') NULL,
`inst_grace_months` INT NOT NULL DEFAULT 0,
`inst_post_grace_rate` DECIMAL(5,2) NULL,
`branch_id` BIGINT UNSIGNED NULL,
`applies_to` ENUM('membership_fee','all') NOT NULL DEFAULT 'membership_fee',
`effective_from` DATE NOT NULL,
`effective_to` DATE NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`board_decision_number` VARCHAR(50) NULL,
`board_decision_date` DATE NULL,
`notes` TEXT NULL,
`created_by` INT UNSIGNED NULL,
`updated_by` INT UNSIGNED NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS `board_offers`",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `payment_requests`
ADD COLUMN `board_offer_id` INT UNSIGNED NULL,
ADD COLUMN `offer_snapshot_json` TEXT NULL",
'down' => "
ALTER TABLE `payment_requests`
DROP COLUMN `offer_snapshot_json`,
DROP COLUMN `board_offer_id`",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `installment_plans`
ADD COLUMN `board_offer_id` INT UNSIGNED NULL,
ADD COLUMN `grace_months` INT NULL DEFAULT 0,
ADD COLUMN `post_grace_interest_rate` DECIMAL(5,2) NULL",
'down' => "
ALTER TABLE `installment_plans`
DROP COLUMN `post_grace_interest_rate`,
DROP COLUMN `grace_months`,
DROP COLUMN `board_offer_id`",
];
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