<?php
namespace App\Controller;
use App\Entity\Category;
use App\Entity\City;
use App\Entity\CommandeDetails;
use App\Entity\Country;
use App\Entity\Magasin;
use App\Entity\Produit;
use App\Entity\Region;
use App\Entity\SellerApplication;
use App\Entity\User;
use App\Service\ShopProvisioner;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class SellerApplicationController extends AbstractController
{
// Bump this string whenever the Seller Agreement / Privacy Policy changes,
// so each acceptance record shows exactly which version the seller agreed to.
private const AGREEMENT_VERSION = 'v1.0 (2026-05-31)';
// Where uploaded owner-ID files are stored — relative to the project dir.
// This is OUTSIDE public/, so the files are never served by a direct URL.
private const ID_DIR = '/var/uploads/seller_ids';
// Shop logos are PUBLIC (shown as the shop icon), so they live under public/.
private const LOGO_DIR = '/public/assets/uploads/logos';
private const LOGO_SIZE = 400;
public function __construct(
private EntityManagerInterface $em,
private ShopProvisioner $provisioner
) {}
#[Route('/become-seller', name: 'seller_apply', methods: ['GET', 'POST'])]
public function apply(Request $request): Response
{
$user = $this->getUser();
$errors = [];
$suggestions = [];
if ($request->isMethod('POST')) {
$business = trim((string) $request->request->get('business_name', ''));
$contact = trim((string) $request->request->get('contact_name', ''));
$email = trim((string) $request->request->get('email', ''));
$phone = trim((string) $request->request->get('phone', ''));
$category = trim((string) $request->request->get('category', ''));
$country = trim((string) $request->request->get('country', ''));
$region = trim((string) $request->request->get('region', ''));
$city = trim((string) $request->request->get('city', ''));
$lat = trim((string) $request->request->get('latitude', ''));
$lng = trim((string) $request->request->get('longitude', ''));
$desc = trim((string) $request->request->get('description', ''));
$agree = $request->request->getBoolean('agree');
$idFile = $request->files->get('id_document');
$idExt = '';
$logoFile = $request->files->get('logo');
$logoExt = '';
if ($business === '') { $errors[] = 'Business / shop name is required.'; }
if ($contact === '') { $errors[] = 'Your name is required.'; }
if ($phone === '') { $errors[] = 'A phone number is required.'; }
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Please enter a valid email address, or leave it blank.';
}
if (!$agree) {
$errors[] = 'Please read and agree to the Seller Agreement and Privacy Policy to continue.';
}
// ── Owner ID file: required, image or PDF, max 8 MB ──
if ($idFile === null) {
$errors[] = 'Please attach a clear photo or scan of the owner’s government-issued ID.';
} else {
$idExt = strtolower($idFile->getClientOriginalExtension() ?: ($idFile->guessExtension() ?: ''));
if (!in_array($idExt, ['jpg', 'jpeg', 'png', 'webp', 'pdf'], true)) {
$errors[] = 'The ID file must be a JPG, PNG, WEBP or PDF.';
} elseif (!$idFile->isValid()) {
$errors[] = 'The ID upload didn’t complete — please try attaching it again.';
} elseif ($idFile->getSize() > 8 * 1024 * 1024) {
$errors[] = 'The ID file is too large — please keep it under 8 MB.';
}
}
// ── Optional shop logo: image only, max 4 MB. Only validated if provided. ──
if ($logoFile !== null) {
$logoExt = strtolower($logoFile->getClientOriginalExtension() ?: ($logoFile->guessExtension() ?: ''));
if (!in_array($logoExt, ['jpg', 'jpeg', 'png', 'webp'], true)) {
$errors[] = 'The shop logo must be a PNG, JPG or WEBP image.';
} elseif (!$logoFile->isValid()) {
$errors[] = 'The logo upload didn’t complete — please try attaching it again.';
} elseif ($logoFile->getSize() > 4 * 1024 * 1024) {
$errors[] = 'The logo is too large — please keep it under 4 MB.';
}
}
if ($business !== '') {
$takenLower = $this->takenShopNames();
if (isset($takenLower[mb_strtolower($business)])) {
$errors[] = 'A shop named “' . $business . '” already exists. Please choose a different business name.';
$suggestions = $this->suggestShopNames($business, $region, $city, $takenLower);
}
}
if (empty($errors)) {
// Store the ID document OUTSIDE the public web root (var/, not web-served).
$dir = $this->getParameter('kernel.project_dir') . self::ID_DIR;
if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
$idStored = 'id_' . bin2hex(random_bytes(16)) . '.' . $idExt;
try {
$idFile->move($dir, $idStored);
} catch (\Throwable $e) {
error_log('[Julico id upload] ' . $e->getMessage());
$errors[] = 'Could not save the ID file — please try again.';
$idStored = null;
}
if ($idStored !== null) {
$appn = new SellerApplication();
$appn->setBusinessName($business);
$appn->setContactName($contact);
$appn->setEmail($email);
$appn->setPhone($phone !== '' ? $phone : null);
$appn->setCategory($category !== '' ? $category : null);
$appn->setCountry($country !== '' ? $country : null);
$appn->setRegion($region !== '' ? $region : null);
$appn->setCity($city !== '' ? $city : null);
$appn->setLatitude(is_numeric($lat) ? $lat : null);
$appn->setLongitude(is_numeric($lng) ? $lng : null);
$appn->setDescription($desc !== '' ? $desc : null);
// Only record a REAL applicant. Never stamp the application with an
// admin / Julico-system account — that pollution is what could make
// a later set-password link target the wrong account.
if ($user !== null && method_exists($user, 'getId') && !$this->isProtectedAccount($user)) {
$appn->setApplicantUserId($user->getId());
}
$appn->setIdDocument($idStored);
// Optional logo: resize to a 400x400 square and store under public/.
// The logo is NOT required, so a storage failure just skips it.
if ($logoFile !== null && $logoExt !== '') {
$logoStored = $this->storeLogo($logoFile, $logoExt);
if ($logoStored !== null) {
$appn->setLogoPath($logoStored);
}
}
// Record the click-to-agree acceptance: which version, when, from where.
$appn->setAgreedVersion(self::AGREEMENT_VERSION);
$appn->setAgreedAt(new \DateTime());
$appn->setAgreedIp($request->getClientIp());
$this->em->persist($appn);
$this->em->flush();
return $this->redirectToRoute('seller_apply', ['ok' => 1]);
}
}
}
$prefill = ['contact_name' => '', 'email' => ''];
if ($user !== null) {
$pre = method_exists($user, 'getPrenom') ? (string) $user->getPrenom() : '';
$nom = method_exists($user, 'getNomm') ? (string) $user->getNomm() : '';
$prefill['contact_name'] = trim($pre . ' ' . $nom);
if (method_exists($user, 'getEmail')) { $prefill['email'] = (string) $user->getEmail(); }
}
$categories = [];
try {
$categories = $this->em->getRepository(Category::class)->findBy([], ['nom' => 'ASC']);
} catch (\Throwable $e) {
error_log('[Julico categories] ' . $e->getMessage());
}
return $this->render('seller/apply.html.twig', [
'errors' => $errors,
'suggestions' => $suggestions,
'sent' => $request->query->getBoolean('ok'),
'prefill' => $prefill,
'old' => $request->request->all(),
'categories' => $categories,
'agreementVersion' => self::AGREEMENT_VERSION,
]);
}
#[Route('/admin/seller-applications/{id}/id-document', name: 'admin_seller_id_document', methods: ['GET'])]
public function idDocument(int $id): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$appn = $this->em->getRepository(SellerApplication::class)->find($id);
if ($appn === null || !$appn->getIdDocument()) {
throw $this->createNotFoundException();
}
$path = $this->getParameter('kernel.project_dir') . self::ID_DIR . '/' . $appn->getIdDocument();
if (!is_file($path)) {
throw $this->createNotFoundException();
}
$ext = strtolower(pathinfo($appn->getIdDocument(), PATHINFO_EXTENSION));
$mime = match ($ext) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
default => 'application/octet-stream',
};
$response = new BinaryFileResponse($path);
$response->headers->set('Content-Type', $mime);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, 'owner-id.' . $ext);
return $response;
}
// Accounts a PUBLIC set-password link must NEVER be allowed to touch.
// Deliberately matches ONLY the exact Julico system inbox(es) — never a whole
// domain, because real sellers also use julico.io addresses (annieshopseller,
// raedshop_outsiderseller, etc). Add more system addresses to the list below
// if you ever create them. NOTE: keep the "at" sign out of /** */ doc-comments
// anywhere in this file — the route loader reads them as annotations and crashes.
private function isProtectedAccount($u): bool
{
if ($u === null || !method_exists($u, 'getEmail')) { return false; }
$email = strtolower(trim((string) $u->getEmail()));
$systemAccounts = ['info@julico.io'];
return in_array($email, $systemAccounts, true);
}
// Resize an uploaded logo to a uniform square and store under public/assets/uploads/logos/.
// Returns the stored filename, or null on failure.
private function storeLogo($file, string $ext): ?string
{
$dir = $this->getParameter('kernel.project_dir') . self::LOGO_DIR;
if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
$name = 'shop_' . bin2hex(random_bytes(12)) . '.' . $ext;
$dest = $dir . '/' . $name;
$src = $file->getRealPath() ?: $file->getPathname();
if ($this->makeSquareLogo($src, $ext, $dest, self::LOGO_SIZE)) {
return $name;
}
try {
$file->move($dir, $name);
return $name;
} catch (\Throwable $e) {
error_log('[Julico apply logo] ' . $e->getMessage());
return null;
}
}
// Center cover-crop to a square of $size px. Returns true on success.
private function makeSquareLogo(string $src, string $ext, string $dest, int $size): bool
{
if (!function_exists('imagecreatetruecolor') || !is_file($src)) { return false; }
$img = match ($ext) {
'jpg', 'jpeg' => @imagecreatefromjpeg($src),
'png' => @imagecreatefrompng($src),
'webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($src) : false,
default => false,
};
if (!$img) { return false; }
$w = imagesx($img);
$h = imagesy($img);
if ($w < 1 || $h < 1) { imagedestroy($img); return false; }
$side = min($w, $h);
$sx = (int) (($w - $side) / 2);
$sy = (int) (($h - $side) / 2);
$canvas = imagecreatetruecolor($size, $size);
if (in_array($ext, ['png', 'webp'], true)) {
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $size, $size, $transparent);
} else {
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefilledrectangle($canvas, 0, 0, $size, $size, $white);
}
imagecopyresampled($canvas, $img, 0, 0, $sx, $sy, $size, $size, $side, $side);
$ok = match ($ext) {
'jpg', 'jpeg' => imagejpeg($canvas, $dest, 88),
'png' => imagepng($canvas, $dest, 6),
'webp' => function_exists('imagewebp') ? imagewebp($canvas, $dest, 88) : false,
default => false,
};
imagedestroy($img);
imagedestroy($canvas);
return $ok === true;
}
private function takenShopNames(): array
{
$set = [];
try {
foreach ($this->em->getRepository(Magasin::class)->findAll() as $s) {
$n = method_exists($s, 'getNom') ? (string) $s->getNom() : '';
if ($n !== '') { $set[mb_strtolower(trim($n))] = true; }
}
} catch (\Throwable $e) {
error_log('[Julico takenShopNames] ' . $e->getMessage());
}
return $set;
}
private function shopNameMap(): array
{
$map = [];
try {
foreach ($this->em->getRepository(Magasin::class)->findAll() as $s) {
$n = method_exists($s, 'getNom') ? trim((string) $s->getNom()) : '';
if ($n !== '') { $map[mb_strtolower($n)] = $n; }
}
} catch (\Throwable $e) {
error_log('[Julico shopNameMap] ' . $e->getMessage());
}
return $map;
}
private function suggestShopNames(string $base, string $region, string $city, array $takenLower): array
{
$base = trim(preg_replace('/\s+/', ' ', $base));
if ($base === '') { return []; }
$cands = [];
foreach (['Wholesale', 'Trading', 'Co', 'Store', 'Hub', 'Market', 'Express'] as $suf) {
$cands[] = $base . ' ' . $suf;
}
if ($city !== '') { $cands[] = $base . ' ' . $city; }
if ($region !== '') { $cands[] = $base . ' ' . $region; }
$cands[] = $base . ' Lebanon';
$cands[] = $base . ' 2';
$cands[] = $base . ' 3';
$out = [];
foreach ($cands as $c) {
$c = trim(preg_replace('/\s+/', ' ', $c));
$k = mb_strtolower($c);
if ($c === '' || isset($takenLower[$k]) || isset($out[$k])) { continue; }
$out[$k] = $c;
if (count($out) >= 5) { break; }
}
return array_values($out);
}
private function nameChecks(array $apps): array
{
$byLower = $this->shopNameMap();
$takenLower = [];
foreach ($byLower as $k => $v) { $takenLower[$k] = true; }
$out = [];
foreach ($apps as $a) {
$name = trim((string) $a->getBusinessName());
if ($name === '') { continue; }
$lk = mb_strtolower($name);
if (isset($byLower[$lk])) {
$out[$a->getId()] = [
'taken' => true,
'conflict' => $byLower[$lk],
'suggestions' => $this->suggestShopNames($name, (string) $a->getRegion(), (string) $a->getCity(), $takenLower),
];
} else {
$out[$a->getId()] = ['taken' => false, 'conflict' => null, 'suggestions' => []];
}
}
return $out;
}
private function waNumber(?string $phone): string
{
$d = preg_replace('/\D+/', '', (string) $phone);
if ($d === '') { return ''; }
if (str_starts_with($d, '00')) { $d = substr($d, 2); }
elseif (str_starts_with($d, '0')) { $d = substr($d, 1); }
return $d;
}
private function entName($e): string
{
if ($e === null) { return ''; }
foreach (['getName', 'getNom', 'getLibelle', 'getTitle'] as $m) {
if (method_exists($e, $m)) {
try { $v = $e->$m(); } catch (\Throwable $ex) { $v = null; }
if ($v !== null && $v !== '') { return (string) $v; }
}
}
return '';
}
private function locNorm(?string $s): string
{
$s = mb_strtolower(trim((string) $s));
$s = preg_replace('/\b(governorate|mohafazat|muhafazah|district|caza|qada|province|region|city of|el|al)\b/u', ' ', $s);
$s = preg_replace('/[^a-z\s]/u', ' ', $s);
$s = preg_replace('/\s+/', ' ', $s);
return trim($s);
}
private function locMatch(string $a, string $b): bool
{
if ($a === '' || $b === '') { return false; }
if ($a === $b) { return true; }
return str_contains($a, $b) || str_contains($b, $a);
}
private function resolveLocationIds(SellerApplication $a): array
{
$out = ['countryId' => null, 'regionId' => null, 'cityId' => null];
try {
$cTxt = $this->locNorm($a->getCountry());
$rTxt = $this->locNorm($a->getRegion());
$tTxt = $this->locNorm($a->getCity());
$country = null;
if ($cTxt !== '') {
foreach ($this->em->getRepository(Country::class)->findAll() as $c) {
if ($this->locNorm($c->getName()) === $cTxt) { $country = $c; break; }
}
if ($country === null) {
foreach ($this->em->getRepository(Country::class)->findAll() as $c) {
if ($this->locMatch($this->locNorm($c->getName()), $cTxt)) { $country = $c; break; }
}
}
}
$region = null;
if ($rTxt !== '') {
$regions = $country !== null
? $country->getRegions()->toArray()
: $this->em->getRepository(Region::class)->findAll();
foreach ($regions as $r) {
if ($this->locNorm($r->getName()) === $rTxt) { $region = $r; break; }
}
if ($region === null) {
foreach ($regions as $r) {
if ($this->locMatch($this->locNorm($r->getName()), $rTxt)) { $region = $r; break; }
}
}
}
if ($region !== null && $country === null) { $country = $region->getCountry(); }
$city = null;
if ($tTxt !== '' && $region !== null) {
foreach ($region->getCities() as $ci) {
if ($this->locNorm($ci->getName()) === $tTxt) { $city = $ci; break; }
}
if ($city === null) {
foreach ($region->getCities() as $ci) {
if ($this->locMatch($this->locNorm($ci->getName()), $tTxt)) { $city = $ci; break; }
}
}
}
$out['countryId'] = $country?->getId();
$out['regionId'] = $region?->getId();
$out['cityId'] = $city?->getId();
} catch (\Throwable $e) {
error_log('[Julico resolveLocationIds] ' . $e->getMessage());
}
return $out;
}
private function productPrice($p): float
{
try {
if (method_exists($p, 'getSalePrice')) { $sp = $p->getSalePrice(); if ($sp !== null && (float) $sp > 0) { return (float) $sp; } }
if (method_exists($p, 'getPrix')) { return (float) ($p->getPrix() ?? 0); }
} catch (\Throwable $e) {}
return 0.0;
}
private function shopArea($shop): array
{
return [
'city' => method_exists($shop, 'getCity') ? $this->entName($shop->getCity()) : '',
'region' => method_exists($shop, 'getRegion') ? $this->entName($shop->getRegion()) : '',
'country' => method_exists($shop, 'getCountry') ? $this->entName($shop->getCountry()) : '',
];
}
private function competition(array $apps): array
{
$hasPending = false;
foreach ($apps as $a) { if ($a->getStatus() === 'pending') { $hasPending = true; break; } }
if (!$hasPending) { return []; }
$out = [];
try {
$rows = $this->em->createQueryBuilder()
->select('p', 'c', 'm', 'mc', 'mr', 'mci')
->from(Produit::class, 'p')
->join('p.category', 'c')
->join('p.magasin', 'm')
->leftJoin('m.country', 'mc')
->leftJoin('m.region', 'mr')
->leftJoin('m.city', 'mci')
->getQuery()->getResult();
$catalogue = [];
foreach ($rows as $p) {
$shop = method_exists($p, 'getMagasin') ? $p->getMagasin() : null;
$cat = method_exists($p, 'getCategory') ? $p->getCategory() : null;
if ($shop === null || $cat === null) { continue; }
$catNom = method_exists($cat, 'getNom') ? (string) $cat->getNom() : '';
$area = $this->shopArea($shop);
$catalogue[] = [
'product' => method_exists($p, 'getProductName') ? (string) $p->getProductName() : '',
'price' => $this->productPrice($p),
'catLower' => mb_strtolower(trim($catNom)),
'shopId' => method_exists($shop, 'getId') ? (int) $shop->getId() : 0,
'shopNom' => method_exists($shop, 'getNom') ? (string) $shop->getNom() : '',
'cityLower' => mb_strtolower($area['city']),
'regLower' => mb_strtolower($area['region']),
'areaLabel' => trim($area['city'] !== '' ? $area['city'] : $area['region']),
];
}
foreach ($apps as $a) {
if ($a->getStatus() !== 'pending') { continue; }
$appCat = mb_strtolower(trim((string) $a->getCategory()));
$appCity = mb_strtolower(trim((string) $a->getCity()));
$appReg = mb_strtolower(trim((string) $a->getRegion()));
$catLabel = trim((string) $a->getCategory());
$areaLabel = trim((string) $a->getCity()) !== '' ? trim((string) $a->getCity()) : trim((string) $a->getRegion());
$areaShops = []; $catShops = []; $overlapShops = [];
$items = []; $overlapItemCount = 0;
foreach ($catalogue as $row) {
$areaMatch = ($appCity !== '' && $row['cityLower'] === $appCity)
|| ($appReg !== '' && $row['regLower'] === $appReg);
$catMatch = $appCat !== '' && (
$row['catLower'] === $appCat
|| ($row['catLower'] !== '' && (str_contains($row['catLower'], $appCat) || str_contains($appCat, $row['catLower'])))
);
if ($areaMatch) { $areaShops[$row['shopId']] = true; }
if ($catMatch) { $catShops[$row['shopId']] = true; }
if ($catMatch && $areaMatch) {
$overlapShops[$row['shopId']] = true;
$overlapItemCount++;
if (count($items) < 12) {
$items[] = [
'product' => $row['product'],
'shop' => $row['shopNom'],
'area' => $row['areaLabel'],
'price' => $row['price'],
];
}
}
}
$nArea = count($areaShops);
$nCat = count($catShops);
$nOver = count($overlapShops);
if ($overlapItemCount > 0) { $severity = 'high'; }
elseif ($nCat > 0 || $nArea > 0) { $severity = 'medium'; }
else { $severity = 'low'; }
$parts = [];
if ($catLabel !== '') { $parts[] = 'Sells “' . $catLabel . '”'; }
if ($areaLabel !== '') { $parts[] = 'in ' . $areaLabel; }
$lead = $parts ? implode(' ', $parts) . '. ' : '';
if ($severity === 'high') {
$summary = $lead . $nOver . ' shop' . ($nOver !== 1 ? 's' : '') . ' here already sell' . ($nOver === 1 ? 's' : '')
. ' this category — ' . $overlapItemCount . ' overlapping item' . ($overlapItemCount !== 1 ? 's' : '') . '.';
} elseif ($severity === 'medium') {
$bits = [];
if ($nCat > 0) { $bits[] = $nCat . ' shop' . ($nCat !== 1 ? 's' : '') . ' sell' . ($nCat === 1 ? 's' : '') . ' this category (elsewhere)'; }
if ($nArea > 0) { $bits[] = $nArea . ' shop' . ($nArea !== 1 ? 's' : '') . ' in this area (other categories)'; }
$summary = $lead . implode('; ', $bits) . '.';
} else {
$summary = $lead . 'No existing shop overlaps on category or area.';
}
$out[$a->getId()] = [
'severity' => $severity,
'summary' => $summary,
'items' => $items,
'moreItems' => max(0, $overlapItemCount - count($items)),
];
}
} catch (\Throwable $e) {
error_log('[Julico competition] ' . $e->getMessage());
return [];
}
return $out;
}
#[Route('/admin/seller-applications', name: 'admin_seller_applications', methods: ['GET'])]
public function list(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$status = (string) $request->query->get('status', 'pending');
$repo = $this->em->getRepository(SellerApplication::class);
$criteria = in_array($status, ['pending', 'approved', 'rejected'], true) ? ['status' => $status] : [];
$apps = $repo->findBy($criteria, ['created_at' => 'DESC']);
$shopsByApp = [];
$waPhone = [];
$setupUrl = [];
$now = new \DateTime();
foreach ($apps as $a) {
$waPhone[$a->getId()] = $this->waNumber($a->getPhone());
$mid = $a->getCreatedMagasinId();
if ($mid) {
$s = $this->em->getRepository(Magasin::class)->find($mid);
if ($s) { $shopsByApp[$a->getId()] = $s; }
}
$tok = $a->getSetupToken();
if ($tok && $a->getSetupTokenExpires() && $a->getSetupTokenExpires() >= $now) {
$setupUrl[$a->getId()] = $this->generateUrl('seller_setup', ['token' => $tok], UrlGeneratorInterface::ABSOLUTE_URL);
}
}
return $this->render('super_admin/seller_applications.html.twig', [
'apps' => $apps,
'status' => $status,
'history' => $this->sellerHistory($apps),
'nameChecks' => $this->nameChecks($apps),
'competition' => $this->competition($apps),
'shopsByApp' => $shopsByApp,
'waPhone' => $waPhone,
'setupUrl' => $setupUrl,
'counts' => [
'pending' => $repo->count(['status' => 'pending']),
'approved' => $repo->count(['status' => 'approved']),
'rejected' => $repo->count(['status' => 'rejected']),
'all' => $repo->count([]),
],
]);
}
#[Route('/admin/seller-applications/{id}/decide', name: 'admin_seller_decide', methods: ['POST'])]
public function decide(int $id, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$appn = $this->em->getRepository(SellerApplication::class)->find($id);
if ($appn === null) { throw $this->createNotFoundException(); }
$action = (string) $request->request->get('action', '');
$note = trim((string) $request->request->get('admin_note', ''));
$status = $request->query->get('status', 'pending');
if ($action === 'approve') {
$managerEmail = trim((string) $request->request->get('manager_email', ''));
if ($managerEmail === '') {
$managerEmail = trim((string) $appn->getEmail());
}
if ($managerEmail === '' || !filter_var($managerEmail, FILTER_VALIDATE_EMAIL)) {
$this->addFlash('error', 'Enter a valid login email to approve — this is the account the shop is linked to.');
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
if (trim((string) $appn->getEmail()) !== $managerEmail) {
$appn->setEmail($managerEmail);
}
// Carry the applicant's pinned location over to the new shop.
$loc = $this->resolveLocationIds($appn);
$res = $this->provisioner->provision([
'shopName' => $appn->getBusinessName(),
'active' => false,
'countryId' => $loc['countryId'],
'regionId' => $loc['regionId'],
'cityId' => $loc['cityId'],
'managerEmail' => $managerEmail,
'managerName' => $appn->getContactName(),
'managerPhone' => $appn->getPhone(),
'tempPassword' => $request->request->get('temp_password'),
], $this->getUser());
if ($res['error'] !== null) {
$this->addFlash('error', 'Could not approve: ' . $res['error']);
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
$appn->setStatus('approved');
$appn->setCreatedMagasinId($res['shop']->getId());
// Carry the logo the seller uploaded at apply time onto the new shop.
if ($appn->getLogoPath()) {
$res['shop']->setLogoPath($appn->getLogoPath());
}
// The shop is linked to $res['manager'] — that account, and ONLY that
// account, is what the set-password link may target. Always point the
// application at it, overwriting any stale applicant id captured from
// whoever happened to be logged in when the form was submitted.
if ($res['manager'] !== null) {
$appn->setApplicantUserId($res['manager']->getId());
}
if ($res['createdUser']) {
$appn->setSetupToken(bin2hex(random_bytes(24)));
$appn->setSetupTokenExpires((new \DateTime())->modify('+30 day'));
$acct = ' A new account was created — send the seller their set-password link (WhatsApp button below).';
} else {
$appn->setSetupToken(null);
$appn->setSetupTokenExpires(null);
$acct = ' Linked to existing account ' . $res['manager']->getEmail() . ' (password unchanged).';
}
$this->addFlash('success', 'Approved — shop “' . $res['shop']->getNom() . '” created (awaiting payment, not on the storefront yet). Confirm a payment below to make it live.' . $acct);
} elseif ($action === 'reject') {
$appn->setStatus('rejected');
$this->addFlash('success', 'Application rejected.');
}
$appn->setAdminNote($note !== '' ? $note : null);
$appn->setReviewedAt(new \DateTime());
$this->em->flush();
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
#[Route('/admin/seller-applications/{id}/confirm-payment', name: 'admin_seller_confirm_payment', methods: ['POST'])]
public function confirmPayment(int $id, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$appn = $this->em->getRepository(SellerApplication::class)->find($id);
if ($appn === null) { throw $this->createNotFoundException(); }
$status = $request->query->get('status', 'approved');
$shopId = $appn->getCreatedMagasinId();
$shop = $shopId ? $this->em->getRepository(Magasin::class)->find($shopId) : null;
if ($shop === null) {
$this->addFlash('error', 'No shop is linked to this application yet — approve it first.');
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
$months = (int) $request->request->get('months', 0);
if (!in_array($months, [1, 3, 6, 12], true)) {
$this->addFlash('error', 'Pick a valid subscription length (1, 3, 6 or 12 months).');
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
$today = new \DateTime('today');
$base = $shop->getSubscriptionPaidUntil();
$start = ($base !== null && $base > $today) ? (clone $base) : (clone $today);
$newUntil = $start->modify('+' . $months . ' month');
$shop->setSubscriptionPaidUntil($newUntil);
$shop->setActive(true);
$this->em->flush();
$this->addFlash('success', 'Payment confirmed — “' . $shop->getNom() . '” is now live until ' . $newUntil->format('M j, Y') . '.');
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
#[Route('/admin/seller-applications/{id}/remove-subscription', name: 'admin_seller_remove_subscription', methods: ['POST'])]
public function removeSubscription(int $id, Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$appn = $this->em->getRepository(SellerApplication::class)->find($id);
if ($appn === null) { throw $this->createNotFoundException(); }
$status = $request->query->get('status', 'approved');
$shopId = $appn->getCreatedMagasinId();
$shop = $shopId ? $this->em->getRepository(Magasin::class)->find($shopId) : null;
if ($shop === null) {
$this->addFlash('error', 'No shop is linked to this application.');
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
$shop->setActive(false);
$shop->setSubscriptionPaidUntil(null);
$this->em->flush();
$this->addFlash('success', 'Subscription removed — “' . $shop->getNom() . '” is now off the storefront (awaiting payment). Confirm a payment to reactivate it.');
return $this->redirectToRoute('admin_seller_applications', ['status' => $status]);
}
#[Route('/seller-setup/{token}', name: 'seller_setup', methods: ['GET', 'POST'])]
public function setupPassword(string $token, Request $request, UserPasswordHasherInterface $hasher): Response
{
$appn = $this->em->getRepository(SellerApplication::class)->findOneBy(['setup_token' => $token]);
$valid = $appn !== null
&& $appn->getSetupTokenExpires() !== null
&& $appn->getSetupTokenExpires() >= new \DateTime();
if (!$valid) {
return $this->render('seller/setup_password.html.twig', ['invalid' => true]);
}
$errors = [];
if ($request->isMethod('POST')) {
if (!$this->isCsrfTokenValid('seller_setup', (string) $request->request->get('_token'))) {
$errors[] = 'Security check failed — please try again.';
}
$p1 = (string) $request->request->get('password', '');
$p2 = (string) $request->request->get('password_confirm', '');
if (strlen($p1) < 8) { $errors[] = 'Password must be at least 8 characters.'; }
if ($p1 !== $p2) { $errors[] = 'The two passwords don’t match.'; }
if (empty($errors)) {
// Resolve the account this link is allowed to set a password for.
// PRIMARY source of truth = the manager email the shop was linked to
// at approval time. The stored applicant id is only a FALLBACK, because
// it can be polluted by whoever happened to be logged in at apply time.
$user = null;
$email = strtolower(trim((string) $appn->getEmail()));
if ($email !== '') {
$user = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
}
if ($user === null) {
$uid = $appn->getApplicantUserId();
$user = $uid ? $this->em->getRepository(User::class)->find($uid) : null;
}
if ($user === null) {
$errors[] = 'Account not found — please contact Julico support.';
} elseif ($this->isProtectedAccount($user)) {
// Hard stop: a public set-password link must NEVER change the Julico
// system inbox. Refuse, change nothing.
error_log('[Julico setupPassword] refused protected account "'
. (method_exists($user, 'getEmail') ? $user->getEmail() : '?')
. '" via token for application #' . $appn->getId());
$errors[] = 'This setup link is misconfigured and was blocked for safety — no password was changed. Please contact Julico support.';
} else {
$user->setPassword($hasher->hashPassword($user, $p1));
$appn->setSetupToken(null);
$appn->setSetupTokenExpires(null);
$this->em->flush();
$this->addFlash('success', 'Your password is set — you can now log in.');
return $this->redirect('/login');
}
}
}
return $this->render('seller/setup_password.html.twig', [
'invalid' => false,
'errors' => $errors,
'token' => $token,
'email' => $appn->getEmail(),
'shop' => $appn->getBusinessName(),
]);
}
private function sellerHistory(array $apps): array
{
try {
$userRepo = $this->em->getRepository(User::class);
$appShops = [];
$allShopIds = [];
foreach ($apps as $a) {
$email = strtolower(trim((string) $a->getEmail()));
if ($email === '') { continue; }
$u = $userRepo->findOneBy(['email' => $email]);
if ($u === null || !method_exists($u, 'getLinkedMagasins')) { continue; }
foreach ($u->getLinkedMagasins() as $shop) {
if ($shop === null || !method_exists($shop, 'getId')) { continue; }
$sid = (int) $shop->getId();
if ($a->getCreatedMagasinId() !== null && (int) $a->getCreatedMagasinId() === $sid) { continue; }
$appShops[$a->getId()][$sid] = [
'name' => method_exists($shop, 'getNom') ? (string) $shop->getNom() : ('#' . $sid),
'active' => method_exists($shop, 'isActive') ? ($shop->isActive() === true) : false,
];
$allShopIds[$sid] = true;
}
}
if (empty($allShopIds)) { return []; }
$ids = array_keys($allShopIds);
$prodCounts = [];
$rows = $this->em->createQueryBuilder()
->select('m.id AS sid', 'COUNT(p.id) AS cnt')
->from(Produit::class, 'p')
->join('p.magasin', 'm')
->where('m.id IN (:ids)')->setParameter('ids', $ids)
->groupBy('m.id')->getQuery()->getArrayResult();
foreach ($rows as $r) { $prodCounts[(int) $r['sid']] = (int) $r['cnt']; }
$agg = [];
$lines = $this->em->createQueryBuilder()
->select('d', 'c', 'p', 'm', 'v')
->from(CommandeDetails::class, 'd')
->join('d.commande', 'c')
->join('d.produit', 'p')
->join('p.magasin', 'm')
->leftJoin('d.produitVariant', 'v')
->where('m.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()->getResult();
foreach ($lines as $d) {
$c = method_exists($d, 'getCommande') ? $d->getCommande() : null;
$p = method_exists($d, 'getProduit') ? $d->getProduit() : null;
if ($c === null || $p === null) { continue; }
$shop = method_exists($p, 'getMagasin') ? $p->getMagasin() : null;
$sid = ($shop && method_exists($shop, 'getId')) ? (int) $shop->getId() : null;
if ($sid === null || !isset($allShopIds[$sid])) { continue; }
if ($this->lineCancelled($c)) { continue; }
if (!isset($agg[$sid])) { $agg[$sid] = ['orders' => [], 'units' => 0, 'revenue' => 0.0]; }
$qty = (int) (method_exists($d, 'getProduitQttCmd') ? ($d->getProduitQttCmd() ?? 0) : 0);
$unit = $this->lineUnitPrice($d, $p);
$agg[$sid]['units'] += $qty;
$agg[$sid]['revenue'] += $qty * $unit;
if (method_exists($c, 'getId')) { $agg[$sid]['orders'][(int) $c->getId()] = true; }
}
$out = [];
foreach ($appShops as $appId => $shops) {
$listShops = []; $tOrders = 0; $tUnits = 0; $tRev = 0.0;
foreach ($shops as $sid => $meta) {
$a = $agg[$sid] ?? ['orders' => [], 'units' => 0, 'revenue' => 0.0];
$orders = count($a['orders']);
$listShops[] = [
'name' => $meta['name'],
'active' => $meta['active'],
'products' => $prodCounts[$sid] ?? 0,
'orders' => $orders,
'units' => $a['units'],
'revenue' => round($a['revenue'], 2),
];
$tOrders += $orders; $tUnits += $a['units']; $tRev += $a['revenue'];
}
usort($listShops, fn($x, $y) => strcasecmp($x['name'], $y['name']));
$out[$appId] = [
'shops' => $listShops,
'totalOrders' => $tOrders,
'totalUnits' => $tUnits,
'totalRevenue' => round($tRev, 2),
];
}
return $out;
} catch (\Throwable $e) {
error_log('[Julico sellerHistory] ' . $e->getMessage());
return [];
}
}
private function lineCancelled($c): bool
{
try {
$label = '';
if (method_exists($c, 'getCmdStatus')) {
$cs = $c->getCmdStatus();
if ($cs !== null && method_exists($cs, 'getStatusDesc')) { $label = (string) $cs->getStatusDesc(); }
}
if ($label === '' && method_exists($c, 'getStatusCmd')) { $label = (string) $c->getStatusCmd(); }
return str_contains(strtolower($label), 'cancel');
} catch (\Throwable $e) { return false; }
}
private function lineUnitPrice($d, $p): float
{
try {
$v = method_exists($d, 'getProduitVariant') ? $d->getProduitVariant() : null;
if ($v !== null && method_exists($v, 'getPrix')) {
$vp = $v->getPrix();
if ($vp !== null && (float) $vp > 0) { return (float) $vp; }
}
if (method_exists($p, 'getSalePrice')) {
$sp = $p->getSalePrice();
if ($sp !== null && (float) $sp > 0) { return (float) $sp; }
}
if (method_exists($p, 'getPrix')) { return (float) ($p->getPrix() ?? 0); }
} catch (\Throwable $e) {}
return 0.0;
}
}