File: /home/xedaptot/be.naniguide.com/app/Http/Controllers/Refactor/Admin/PlanRemoteMappingController.php
<?php
namespace App\Http\Controllers\Refactor\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Model\Plan;
use App\Model\PlanRemoteMapping;
use App\Model\PaymentGateway;
class PlanRemoteMappingController extends Controller
{
/**
* Remote plan mapping page — grid of local plans x remote gateways.
*/
public function index(Request $request)
{
if (\Gate::denies('read', Plan::newDefaultPlan())) {
return $this->notAuthorized();
}
$subscriptionService = app(\App\Services\Subscription\SubscriptionManagementService::class);
$remoteGateways = $subscriptionService->getRemoteGateways();
$localPlans = Plan::active()->orderBy('name')->get();
// Preload existing mappings keyed by plan_id => gateway_id => mapping
$gatewayIds = array_map(fn($g) => $g->id, $remoteGateways);
$allMappings = PlanRemoteMapping::with(['plan', 'paymentGateway'])
->whereIn('payment_gateway_id', $gatewayIds)
->get();
$mappingIndex = [];
foreach ($allMappings as $m) {
$mappingIndex[$m->plan_id][$m->payment_gateway_id] = $m;
}
$stats = [
'total_mappings' => $allMappings->count(),
'gateways' => count($remoteGateways),
'plans' => $localPlans->count(),
];
return view('refactor.pages.admin.remote-plan-mappings.index', [
'remoteGateways' => $remoteGateways,
'localPlans' => $localPlans,
'mappingIndex' => $mappingIndex,
'stats' => $stats,
]);
}
/**
* AJAX: list remote plans for a gateway.
*/
public function remotePlans(Request $request)
{
if (\Gate::denies('read', Plan::newDefaultPlan())) {
return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
}
$gateway = PaymentGateway::findOrFail($request->gateway_id);
$subscriptionService = app(\App\Services\Subscription\SubscriptionManagementService::class);
try {
$remotePlans = $subscriptionService->fetchRemotePlans($gateway);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 400);
}
$localPlans = Plan::active()->get();
$existingMappings = PlanRemoteMapping::where('payment_gateway_id', $gateway->id)
->get()
->keyBy('remote_plan_id');
$items = [];
foreach ($remotePlans as $rp) {
$mapping = $existingMappings->get($rp->id);
$mismatches = $mapping ? $mapping->getMismatches() : [];
$items[] = [
'remote_plan_id' => $rp->id,
'remote_plan_name' => $rp->name,
'remote_price' => $rp->price,
'remote_currency' => $rp->currency,
'remote_interval_count' => $rp->intervalCount,
'remote_interval_unit' => $rp->intervalUnit,
'remote_interval' => $rp->intervalCount . ' ' . $rp->intervalUnit,
'remote_trial_days' => $rp->trialDays,
'remote_status' => $rp->status,
'mapped_plan_id' => $mapping?->plan_id,
'mapped_plan_name' => $mapping?->plan?->name,
'mapping_uid' => $mapping?->uid,
'has_mismatches' => !empty($mismatches),
'mismatches' => $mismatches,
];
}
return response()->json([
'status' => 'success',
'items' => $items,
'local_plans' => $localPlans->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'price' => $p->price,
'currency' => $p->currency?->code,
'frequency_amount' => $p->frequency_amount,
'frequency_unit' => $p->frequency_unit,
'trial_amount' => $p->trial_amount,
'trial_unit' => $p->trial_unit,
]),
'total' => count($remotePlans),
'gateway' => [
'id' => $gateway->id,
'uid' => $gateway->uid,
'name' => $gateway->name,
'type' => $gateway->type,
],
]);
}
/**
* Store or update a plan mapping.
*/
public function store(Request $request)
{
if (\Gate::denies('read', Plan::newDefaultPlan())) {
return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
}
$request->validate([
'gateway_id' => 'required|exists:payment_gateways,id',
'remote_plan_id' => 'required|string',
'plan_id' => 'required|exists:plans,id',
]);
// Reject mapping free plans to remote gateways — free plans bypass checkout
// so a remote subscription would never be created at the provider
$plan = Plan::findOrFail($request->plan_id);
if ($plan->isFree()) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/admin_remote_plan_mappings.flash.free_plan_cannot_map'),
], 422);
}
$gateway = PaymentGateway::findOrFail($request->gateway_id);
try {
$result = app(\App\Services\Subscription\SubscriptionManagementService::class)
->savePlanRemoteMapping($gateway, $plan, $request->remote_plan_id);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => trans('refactor/admin_remote_plan_mappings.flash.cannot_fetch_remote', ['error' => $e->getMessage()]),
], 400);
}
$mapping = $result['mapping'];
$mismatches = $result['mismatches'];
$rowData = $this->buildRowResponse($mapping, $mapping->plan, $gateway);
if (!empty($mismatches)) {
$mismatchDetails = array_map(fn($m) => $m['message'], $mismatches);
return response()->json(array_merge($rowData, [
'status' => 'warning',
'message' => trans('refactor/admin_remote_plan_mappings.flash.mapping_saved_mismatches', ['details' => implode('; ', $mismatchDetails)]),
]));
}
return response()->json(array_merge($rowData, [
'status' => 'success',
'message' => trans('refactor/admin_remote_plan_mappings.flash.mapping_saved'),
]));
}
/**
* Remove a plan mapping.
*/
public function destroy(Request $request)
{
if (\Gate::denies('read', Plan::newDefaultPlan())) {
return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
}
$mapping = PlanRemoteMapping::where('uid', $request->uid)->firstOrFail();
$result = app(\App\Services\Subscription\SubscriptionManagementService::class)
->removePlanRemoteMapping($mapping);
$rowData = $this->buildRowResponse(null, $result['plan'], $result['gateway']);
return response()->json(array_merge($rowData, [
'status' => 'success',
'message' => trans('refactor/admin_remote_plan_mappings.flash.mapping_removed'),
]));
}
/**
* Sync one plan mapping: fetch latest from remote, update local plan to match.
*/
public function syncOne(Request $request)
{
if (\Gate::denies('read', Plan::newDefaultPlan())) {
return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
}
$mapping = PlanRemoteMapping::where('uid', $request->uid)->firstOrFail();
$mapping->load(['plan', 'plan.currency', 'paymentGateway']);
$logs = [];
try {
$result = app(\App\Services\Subscription\SubscriptionManagementService::class)
->syncPlanRemoteMappingToLocalPlan($mapping);
$mapping = $result['mapping'];
$remotePlan = $result['remote_plan'];
$localUpdates = $result['local_updates'];
$hasError = $result['has_error'];
$mismatches = $result['mismatches'];
$siblingMappings = $result['sibling_mappings'];
$logs[] = ['type' => 'info', 'message' => trans('refactor/admin_remote_plan_mappings.sync.fetched_remote', ['name' => $remotePlan->name, 'price' => $remotePlan->price, 'currency' => $remotePlan->currency, 'interval' => $remotePlan->intervalUnit])];
} catch (\Throwable $e) {
$logs[] = ['type' => 'error', 'message' => trans('refactor/admin_remote_plan_mappings.sync.failed_fetch', ['id' => $mapping->remote_plan_id, 'error' => $e->getMessage()])];
return response()->json([
'status' => 'error',
'message' => trans('refactor/admin_remote_plan_mappings.sync.sync_failed_fetch'),
'logs' => $logs,
]);
}
$logs[] = ['type' => 'info', 'message' => trans('refactor/admin_remote_plan_mappings.sync.updated_mapping')];
if ($hasError && $result['missing_currency']) {
$logs[] = ['type' => 'error', 'message' => trans('refactor/admin_remote_plan_mappings.sync.currency_not_found', ['currency' => $result['missing_currency']])];
}
if (!empty($localUpdates)) {
if (!$hasError) {
foreach ($localUpdates as $upd) {
$logs[] = ['type' => 'success', 'message' => trans('refactor/admin_remote_plan_mappings.sync.plan_updated', ['detail' => $upd])];
}
} else {
foreach ($localUpdates as $upd) {
$logs[] = ['type' => 'warning', 'message' => trans('refactor/admin_remote_plan_mappings.sync.skipped_currency_error', ['detail' => $upd])];
}
}
} else {
$logs[] = ['type' => 'success', 'message' => trans('refactor/admin_remote_plan_mappings.sync.already_in_sync')];
}
foreach ($mismatches as $m) {
$logs[] = ['type' => 'warning', 'message' => trans('refactor/admin_remote_plan_mappings.sync.remaining_mismatch', ['message' => $m['message']])];
}
$rowData = $this->buildRowResponse($mapping, $mapping->plan, $mapping->paymentGateway);
$siblingCells = [];
if (!empty($localUpdates) && !$hasError) {
foreach ($siblingMappings as $sibling) {
$siblingCells[] = $this->buildRowResponse($sibling, $sibling->plan, $sibling->paymentGateway);
}
}
return response()->json(array_merge($rowData, [
'status' => $hasError ? 'error' : (empty($mismatches) ? 'success' : 'warning'),
'message' => $hasError
? trans('refactor/admin_remote_plan_mappings.sync.sync_errors')
: (empty($localUpdates) ? trans('refactor/admin_remote_plan_mappings.sync.plan_in_sync') : trans('refactor/admin_remote_plan_mappings.sync.plan_updated_from_remote')),
'logs' => $logs,
'sibling_cells' => $siblingCells,
]));
}
/**
* Sync all plan mappings for a gateway.
*/
public function syncAll(Request $request)
{
if (\Gate::denies('read', Plan::newDefaultPlan())) {
return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
}
$gateway = PaymentGateway::findOrFail($request->gateway_id);
$result = app(\App\Services\Subscription\SubscriptionManagementService::class)
->syncAllPlanRemoteMappingsToLocalPlans($gateway);
$mappings = $result['mappings'];
if ($mappings->isEmpty()) {
return response()->json([
'status' => 'info',
'message' => trans('refactor/admin_remote_plan_mappings.sync.no_mappings'),
'logs' => [['type' => 'info', 'message' => trans('refactor/admin_remote_plan_mappings.sync.nothing_to_sync')]],
]);
}
$allLogs = [];
foreach ($result['results'] as $syncResult) {
$mapping = $syncResult['mapping'];
$allLogs[] = ['type' => 'info', 'message' => "--- Syncing: {$mapping->plan->name} ↔ {$mapping->remote_plan_name} ---"];
if (!empty($syncResult['error'])) {
$allLogs[] = ['type' => 'error', 'message' => trans('refactor/admin_remote_plan_mappings.sync.failed_fetch', ['id' => $mapping->remote_plan_id, 'error' => $syncResult['error']])];
continue;
}
$remotePlan = $syncResult['remote_plan'];
$localUpdates = $syncResult['local_updates'];
$planError = $syncResult['has_error'];
$allLogs[] = ['type' => 'info', 'message' => trans('refactor/admin_remote_plan_mappings.sync.fetched_remote', ['name' => $remotePlan->name, 'price' => $remotePlan->price, 'currency' => $remotePlan->currency, 'interval' => $remotePlan->intervalUnit])];
if ($planError && $syncResult['missing_currency']) {
$allLogs[] = ['type' => 'error', 'message' => trans('refactor/admin_remote_plan_mappings.sync.currency_not_found', ['currency' => $syncResult['missing_currency']])];
}
if (!empty($localUpdates) && !$planError) {
foreach ($localUpdates as $upd) {
$allLogs[] = ['type' => 'success', 'message' => $upd];
}
} elseif ($planError) {
foreach ($localUpdates as $upd) {
$allLogs[] = ['type' => 'warning', 'message' => trans('refactor/admin_remote_plan_mappings.sync.skipped', ['detail' => $upd])];
}
} else {
$allLogs[] = ['type' => 'success', 'message' => trans('refactor/admin_remote_plan_mappings.sync.already_in_sync')];
}
}
$inSync = $result['in_sync'];
$totalUpdated = $result['updated'];
$totalErrors = $result['errors'];
$msg = trans('refactor/admin_remote_plan_mappings.sync.synced_gateway', [
'gateway' => $gateway->name,
'in_sync' => $inSync,
'updated' => $totalUpdated,
'errors' => $totalErrors,
]);
// Build row HTML for each mapping so UI can refresh inline
$cells = [];
foreach ($mappings as $mapping) {
$mapping->load(['plan', 'plan.currency', 'paymentGateway']);
$rowData = $this->buildRowResponse($mapping, $mapping->plan, $mapping->paymentGateway);
$cells[] = $rowData;
}
return response()->json([
'status' => $totalErrors > 0 ? 'error' : 'success',
'message' => $msg,
'logs' => $allLogs,
'cells' => $cells,
'total' => $mappings->count(),
'updated' => $totalUpdated,
'errors' => $totalErrors,
'in_sync' => $inSync,
]);
}
/**
* Render the mapping cell partial as HTML string.
*/
protected function renderCellHtml(?PlanRemoteMapping $mapping, $plan, $gateway): string
{
return view('refactor.pages.admin.remote-plan-mappings._cell', [
'mapping' => $mapping,
'plan' => $plan,
'gateway' => $gateway,
])->render();
}
/**
* Render the status badge HTML for a mapping row.
*/
protected function renderStatusHtml(?PlanRemoteMapping $mapping): string
{
if (!$mapping) {
return '<span class="mc-badge mc-badge-default">Unmapped</span>';
}
$mismatches = $mapping->getMismatches();
if (empty($mismatches)) {
return '<span class="mc-badge mc-badge-green mc-badge-dot">' . e(trans('refactor/admin_remote_plan_mappings.cell.in_sync')) . '</span>';
}
return '<span class="mc-badge mc-badge-orange mc-badge-dot">' . count($mismatches) . ' mismatch</span>';
}
/**
* Render the actions column HTML for a mapping row.
*/
protected function renderActionsHtml(?PlanRemoteMapping $mapping): string
{
if (!$mapping) {
return '';
}
$syncIcon = view('refactor.components.icons.mc-icon', ['icon' => 'refresh', 'size' => 14])->render();
$xIcon = view('refactor.components.icons.mc-icon', ['icon' => 'x', 'size' => 14])->render();
$syncLabel = e(trans('refactor/admin_remote_plan_mappings.buttons.sync'));
return '<div style="display:flex;gap:var(--space-1);justify-content:flex-end;">'
. '<button class="mc-btn mc-btn-ghost mc-btn-xs rpm-sync-one-btn" data-uid="' . e($mapping->uid) . '">' . $syncIcon . ' ' . $syncLabel . '</button>'
. '<button class="mc-btn mc-btn-ghost mc-btn-xs rpm-unmap-btn" data-uid="' . e($mapping->uid) . '" style="color:var(--color-error);">' . $xIcon . '</button>'
. '</div>';
}
/**
* Build full row response data (cell + status + actions).
*/
protected function buildRowResponse(?PlanRemoteMapping $mapping, $plan, $gateway): array
{
return [
'cell_html' => $this->renderCellHtml($mapping, $plan, $gateway),
'status_html' => $this->renderStatusHtml($mapping),
'actions_html' => $this->renderActionsHtml($mapping),
'plan_html' => $this->renderPlanLabel($plan),
'plan_id' => $plan->id,
'gateway_id' => $gateway->id,
];
}
/**
* Render the local plan label (name + price) as HTML string.
*/
protected function renderPlanLabel($plan): string
{
$price = \App\Library\Tool::format_price($plan->price, $plan->currency->format);
$freq = $plan->displayFrequencyTime();
return '<div style="font-weight:var(--weight-semibold);font-size:var(--text-base);">' . e($plan->name) . '</div>'
. '<div style="font-size:var(--text-sm);color:var(--color-text-muted);margin-top:2px;">' . $price . ' / ' . $freq . '</div>';
}
}