json([ 'success' => false, 'message' => 'You are not permitted to view legal charges.', ], 403); } return response()->json([ 'success' => true, 'can_edit' => true, 'data' => $this->configuration(), ]); } /** * Replace the configuration for the submitted categories. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\JsonResponse */ public function update(Request $request) { if (!Session::has(self::EDIT_PERMISSION)) { return response()->json([ 'success' => false, 'message' => 'You are not permitted to edit legal charges.', ], 403); } $validator = Validator::make($request->all(), [ 'data' => 'required|array', 'data.*.category_id' => 'required|integer|exists:merchant_category,id', 'data.*.boundary' => 'nullable|integer|min:0', 'data.*.amount_upto' => 'nullable|integer|min:0', 'data.*.amount_above' => 'nullable|integer|min:0', 'data.*.status' => 'required|boolean', ], [ 'data.*.category_id.exists' => 'One of the submitted categories no longer exists.', ]); if ($validator->fails()) { return response()->json([ 'success' => false, 'message' => $validator->errors()->first(), 'errors' => $validator->errors(), ], 422); } $rejected = $this->categoriesWithAmountButNoBoundary($request->get('data')); if (!empty($rejected)) { return response()->json([ 'success' => false, 'message' => 'A net loan amount boundary is required wherever a charge is entered: ' . implode(', ', $rejected) . '.', ], 422); } $userId = Auth::check() ? Auth::id() : null; $freeAboveBoundary = []; DB::transaction(function () use ($request, $userId, &$freeAboveBoundary) { foreach ($request->get('data') as $item) { $categoryId = (int) $item['category_id']; $boundary = $this->intOrNull(isset($item['boundary']) ? $item['boundary'] : null); $status = empty($item['status']) ? 0 : 1; // No boundary means the category is not configured. Clear both bands // so the resolver falls through to no charge, and keep the removal on // record rather than hard-deleting it as the previous save path did. if (is_null($boundary)) { $this->clearCategory($categoryId, $userId); continue; } $amounts = [ MerchantCategoryLegalCharges::BAND_UPTO => (int) $this->intOrNull(isset($item['amount_upto']) ? $item['amount_upto'] : null), MerchantCategoryLegalCharges::BAND_ABOVE => (int) $this->intOrNull(isset($item['amount_above']) ? $item['amount_above'] : null), ]; foreach ($amounts as $condition => $amount) { $this->persistBand($categoryId, $condition, $boundary, $amount, $status, $userId); } // Worth saying out loud: loans above the boundary carry no charge. // Before this change they were charged via a hardcoded threshold, so // an empty "above" amount is more likely an oversight than intent. if ($status === 1 && $amounts[MerchantCategoryLegalCharges::BAND_ABOVE] === 0) { $freeAboveBoundary[] = $categoryId; } } }); return response()->json([ 'success' => true, 'warnings' => $this->freeAboveBoundaryWarnings($freeAboveBoundary), ]); } /** * Names of categories that carry a charge but no boundary to apply it either * side of. Saving those would silently discard the amounts, so the whole * request is refused instead. * * @param array $data * @return array */ private function categoriesWithAmountButNoBoundary(array $data) { $categoryIds = []; foreach ($data as $item) { $boundary = $this->intOrNull(isset($item['boundary']) ? $item['boundary'] : null); $hasAmount = !is_null($this->intOrNull(isset($item['amount_upto']) ? $item['amount_upto'] : null)) || !is_null($this->intOrNull(isset($item['amount_above']) ? $item['amount_above'] : null)); if (is_null($boundary) && $hasAmount) { $categoryIds[] = (int) $item['category_id']; } } if (empty($categoryIds)) { return []; } return MerchantCategory::whereIn('id', $categoryIds)->pluck('name')->all(); } /** * Configuration grouped into one entry per category. * * Legacy duplicates are tolerated here by taking the most recent row for each * band; saving the category collapses them (see persistBand). * * @return array */ private function configuration() { $rows = MerchantCategoryLegalCharges::whereIn('condition', MerchantCategoryLegalCharges::bands()) ->orderBy('created_at')->orderBy('id')->get(); $data = []; foreach ($rows->groupBy('category_id') as $categoryId => $bands) { $upto = $bands->where('condition', MerchantCategoryLegalCharges::BAND_UPTO)->last(); $above = $bands->where('condition', MerchantCategoryLegalCharges::BAND_ABOVE)->last(); $boundaryRow = $upto ?: $above; $data[] = [ 'category_id' => (int) $categoryId, 'boundary' => $this->intOrNull($boundaryRow->net_loan_amount), 'amount_upto' => $upto ? $this->intOrNull($upto->legal_charges) : null, 'amount_above' => $above ? $this->intOrNull($above->legal_charges) : null, 'status' => (int) $boundaryRow->status, ]; } return $data; } /** * Create or update one band, collapsing any legacy duplicates onto it. * * @param int $categoryId * @param string $condition * @param int $boundary * @param int $amount * @param int $status * @param int|null $userId * @return void */ private function persistBand($categoryId, $condition, $boundary, $amount, $status, $userId) { $existing = MerchantCategoryLegalCharges::where('category_id', $categoryId) ->where('condition', $condition) ->orderBy('created_at', 'desc') ->orderBy('id', 'desc') ->get(); // Keep the most recent row -- the one the old resolver would have used -- and // retire the rest, so a category converges on one row per band. $band = $existing->shift(); foreach ($existing as $duplicate) { MerchantCategoryLegalChargesLogs::record($duplicate, 'delete', $userId); $duplicate->delete(); } $isNew = is_null($band); if ($isNew) { $band = new MerchantCategoryLegalCharges(); $band->category_id = $categoryId; $band->condition = $condition; $band->created_by = $userId; } $previousStatus = $isNew ? null : (int) $band->status; $band->net_loan_amount = $boundary; $band->legal_charges = $amount; $band->status = $status; $band->updated_by = $userId; if (!$isNew && !$band->isDirty()) { return; } $band->save(); MerchantCategoryLegalChargesLogs::record($band, $this->resolveEvent($isNew, $previousStatus, $status), $userId); } /** * Retire every band for a category. * * @param int $categoryId * @param int|null $userId * @return void */ private function clearCategory($categoryId, $userId) { $bands = MerchantCategoryLegalCharges::where('category_id', $categoryId)->get(); foreach ($bands as $band) { MerchantCategoryLegalChargesLogs::record($band, 'delete', $userId); $band->delete(); } } /** * @param bool $isNew * @param int|null $previousStatus * @param int $status * @return string */ private function resolveEvent($isNew, $previousStatus, $status) { if ($isNew) { return 'create'; } if ($previousStatus !== $status) { return $status === 1 ? 'activate' : 'deactivate'; } return 'update'; } /** * @param array $categoryIds * @return array */ private function freeAboveBoundaryWarnings(array $categoryIds) { if (empty($categoryIds)) { return []; } $names = MerchantCategory::whereIn('id', array_unique($categoryIds))->pluck('name', 'id'); $warnings = []; foreach (array_unique($categoryIds) as $categoryId) { $name = isset($names[$categoryId]) ? $names[$categoryId] : ('category ' . $categoryId); $warnings[] = $name . ': no legal charge will be applied above the boundary.'; } return $warnings; } /** * Treat empty strings and nulls alike, so a blank input clears a value instead * of silently becoming zero. * * @param mixed $value * @return int|null */ private function intOrNull($value) { if (is_null($value) || $value === '') { return null; } return (int) $value; } }