charge when L <= B * condition = loan_amount_more_than, net_loan_amount = B -> charge when L > B * * The upper bound is inclusive. Anything that does not match a band carries no * legal charge, so a category with no rows, with no boundary set, or with only one * of the two bands configured resolves to 0 outside the band it does define. * * This table is the only source of truth for the amount. There are no hardcoded * thresholds or amounts in the resolvers. */ class MerchantCategoryLegalCharges extends Model { use SoftDeletes; protected $table = 'merchant_category_legal_charges'; protected $fillable = [ 'category_id', 'condition', 'net_loan_amount', 'legal_charges', 'status', 'created_by', 'updated_by', 'created_at', 'updated_at', ]; /** Band that applies at or below the boundary. */ const BAND_UPTO = 'loan_amount_less_than'; /** Band that applies above the boundary. */ const BAND_ABOVE = 'loan_amount_more_than'; /** * How many bands a category may define. Two today: at or below the boundary, * and above it. Raising this is the only change needed to allow more bands -- * the resolver below is written against an ordered list, not a fixed pair. */ const MAX_BANDS_PER_CATEGORY = 2; public static function bands() { return [self::BAND_UPTO, self::BAND_ABOVE]; } /** * Active, fully configured bands for a category, cheapest boundary first. * * A row with no boundary is not a usable band -- it cannot say which loan * amounts it covers -- so it is excluded rather than guessed at. * * @param int $categoryId * @return \Illuminate\Database\Eloquent\Collection */ public static function activeBandsForCategory($categoryId) { return self::where('category_id', $categoryId)->where('status', 1) ->whereNotNull('net_loan_amount')->whereIn('condition', self::bands()) ->orderBy('net_loan_amount')->get(); } /** * The configured charge for a loan amount, or 0 when no band covers it. * * @param int $categoryId * @param int|float $loanAmount * @return int */ public static function amountForLoanAmount($categoryId, $loanAmount) { $band = self::bandForLoanAmount($categoryId, $loanAmount); return $band ? (int) $band->legal_charges : 0; } /** * The band covering a loan amount, or null when none does. * * @param int $categoryId * @param int|float $loanAmount * @return \App\MerchantCategoryLegalCharges|null */ public static function bandForLoanAmount($categoryId, $loanAmount) { return self::selectBand(self::activeBandsForCategory($categoryId), $loanAmount); } /** * Pick the band covering a loan amount from an already loaded set. * * The at-or-below band is considered first so the boundary itself falls on the * inclusive side, matching how the condition has always been evaluated. Kept * free of queries so the selection rules can be exercised on their own. * * @param \Illuminate\Support\Collection|array $bands ordered by boundary ascending * @param int|float $loanAmount * @return \App\MerchantCategoryLegalCharges|null */ public static function selectBand($bands, $loanAmount) { // A band with no boundary cannot say which loan amounts it covers. Dropping // it here as well as in the query matters: a null boundary would otherwise // satisfy "amount > boundary" for every loan and charge the whole book. $bands = collect($bands)->reject(function ($band) { return is_null($band->net_loan_amount); })->values(); foreach ($bands as $band) { if ($band->condition === self::BAND_UPTO && $loanAmount <= $band->net_loan_amount) { return $band; } } foreach ($bands->reverse() as $band) { if ($band->condition === self::BAND_ABOVE && $loanAmount > $band->net_loan_amount) { return $band; } } return null; } /** * The charge for an application, honouring the stamp series exceptions. * * Applications listed in STAMP_SERIES_SPECIFIC_CASES are charged the top band * regardless of their loan amount. This is the single entry point for both the * EMI/disbursement resolver and the customer payment link, so the two cannot * drift apart again. * * @param int $categoryId * @param int|float $loanAmount * @param int|null $applicationId * @return int */ public static function amountForApplication($categoryId, $loanAmount, $applicationId = null) { if (self::isStampSeriesException($applicationId)) { return self::topBandAmount($categoryId); } return self::amountForLoanAmount($categoryId, $loanAmount); } /** * The amount on the highest configured band, or 0 when nothing is configured. * * @param int $categoryId * @return int */ public static function topBandAmount($categoryId) { return self::topAmountFromBands(self::activeBandsForCategory($categoryId)); } /** * The amount on the highest band of an already loaded set. * * @param \Illuminate\Support\Collection|array $bands * @return int */ public static function topAmountFromBands($bands) { $bands = collect($bands)->reject(function ($band) { return is_null($band->net_loan_amount); })->values(); if ($bands->isEmpty()) { return 0; } $above = $bands->where('condition', self::BAND_ABOVE)->last(); return (int) ($above ? $above->legal_charges : $bands->last()->legal_charges); } /** * Whether an application is a listed stamp series exception. * * @param int|null $applicationId * @return bool */ public static function isStampSeriesException($applicationId) { if (empty($applicationId)) { return false; } $exceptionalIds = array_filter(array_map('trim', explode(',', (string) _env('STAMP_SERIES_SPECIFIC_CASES'))), 'strlen'); return in_array((string) $applicationId, $exceptionalIds, true); } }