field mapping + final static text * - "Non Solar Borrower Agreement Draft.docx" -> letter framing (address block, * subject, greeting, closing declaration) * Where the two disagree the xlsx wins, since it resolves the docx's "TBC" rows. * * Excel row -> DB mapping * ---------------------------------------------------------------------------- * R2 Sanction Letter Date status_logs.created_at WHERE status='approved' * R3 Loan Application Date status_logs.created_at WHERE status='form completed' * (fallback consumer_application.created_at) * R4 Merchant's Business ID merchant.id * R5 Application ID consumer_application.id * R6 Borrower consumer_application.name * R7 Co-Borrower consumer_application.co_applicant_name * R8 Guarantor static "NA" * R9 Lender lenders.name (via lender_id) * R10 Merchant merchant.merchant_name * R11 permanent_address_line_1 consumer_application.permanent_address_line_1 * R12 permanent_address_state consumer_application.permanent_address_state * R13 "pemanent_address_city" consumer_application.permanent_address_city [sic: xlsx typo] * R14 permanent_address_pincode consumer_application.permanent_address_pincode * R15 permanent_address_landmark consumer_application.permanent_address_landmark * R16 mobile_number consumer_application.mobile_number * R17 email consumer_application.email * R20 Purpose merchant_category.name (ConsumerApplication::category) * R21 Loan amount emi_calculation.net_loan_amount * (fallback consumer_application.approved_amount) * R22 Disbursement Schedule disbursement_offers.phase + per_disbursement_amount * R23 Tenure emi_calculation.tenure * R25 Due Date emi_calculation.emi_date (day of month) * R27 Interest Rate emi_calculation.annualised_interest * + merchant_offers.interest_type_id * R34 Documentation charges emi_calculation.legal_charges (stamp paper only; * CERSAI charges are intentionally NOT included) * R40 APR computed, same formula as blacksoil/kfs.blade.php * R48 Processing Fee emi_calculation.processing_fee * R52 Merchant's Bank Account merchant_bank.account_holder / account_number / ifsc_code * R58 Notice - Borrower consumer_application.name * R59 Notice - Co-Borrower consumer_application.co_applicant_name */ class NonSolarSanctionLetterData { /** Standard key used to pass this payload to views and controllers. */ const PAYLOAD_KEY = 'nonsolar_sanction_letter'; /** Static values defined by the Excel format. */ const PLATFORM = 'Credit Fair'; const TYPE_OF_LOAN = 'Term Loan'; const INSTALMENTS = 'Equated Monthly Instalments'; const COOLING_OFF = '4 days'; const NOT_APPLICABLE = 'NA'; /** The two non-solar view names, so callers do not repeat the strings. */ const SANCTION_LETTER_VIEW = 'admin.documents.blacksoil.non_solar_sanction_letter'; const LOAN_AGREEMENT_VIEW = 'admin.documents.blacksoil.non_solar_loan_agreement'; /** Schedule I, Part B additions (loan agreement only). */ const PLACE_OF_EXECUTION = 'Delhi'; const EXECUTION_DATE = 'The Execution date shall be the date of the signature by the last signatory'; /** * Interest rate suffix, matching non-solar KFS point 6(a) verbatim so the * sanction letter and the KFS read identically: * * KFS 6(a) "Interest rate (%) and type (fixed or floating or hybrid)" * -> "1.71% (Reducing, Fixed)" * Sanction letter "Interest Rate (exclusive of applicable taxes)" * -> "1.71% (Reducing, Fixed)" * * "Reducing" is the computation method and "Fixed" answers the * fixed/floating/hybrid question, so this is not derived from * merchant_offers.interest_type_id - the KFS hardcodes it the same way. */ const INTEREST_RATE_TYPE = '(Reducing, Fixed)'; /** * Should the NON-SOLAR sanction letter be used for this application? * True only for BlackSoil (lender 20) applications in a non-solar category. * * Pass $application when the caller already has the model, to avoid a query. * The $applicationId fallback matters because several render paths do not put * an 'application' key in the view payload - LoanHypothecation.php:425 and * DigioHypothecation.php:729 both render blacksoil.loan_agreement_v1 (which * embeds the sanction letter) with only 'id' available. Relying on * isset($application) alone would silently fall back to the solar letter * on those paths. * * @param int $applicationId * @param \App\ConsumerApplication|null $application * @return bool */ public static function appliesTo($applicationId, $application = null) { if (!$application instanceof ConsumerApplication) { $application = ConsumerApplication::with('merchant')->find($applicationId); } if ($application && $application->lender_id == \App\BlackSoil::$lender_id) { return $application->isNonSolar(); } return false; } /** * Build the view payload for the non-solar sanction letter. * * @param int $applicationId * @return array|null null when the application (or its EMI row) is missing */ public static function build($applicationId) { $application = ConsumerApplication::with([ 'emi', 'emi.offer', 'merchant', 'merchant.merchant_category', 'merchant_bank_details', 'application_element.merchant_center.bank_details', 'application_element', 'category', 'lender', ])->whereNull('is_deleted')->find($applicationId); if (!isset($application->id) || !isset($application->emi)) { return null; } $emi = $application->emi; $offer = isset($emi->offer) ? $emi->offer : null; $element = isset($application->application_element) ? $application->application_element : null; $bank = null; if (isset($application->application_element->merchant_center->bank_details)) { $bank = $application->application_element->merchant_center->bank_details; } elseif (isset($application->merchant_bank_details)) { $bank = $application->merchant_bank_details; } $borrowerAddress = self::permanentAddress($application); $coBorrowerAddress = self::coApplicantAddress($application); return [ // ---- Letter framing (docx) ------------------------------------ 'letter_date' => Carbon::now()->format('d/m/Y'), 'salutation' => ($application->gender == 'MALE') ? 'Mr.' : 'Ms.', // ---- Borrower ------------------------------------------------- 'borrower_name' => $application->name, 'borrower_father_name' => $element ? $element->father_name : '', 'borrower_address' => $borrowerAddress, 'borrower_address_string' => self::formatAddress($borrowerAddress), 'borrower_mobile' => $application->mobile_number, 'borrower_email' => $application->email, // ---- Co-borrower ---------------------------------------------- 'has_co_borrower' => !empty($application->co_applicant_name), 'co_borrower_name' => $application->co_applicant_name, 'co_borrower_father_name' => $element ? $element->co_applicant_father_name : '', 'co_borrower_address' => $coBorrowerAddress, 'co_borrower_address_string' => self::formatAddress($coBorrowerAddress), 'co_borrower_mobile' => $application->co_applicant_mobile_number, 'co_borrower_email' => $application->co_applicant_email, // ---- Terms table ---------------------------------------------- 'sanction_letter_date' => self::approvalDate($applicationId), 'loan_application_date' => self::loginDate($application), 'merchant_business_id' => isset($application->merchant->id) ? $application->merchant->id : '', 'application_id' => $application->id, 'guarantor' => self::NOT_APPLICABLE, 'lender_name' => isset($application->lender->name) ? $application->lender->name : '', // Shown above the sign-off. The stored URL contains unencoded spaces // (".../BlackSoil Capital Private Limited/....png"), which wkhtmltopdf // will not fetch as-is, so the path is percent-encoded here. 'lender_logo' => self::encodeUrl(isset($application->lender->logo) ? $application->lender->logo : ''), 'merchant_name' => isset($application->merchant->merchant_name) ? $application->merchant->merchant_name : $application->src_merchant_name, // The merchant's OWN contact details, shown in the "Merchant" row. 'merchant_address' => self::merchantAddress($application), 'merchant_mobile' => isset($application->merchant->contact_number) ? $application->merchant->contact_number : '', 'merchant_email' => isset($application->merchant->email) ? $application->merchant->email : '', 'platform' => self::PLATFORM, 'type_of_loan' => self::TYPE_OF_LOAN, 'purpose' => self::purpose($application), 'loan_amount' => self::money( isset($emi->net_loan_amount) && $emi->net_loan_amount > 0 ? $emi->net_loan_amount : $application->approved_amount ), 'disbursement_schedule' => self::disbursementSchedule($application), 'tenure' => $emi->tenure, 'instalments' => self::INSTALMENTS, 'emi_due_day' => self::emiDueDay($emi), 'interest_rate' => self::interestRate($emi, $offer), 'documentation_charges' => self::money(isset($emi->legal_charges) ? $emi->legal_charges : 0), 'insurance_charges' => self::NOT_APPLICABLE, 'other_upfront_charges' => self::NOT_APPLICABLE, 'apr' => self::apr($application, $emi), 'processing_fee' => self::money(isset($emi->processing_fee) ? $emi->processing_fee : 0), 'merchant_bank' => [ 'account_holder' => $bank ? ($bank->account_holder ?: '') : '', 'bank_name' => $bank ? ($bank->bank_name ?: '') : '', 'branch_name' => $bank ? ($bank->branch_name ?: '') : '', 'account_number' => $bank ? ($bank->account_number ?: '') : '', 'ifsc_code' => $bank ? ($bank->ifsc_code ?: '') : '', ], 'cooling_off_period' => self::COOLING_OFF, // ---- Loan agreement only (Schedule I) ------------------------- // Part B of Schedule I reuses the whole terms table above and adds // these two rows. Place of execution is Delhi per business // instruction; the reference docx said "Gurgaon, Haryana". 'place_of_execution' => self::PLACE_OF_EXECUTION, 'execution_date' => self::EXECUTION_DATE, 'schedule_i' => self::scheduleOnePartA($application, $element), ]; } /** * SCHEDULE I, PART A - DETAILS OF BORROWERS. * * Three blocks, per "Schedule 1.xlsx" ("Final Schedule" sheet): * borrower / co_borrower / guarantor (guarantor is NA throughout). * * Note this part uses the CURRENT address, whereas the sanction letter body * uses the PERMANENT address - that difference is in the spec, not a slip. * * @return array */ private static function scheduleOnePartA($application, $element) { return [ 'borrower' => [ 'name' => $application->name, 'constitution' => self::constitution($element), 'father_name' => $element ? $element->father_name : '', 'residential_address' => self::currentAddress($application), 'contact' => $application->mobile_number, 'address_for_business' => self::businessAddress($application), 'dob' => self::date($application->dob), 'pan' => $application->pan_card, // xlsx row 14 is a bare "BUSINESS" label with no mapped source. 'business' => '', ], 'co_borrower' => [ 'name' => $application->co_applicant_name, 'father_name' => $element ? $element->co_applicant_father_name : '', // The xlsx maps this to current_address_* (the BORROWER's // address), which would just repeat the borrower's address. // Using the co-applicant's own address columns instead. 'residential_address' => self::coApplicantAddress($application), 'contact' => $application->co_applicant_mobile_number, 'address_for_business' => self::NOT_APPLICABLE, 'dob' => self::date($application->co_applicant_dob), 'pan' => $application->co_applicant_pan_card, ], // Guarantor is NA throughout for non-solar. 'guarantor' => self::NOT_APPLICABLE, ]; } /** * Percent-encode the path of a URL while leaving the scheme/host and any * already-encoded sequences alone. Needed because lenders.logo is stored * with literal spaces in the path. */ private static function encodeUrl($url) { if (empty($url)) { return ''; } $parts = parse_url($url); if (empty($parts['host']) || empty($parts['path'])) { return $url; } $path = implode('/', array_map(function ($segment) { return rawurlencode(rawurldecode($segment)); }, explode('/', $parts['path']))); $scheme = isset($parts['scheme']) ? $parts['scheme'] : 'https'; $url = $scheme . '://' . $parts['host'] . $path; if (!empty($parts['query'])) { $url .= '?' . $parts['query']; } return $url; } private static function date($value) { if (empty($value) || $value == '0000-00-00') { return ''; } try { return Carbon::parse($value)->format('d/m/Y'); } catch (\Exception $e) { return ''; } } /** * CONSTITUTION - the "Type of Entity" captured on the application, e.g. * "Individual", "Proprietorships", "Partnership Firm/LLP". * * `consumer_application_element.type_of_entity` is the field business means. * It is frequently null, so this falls back to * `type_of_customer_application` ('Individual' / 'Non-individual'). */ private static function constitution($element) { if (!$element) { return ''; } if (!empty($element->type_of_entity)) { return $element->type_of_entity; } return $element->type_of_customer_application ?: ''; } /** * ADDRESS FOR BUSINESS - the borrower's work / business / company address. * * Tries the structured work_address_* columns first, then the free-text * contact_address. Returns '' when nothing is on file, which is the common * case: as of this writing none of the 40 BlackSoil non-solar applications * has work_address_line_1 or contact_address populated, so this row * normally renders blank - matching the "Blank for non - solar" note in * Schedule 1.xlsx. */ private static function businessAddress($application) { $line1 = trim((string) $application->work_address_line_1); $city = trim((string) $application->work_address_city); // Only treat the work address as usable when there is a street line or a // city. Several records hold nothing but a stray pincode (line_1 is " "), // and emitting "400025" on its own would read as a broken address. if ($line1 !== '' || $city !== '') { $parts = array_filter([ $line1, trim((string) $application->work_address_landmark), $city, trim((string) $application->work_address_state), trim((string) $application->work_address_pincode), ], function ($v) { return $v !== ''; }); return implode(', ', $parts); } // Deliberately NOT falling back to company_name: it is a name rather than // an address, and in practice it holds junk on these records ("3", "999"). return trim((string) $application->contact_address); } private static function currentAddress($application) { return [ 'line_1' => $application->current_address_line_1, 'state' => $application->current_address_state, 'city' => $application->current_address_city, 'pincode' => $application->current_address_pincode, 'landmark' => $application->current_address_landmark, ]; } /** * Sanction letter date = when the application was approved. * `approval_date` is not a column; the established pattern in this codebase * reads the latest 'approved' entry from status_logs. */ private static function approvalDate($applicationId) { $log = StatusLogs::where('application_id', $applicationId) ->where('status', 'approved') ->orderBy('created_at', 'desc') ->first(['created_at']); return isset($log->created_at) ? Carbon::parse($log->created_at)->format('d/m/Y') : ''; } /** * Loan application date = when the borrower completed the form. * `login_date` is not a column either; falls back to the application's * created_at, which is what ConsumerDocument::getDocument() uses. */ private static function loginDate($application) { $log = StatusLogs::where('application_id', $application->id) ->whereIn('status', ['form completed', 'form_completed']) ->orderBy('created_at', 'desc') ->first(['created_at']); $date = isset($log->created_at) ? $log->created_at : $application->created_at; return $date ? Carbon::parse($date)->format('d/m/Y') : ''; } /** * Permanent address, falling back to the current address when the * permanent one was never captured (same rule as ConsumerDocument::getDocument()). */ private static function permanentAddress($application) { if (!empty($application->permanent_address_line_1)) { return [ 'line_1' => $application->permanent_address_line_1, 'state' => $application->permanent_address_state, 'city' => $application->permanent_address_city, 'pincode' => $application->permanent_address_pincode, 'landmark' => $application->permanent_address_landmark, ]; } return [ 'line_1' => $application->current_address_line_1, 'state' => $application->current_address_state, 'city' => $application->current_address_city, 'pincode' => $application->current_address_pincode, 'landmark' => $application->current_address_landmark, ]; } /** * The merchant's own registered address, from the `merchant` table. */ private static function merchantAddress($application) { $merchant = isset($application->merchant) ? $application->merchant : null; return [ 'line_1' => $merchant ? $merchant->address : '', 'state' => $merchant ? $merchant->state : '', 'city' => $merchant ? $merchant->city : '', 'pincode' => $merchant ? $merchant->pincode : '', 'landmark' => $merchant ? $merchant->landmark : '', ]; } private static function coApplicantAddress($application) { if (!empty($application->co_applicant_address_line_1)) { return [ 'line_1' => $application->co_applicant_address_line_1, 'state' => $application->co_applicant_address_state, 'city' => $application->co_applicant_address_city, 'pincode' => $application->co_applicant_address_pincode, 'landmark' => $application->co_applicant_address_landmark, ]; } // If co-applicant is present but has no separate address, fall back to borrower's address if (!empty($application->co_applicant_name)) { return self::permanentAddress($application); } return [ 'line_1' => '', 'state' => '', 'city' => '', 'pincode' => '', 'landmark' => '', ]; } /** * Formats an address array into a single readable string. * * @param array|string|null $address * @return string */ public static function formatAddress($address) { if (is_string($address)) { return trim($address); } if (!is_array($address)) { return ''; } $parts = []; if (!empty($address['line_1'])) { $parts[] = trim($address['line_1']); } if (!empty($address['landmark'])) { $parts[] = trim($address['landmark']); } $cityState = []; if (!empty($address['city'])) { $cityState[] = trim($address['city']); } if (!empty($address['state'])) { $cityState[] = trim($address['state']); } $cityStateStr = implode(', ', $cityState); if (!empty($address['pincode'])) { $cityStateStr .= ($cityStateStr !== '' ? ' - ' : '') . trim($address['pincode']); } if ($cityStateStr !== '') { $parts[] = $cityStateStr; } return implode(', ', $parts); } private static function purpose($application) { if (isset($application->merchant->merchant_category->name)) { return $application->merchant->merchant_category->name; } if (isset($application->category->name)) { return $application->category->name; } return $application->purpose; } /** * Disbursement schedule. * * NOTE: the Excel names `merchant_offers.disbursement_method` as the source, * but that column means "Adjust Advance Amount In First Phase" (1) vs * "Divide Advance Amount Across All Phases" (2) - it does not hold the * tranche split. The tranche split lives in `disbursement_offers` * (phase + per_disbursement_amount), which is what is read here, using the * same query as ConsumerDocument::getDocument(). * * Returns a list of strings, e.g. ['Tranche 1 - 80%', 'Tranche 2 - 20%'] * or ['Upfront'] for a single-tranche loan. Any number of tranches is * supported, per the Excel note about a possible third tranche. * * @return array */ private static function disbursementSchedule($application) { $phases = DisbursementOffers::where('application_id', $application->id) ->where('offer_id', $application->offer_id) ->whereNull('deleted_at') ->orderBy('phase', 'asc') ->get(['phase', 'per_disbursement_amount']); if ($phases->count() < 2) { return ['Upfront']; } $schedule = []; foreach ($phases as $index => $phase) { $label = 'Tranche ' . ($phase->phase ? $phase->phase : $index + 1); $schedule[] = $label . ' - ' . self::trimZeros($phase->per_disbursement_amount) . '%'; } return $schedule; } /** * Day of the month on which the EMI falls, as an ordinal ("5th"). */ private static function emiDueDay($emi) { return '5th'; } /** * Interest rate as "% ()", e.g. "18% (Reducing)". */ private static function interestRate($emi, $offer) { $rate = isset($emi->annualised_interest) ? self::trimZeros($emi->annualised_interest) : ''; if ($rate === '') { return ''; } return $rate . '% ' . self::INTEREST_RATE_TYPE; } /** * Annual Percentage Rate. * * Same formula as blacksoil/kfs.blade.php (lines 104-121) so the non-solar * letter and the KFS agree. * * TODO(business): when `merchant.is_gst` is 0, the KFS additionally adds the * schedule-summed subvention into $deductions before solving. That total is * only available from the repayment schedule, which this letter does not * render, so it is excluded here. For non-GST merchants this APR can * therefore differ from the KFS. Confirm the intended treatment before * this letter goes out for non-GST merchants. * * @return float */ private static function apr($application, $emi) { $loanAmount = $application->approved_amount; $deductions = $emi->processing_fee + $emi->upfront_from_customer; $financed = $loanAmount - $deductions; if ($financed <= 0 || empty($emi->remaining_emi) || empty($emi->emi)) { return 0; } $apr = round(Finance::rate($emi->remaining_emi, $emi->emi, -$financed, 0) * 100 * 12, 2); // Finance::rate() returns NAN when Newton's method does not converge. if (is_nan($apr) || $apr < 0) { return 0; } return $apr; } private static function money($amount) { return number_format((float) $amount, 2); } /** * "80.00" -> "80", "12.50" -> "12.5" */ private static function trimZeros($value) { return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.'); } }