src/CompanyGroupBundle/Controller/QuoteController.php line 96

Open in your IDE?
  1. <?php
  2. namespace CompanyGroupBundle\Controller;
  3. use ApplicationBundle\Controller\GenericController;
  4. use ApplicationBundle\Modules\Authentication\Constants\UserConstants;
  5. use CompanyGroupBundle\Entity\SubscriptionQuote;
  6. use CompanyGroupBundle\Modules\Api\Service\LegacySubscriptionBillingService;
  7. use CompanyGroupBundle\Modules\Api\Service\PricingService;
  8. use Symfony\Component\HttpFoundation\JsonResponse;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
  11. /**
  12.  * Customer-facing quote controller.
  13.  * All views extend central_header.html.twig.
  14.  * Entity manager: company_group
  15.  */
  16. class QuoteController extends GenericController
  17. {
  18.     /**
  19.      * GET /quote/promo-lookup?code=XXX
  20.      */
  21.     public function PromoCodeLookupAction(Request $request)
  22.     {
  23.         $code trim($request->query->get('code'''));
  24.         if (!$code) {
  25.             return new JsonResponse(['success' => false'message' => 'No code provided']);
  26.         }
  27.         $em $this->getDoctrine()->getManager('company_group');
  28.         $promo $em->getRepository('CompanyGroupBundle\Entity\PromoCode')->findOneBy(['code' => $code]);
  29.         if (!$promo) {
  30.             return new JsonResponse(['success' => false'message' => 'Invalid promo code']);
  31.         }
  32.         $now time();
  33.         if ($promo->getExpiresAtTs() && $promo->getExpiresAtTs() < $now) {
  34.             return new JsonResponse(['success' => false'message' => 'This promo code has expired']);
  35.         }
  36.         if ($promo->getStartsAtTs() && $promo->getStartsAtTs() > $now) {
  37.             return new JsonResponse(['success' => false'message' => 'This promo code is not yet active']);
  38.         }
  39.         if ($promo->getMaxUseCount() && $promo->getUseCountBalance() !== null && $promo->getUseCountBalance() <= 0) {
  40.             return new JsonResponse(['success' => false'message' => 'This promo code has reached its usage limit']);
  41.         }
  42.         return new JsonResponse([
  43.             'success' => true,
  44.             'id' => $promo->getId(),
  45.             'code' => $promo->getCode(),
  46.             'promo_type' => (int)$promo->getPromoType(),
  47.             'promo_value' => (float)$promo->getPromoValue(),
  48.             'max_discount' => $promo->getMaxDiscountAmount() ? (float)$promo->getMaxDiscountAmount() : null,
  49.             'min_amount' => $promo->getMinAmountForApplication() ? (float)$promo->getMinAmountForApplication() : null,
  50.             'label' => (int)$promo->getPromoType() === 1
  51.                 'EUR ' number_format((float)$promo->getPromoValue(), 2) . ' off'
  52.                 : (float)$promo->getPromoValue() . '% off',
  53.         ]);
  54.     }
  55.     /**
  56.      * POST /quote/calculate-price
  57.      */
  58.     public function CalculatePriceAction(Request $request)
  59.     {
  60.         /** @var PricingService $pricing */
  61.         $pricing $this->get('app.pricing_service');
  62.         $normal max(0, (int)$request->request->get('normal_users'0));
  63.         $admin max(0, (int)$request->request->get('admin_users'0));
  64.         $ml max(0, (int)$request->request->get('ml_users'0));
  65.         $cycle in_array($request->request->get('billing_cycle'), ['monthly''yearly'], true)
  66.             ? $request->request->get('billing_cycle')
  67.             : 'monthly';
  68.         $planType in_array($request->request->get('plan_type'), ['team''enterprise'], true)
  69.             ? $request->request->get('plan_type')
  70.             : 'team';
  71.         return new JsonResponse($pricing->getPriceBreakdown($normal$admin$ml$cycle$planType));
  72.     }
  73.     /**
  74.      * GET /quote/request
  75.      * POST /quote/request
  76.      */
  77.     public function RequestQuoteAction(Request $request)
  78.     {
  79.         $session $request->getSession();
  80.         if ($request->isMethod('GET')) {
  81.             $prefill = [
  82.                 'company_name' => $session->get('company_name'''),
  83.                 'customer_email' => $session->get(UserConstants::USER_EMAIL''),
  84.                 'customer_name' => $session->get('userName'''),
  85.                 'customer_phone' => '',
  86.                 'plan_type' => $request->query->get('plan'SubscriptionQuote::PLAN_TEAM),
  87.                 'normal_users' => max(1, (int)$request->query->get('normal_users'1)),
  88.                 'admin_users' => max(0, (int)$request->query->get('admin_users'0)),
  89.                 'ml_users' => max(0, (int)$request->query->get('ml_users'0)),
  90.                 'billing_cycle' => $request->query->get('billing_cycle''monthly'),
  91.                 'payment_type' => $request->query->get('payment_type''automatic'),
  92.                 // When a user is signed in, their identity (email / name / company) is taken
  93.                 // from the session and must be shown read-only — they can't edit who they are.
  94.                 'lock_identity' => $session->get(UserConstants::USER_EMAIL'') !== '' 0,
  95.             ];
  96.             $pricing $this->get('app.pricing_service');
  97.             $breakdown $pricing->getPriceBreakdown(
  98.                 $prefill['normal_users'],
  99.                 $prefill['admin_users'],
  100.                 $prefill['ml_users'],
  101.                 $prefill['billing_cycle'],
  102.                 $prefill['plan_type']
  103.             );
  104.             return $this->render('@CompanyGroup/pages/quotes/request_quote.html.twig', [
  105.                 'prefill' => $prefill,
  106.                 'breakdown' => $breakdown,
  107.                 'page_title' => 'Request a Quote',
  108.             ]);
  109.         }
  110.         $post $request->request;
  111.         $service $this->get('app.quote_service');
  112.         $data = [
  113.             'plan_type' => $post->get('plan_type'SubscriptionQuote::PLAN_TEAM),
  114.             'normal_user_count' => max(0, (int)$post->get('normal_users'0)),
  115.             'admin_user_count' => max(0, (int)$post->get('admin_users'0)),
  116.             'ml_user_count' => max(0, (int)$post->get('ml_users'0)),
  117.             'billing_cycle' => $post->get('billing_cycle''monthly'),
  118.             'payment_type' => $post->get('payment_type''automatic'),
  119.             'customer_email' => trim($post->get('customer_email''')),
  120.             'customer_name' => trim($post->get('customer_name''')),
  121.             'customer_phone' => trim($post->get('customer_phone''')),
  122.             'company_name' => trim($post->get('company_name''')),
  123.             'customer_notes' => trim($post->get('customer_notes''')),
  124.             'company_address' => trim($post->get('company_address''')),
  125.             'country' => trim($post->get('country''')),
  126.             'app_id' => $session->get('appId'),
  127.             'promo_code_id' => $post->get('promo_code_id'null),
  128.         ];
  129.         if (empty($data['customer_email'])) {
  130.             $this->addFlash('error''Please provide your email address.');
  131.             return $this->redirectToRoute('quote_request');
  132.         }
  133.         $quote $service->createCustomerQuote($data);
  134.         return $this->redirectToRoute('quote_view_customer', ['token' => $quote->getQuoteToken()]);
  135.     }
  136.     /**
  137.      * Direct onboarding ("buy now") — standard pricing, no proposal step. Creates a
  138.      * customer quote, immediately raises + accepts its invoice, and (for automatic
  139.      * payment) drops the buyer straight on the gateway. Same endpoint serves a
  140.      * self-serve signup form OR a sales rep onboarding a company on its behalf.
  141.      *
  142.      * POST /quote/onboard
  143.      */
  144.     public function DirectOnboardAction(Request $request)
  145.     {
  146.         $session $request->getSession();
  147.         $post $request->request;
  148.         $service $this->get('app.quote_service');
  149.         $data = [
  150.             'plan_type'         => $post->get('plan_type'SubscriptionQuote::PLAN_TEAM),
  151.             'normal_user_count' => max(0, (int)$post->get('normal_users'1)),
  152.             'admin_user_count'  => max(0, (int)$post->get('admin_users'0)),
  153.             'ml_user_count'     => max(0, (int)$post->get('ml_users'0)),
  154.             'billing_cycle'     => $post->get('billing_cycle''monthly'),
  155.             'payment_type'      => $post->get('payment_type''automatic'),
  156.             'customer_email'    => trim($post->get('customer_email''')),
  157.             'customer_name'     => trim($post->get('customer_name''')),
  158.             'customer_phone'    => trim($post->get('customer_phone''')),
  159.             'company_name'      => trim($post->get('company_name''')),
  160.             'customer_notes'    => trim($post->get('customer_notes''')),
  161.             'company_address'   => trim($post->get('company_address''')),
  162.             'country'           => trim($post->get('country''')),
  163.             'app_id'            => $session->get('appId'),
  164.             'promo_code_id'     => $post->get('promo_code_id'null),
  165.         ];
  166.         if (empty($data['customer_email']) || empty($data['company_name'])) {
  167.             $this->addFlash('error''Please provide a company name and email to continue.');
  168.             return $this->redirectToRoute('quote_request');
  169.         }
  170.         $quote $service->createCustomerQuote($data);
  171.         /** @var LegacySubscriptionBillingService $billing */
  172.         $billing $this->get('app.legacy_subscription_billing_service');
  173.         try {
  174.             $invoice $billing->createOrReuseQuoteInvoice($quote, (int)$this->loggedUserId($request));
  175.             $service->customerAccept($quote); // no status guard — direct accept is allowed
  176.         } catch (\RuntimeException $e) {
  177.             $this->addFlash('error'$e->getMessage() . ' Please contact support to finish setup.');
  178.             return $this->redirectToRoute('quote_view_customer', ['token' => $quote->getQuoteToken()]);
  179.         }
  180.         if ($quote->getPaymentType() === SubscriptionQuote::PAYMENT_AUTOMATIC) {
  181.             return $this->redirectToRoute('quote_payment_redirect', [
  182.                 'token' => $quote->getQuoteToken(),
  183.                 'invoice_id' => $invoice->getId(),
  184.             ]);
  185.         }
  186.         $this->addFlash('success''Account set up. Your first invoice is ready for payment.');
  187.         return $this->redirectToRoute('quote_view_customer', ['token' => $quote->getQuoteToken()]);
  188.     }
  189.     /**
  190.      * GET /quote/{token}
  191.      */
  192.     public function ViewQuoteAction(Request $request$token)
  193.     {
  194.         $service $this->get('app.quote_service');
  195.         $quote $service->findByToken($token);
  196.         if (!$quote) {
  197.             throw $this->createNotFoundException('Quote not found.');
  198.         }
  199.         $history $service->getHistory((int)$quote->getId());
  200.         $pricing $this->get('app.pricing_service');
  201.         $breakdown null;
  202.         if ($quote->getNormalUserCount() !== null) {
  203.             $breakdown $pricing->getPriceBreakdown(
  204.                 (int)$quote->getNormalUserCount(),
  205.                 (int)$quote->getAdminUserCount(),
  206.                 (int)$quote->getMlUserCount(),
  207.                 $quote->getBillingCycle() ?: 'monthly',
  208.                 $quote->getPlanType()
  209.             );
  210.         }
  211.         return $this->render('@CompanyGroup/pages/quotes/customer_quote_view.html.twig', [
  212.             'quote' => $quote,
  213.             'history' => $history,
  214.             'breakdown' => $breakdown,
  215.             'page_title' => 'Your Quote - HoneyBee ERP',
  216.         ]);
  217.     }
  218.     /**
  219.      * GET /quote/{token}/print
  220.      */
  221.     public function PrintQuoteAction(Request $request$token)
  222.     {
  223.         $service $this->get('app.quote_service');
  224.         $quote $service->findByToken($token);
  225.         if (!$quote) {
  226.             throw $this->createNotFoundException('Quote not found.');
  227.         }
  228.         $pricing $this->get('app.pricing_service');
  229.         $breakdown null;
  230.         if ($quote->getNormalUserCount() !== null) {
  231.             $breakdown $pricing->getPriceBreakdown(
  232.                 (int)$quote->getNormalUserCount(),
  233.                 (int)$quote->getAdminUserCount(),
  234.                 (int)$quote->getMlUserCount(),
  235.                 $quote->getBillingCycle() ?: 'monthly',
  236.                 $quote->getPlanType()
  237.             );
  238.         }
  239.         return $this->render('@CompanyGroup/pages/quotes/quote_print.html.twig', [
  240.             'quote' => $quote,
  241.             'breakdown' => $breakdown,
  242.         ]);
  243.     }
  244.     /**
  245.      * POST /quote/{token}/accept
  246.      */
  247.     public function AcceptQuoteAction(Request $request$token)
  248.     {
  249.         $service $this->get('app.quote_service');
  250.         $quote $service->findByToken($token);
  251.         if (!$quote) {
  252.             throw $this->createNotFoundException('Quote not found.');
  253.         }
  254.         $allowedStatuses = [
  255.             SubscriptionQuote::STATUS_SENT,
  256.             SubscriptionQuote::STATUS_MODIFIED,
  257.         ];
  258.         if (!in_array($quote->getStatus(), $allowedStatusestrue)) {
  259.             $this->addFlash('error''This quote cannot be accepted in its current state.');
  260.             return $this->redirectToRoute('quote_view_customer', ['token' => $token]);
  261.         }
  262.         /** @var LegacySubscriptionBillingService $billing */
  263.         $billing $this->get('app.legacy_subscription_billing_service');
  264.         try {
  265.             $invoice $billing->createOrReuseQuoteInvoice($quote, (int)$this->loggedUserId($request));
  266.             $service->customerAccept($quote);
  267.         } catch (\RuntimeException $e) {
  268.             $this->addFlash('error'$e->getMessage() . ' Please contact support to finish setup before payment.');
  269.             return $this->redirectToRoute('quote_view_customer', ['token' => $token]);
  270.         }
  271.         if ($quote->getPaymentType() === SubscriptionQuote::PAYMENT_AUTOMATIC) {
  272.             $this->addFlash('success''Quote accepted. Redirecting you to secure payment.');
  273.             return $this->redirectToRoute('quote_payment_redirect', [
  274.                 'token' => $token,
  275.                 'invoice_id' => $invoice->getId(),
  276.             ]);
  277.         }
  278.         $this->addFlash('success''Quote accepted. Your invoice is waiting for payment confirmation.');
  279.         return $this->redirectToRoute('quote_view_customer', ['token' => $token]);
  280.     }
  281.     /**
  282.      * POST /quote/{token}/reject
  283.      */
  284.     public function RejectQuoteAction(Request $request$token)
  285.     {
  286.         $service $this->get('app.quote_service');
  287.         $quote $service->findByToken($token);
  288.         if (!$quote) {
  289.             throw $this->createNotFoundException('Quote not found.');
  290.         }
  291.         $reason trim($request->request->get('reason'''));
  292.         $service->customerReject($quote$reason ?: null);
  293.         $this->addFlash('info''Quote rejected. Our team will be in touch if you would like to discuss further.');
  294.         return $this->redirectToRoute('quote_view_customer', ['token' => $token]);
  295.     }
  296.     /**
  297.      * GET /quote/{token}/pay/{invoice_id}
  298.      */
  299.     public function PaymentRedirectAction(Request $request$token$invoice_id)
  300.     {
  301.         $service $this->get('app.quote_service');
  302.         $quote $service->findByToken($token);
  303.         if (!$quote) {
  304.             throw $this->createNotFoundException('Quote not found.');
  305.         }
  306.         /** @var LegacySubscriptionBillingService $billing */
  307.         $billing $this->get('app.legacy_subscription_billing_service');
  308.         $invoice = (int)$invoice_id 0
  309.             $this->getDoctrine()->getManager('company_group')
  310.                 ->getRepository('CompanyGroupBundle\Entity\EntityInvoice')
  311.                 ->find((int)$invoice_id)
  312.             : $billing->findQuoteInvoice($quote);
  313.         if (!$invoice) {
  314.             throw $this->createNotFoundException('Invoice not found.');
  315.         }
  316.         $successActionData json_decode((string)$invoice->getSuccessActionData(), true);
  317.         $linkedQuoteId is_array($successActionData) ? (int)($successActionData['quoteId'] ?? 0) : 0;
  318.         if ($linkedQuoteId !== (int)$quote->getId()) {
  319.             throw $this->createAccessDeniedException('Invoice does not belong to this quote.');
  320.         }
  321.         if ($quote->getPaymentType() !== SubscriptionQuote::PAYMENT_AUTOMATIC) {
  322.             $this->addFlash('info''This quote is configured for manual payment confirmation.');
  323.             return $this->redirectToRoute('quote_view_customer', ['token' => $token]);
  324.         }
  325.         $encData $this->get('url_encryptor')->encrypt(json_encode(
  326.             $billing->buildPaymentRedirectPayload($invoice11)
  327.         ));
  328.         return $this->redirect($this->generateUrl('make_payment_of_entity_invoice', [
  329.             'encData' => $encData,
  330.         ], UrlGeneratorInterface::ABSOLUTE_URL));
  331.     }
  332. }