src/Bidcoz/Bundle/FrontendBundle/Controller/DonationController.php line 84

Open in your IDE?
  1. <?php
  2. namespace Bidcoz\Bundle\FrontendBundle\Controller;
  3. use Bidcoz\Bundle\CoreBundle\Controller\CoreController;
  4. use Bidcoz\Bundle\CoreBundle\Entity\Address;
  5. use Bidcoz\Bundle\CoreBundle\Entity\Auction\Auction;
  6. use Bidcoz\Bundle\CoreBundle\Entity\Campaign;
  7. use Bidcoz\Bundle\CoreBundle\Entity\Donation\CashDonation;
  8. use Bidcoz\Bundle\CoreBundle\Entity\Donation\DonationLevel;
  9. use Bidcoz\Bundle\CoreBundle\Entity\Organization;
  10. use Bidcoz\Bundle\CoreBundle\Entity\PaymentGateway\Account\Account;
  11. use Bidcoz\Bundle\CoreBundle\Entity\Proxy\CashDonationProxy;
  12. use Bidcoz\Bundle\CoreBundle\Entity\Proxy\ItemDonationProxy;
  13. use Bidcoz\Bundle\CoreBundle\Entity\User;
  14. use Bidcoz\Bundle\FrontendBundle\Form\Type\AddressType;
  15. use Bidcoz\Bundle\FrontendBundle\Form\Type\Donation\CashDonationLevelType;
  16. use Bidcoz\Bundle\FrontendBundle\Form\Type\Donation\CashType;
  17. use Bidcoz\Bundle\FrontendBundle\Form\Type\Donation\ItemType;
  18. use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
  19. use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
  20. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
  21. use Symfony\Component\HttpFoundation\Request;
  22. use Symfony\Component\Routing\Annotation\Route;
  23. /**
  24.  * @Route("/{orgSlug}/{campaignSlug}/donate")
  25.  * @IsGranted("VIEW", subject="organization")
  26.  * @IsGranted("FRONT_END", subject="campaign")
  27.  */
  28. class DonationController extends CoreController
  29. {
  30.     /**
  31.      * Default one-time donation amounts for the merged /donate/money page.
  32.      * DECISIONS.md #8: Donations ship with defaults so an admin who changes
  33.      * nothing still gets a working page; per-campaign amounts become
  34.      * admin-configurable later. Eight amounts, rendered as a 2×4 grid.
  35.      */
  36.     private const DEFAULT_CASH_AMOUNTS = [2550100250500100025005000];
  37.     /**
  38.      * The "which type of donation?" chooser page has been removed (CAU-384).
  39.      * Navigation now links straight to Donate (/money) and Item Donations
  40.      * (/item), so there is no intermediate cash-or-item step.
  41.      *
  42.      * This route is kept only as a smart redirect, so existing generic
  43.      * "Donate" CTAs (legacy campaign templates, the membership page, the
  44.      * auction menu) and any bookmarked /donate links still resolve: it sends
  45.      * the donor to the first active way to give.
  46.      *
  47.      * This supersedes CAU-317, which redirected past the chooser only when a
  48.      * single option was active — the redirect is now unconditional.
  49.      *
  50.      * @Route("", name="campaign_donate")
  51.      */
  52.     public function donateAction(Organization $organizationCampaign $campaign)
  53.     {
  54.         $campaignParams = [
  55.             'orgSlug'      => $organization->getSlug(),
  56.             'campaignSlug' => $campaign->getSlug(),
  57.         ];
  58.         if ($this->isGranted('DONATION'$organization) && $campaign->getHasCashDonations() && $campaign->getShowCashDonations()) {
  59.             return $this->redirectToRoute('campaign_donate_cash'$campaignParams);
  60.         }
  61.         if ($this->isGranted('PROCUREMENT'$organization) && $campaign->getHasItemDonations()) {
  62.             return $this->redirectToRoute('campaign_donate_item'$campaignParams);
  63.         }
  64.         // Crypto is a real (beta) option, kept last so a cash/item campaign is
  65.         // never sent to it first, but a crypto-only campaign still reaches it.
  66.         if ($this->isGranted('DONATION'$organization) && $this->isGranted('DONATION_CRYPTO'$organization) && count($campaign->getCryptoWallets())) {
  67.             return $this->redirectToRoute('campaign_donate_crypto'$campaignParams);
  68.         }
  69.         // No active way to give: back to the campaign.
  70.         return $this->redirectToRoute('campaign_home'$campaignParams);
  71.     }
  72.     /**
  73.      * @Route("/money", name="campaign_donate_cash")
  74.      */
  75.     public function donateMoneyAction(Request $requestOrganization $organizationCampaign $campaign)
  76.     {
  77.         $stripeEnabled $organization->isStripeAllowed() && $organization->hasPaymentGatewayAccountType(Account::STRIPE);
  78.         $form $this->getCashDonationForm();
  79.         if ('POST' === $request->getMethod()) {
  80.             if (!$user $this->getUser()) {
  81.                 $this->addFlash('info''Please login or create an account before making a donation.');
  82.                 throw $this->createAccessDeniedException('Unable to access this page!');
  83.             }
  84.             $form->handleRequest($request);
  85.             if ($form->isSubmitted() && $form->isValid()) {
  86.                 $data $form->getData();
  87.                 // CAU-380: a monthly gift becomes a recurring Stripe subscription,
  88.                 // reusing the exact StripeManager::createSubscriptionAndSubscribeUser
  89.                 // + DonationManager::createCashDonation path that Fundraising and
  90.                 // Memberships already use. The one-time path is unchanged.
  91.                 $isMonthly $stripeEnabled
  92.                     && 'monthly' === ($data['frequency'] ?? null)
  93.                     && !empty($data['stripeToken']);
  94.                 if ($isMonthly) {
  95.                     /** @var \Bidcoz\Bundle\CoreBundle\Entity\Donation\Interval $interval */
  96.                     $interval $this->getRepository('Donation\Interval')
  97.                         ->findOneBy(['type' => 'month''interval' => 1]);
  98.                     // A stand-alone Donate gift has no level; the donor's monthly
  99.                     // amount is carried as the additional amount, one payment per
  100.                     // interval (intervalsCnt = 1), so createCashDonation records the
  101.                     // monthly amount directly rather than dividing a pledged total.
  102.                     $proxy = new CashDonationProxy(null$user);
  103.                     $proxy->setRequiresLevel(false);
  104.                     $proxy->setAdditionalAmount($data['amount']);
  105.                     $proxy->setInterval($interval);
  106.                     $proxy->setIntervalsCnt(1);
  107.                     $proxy->setStripeToken($data['stripeToken']);
  108.                     // CAU-403: Donate-monthly is open-ended (no committed total) —
  109.                     // the subscription runs until the donor cancels, and it must not
  110.                     // trip the pledge auto-close webhook.
  111.                     $proxy->setOpenEnded(true);
  112.                     $subscriptionId $this->getStripeManager()->createSubscriptionAndSubscribeUser(
  113.                         $campaign,
  114.                         $user,
  115.                         $interval,
  116.                         $data['stripeToken'],
  117.                         $data['amount']
  118.                     );
  119.                     $this->getDonationManager()->createCashDonation($campaign$user$proxy$subscriptionId);
  120.                 } else {
  121.                     $this->getDonationManager()->createSimpleCashDonation($campaign$user$data['amount']);
  122.                 }
  123.                 $this->getEntityManager()->flush();
  124.                 $this->addFlash('success''Thank you for your donation');
  125.                 return $this->redirectToRoute('account_campaign_purchases', [
  126.                     'orgSlug'      => $organization->getSlug(),
  127.                     'campaignSlug' => $campaign->getSlug(),
  128.                 ]);
  129.             }
  130.         }
  131.         return $this->render('@BidcozFrontend/Campaign/Donate/donate_cash.html.twig', [
  132.             'organization'    => $organization,
  133.             'campaign'        => $campaign,
  134.             'form'            => $form->createView(),
  135.             'donationAmounts' => self::DEFAULT_CASH_AMOUNTS,
  136.             'stripeEnabled'   => $stripeEnabled,
  137.             // Impact tiers ("Your donation at work") and the generated contextual
  138.             // message are admin-authored (DECISIONS.md #7); the backend for them
  139.             // does not exist yet, so this is empty and the sections stay hidden.
  140.             'impactTiers'     => [],
  141.         ]);
  142.     }
  143.     /**
  144.      * @Route("/money/level/{donation_level_id}", name="campaign_donate_cash_level", methods={"GET"})
  145.      * @ParamConverter("donationLevel", class="Bidcoz\Bundle\CoreBundle\Entity\Donation\DonationLevel", options={"id" = "donation_level_id"})
  146.      */
  147.     public function viewDonationLevelAction(Request $requestOrganization $organizationCampaign $campaignDonationLevel $donationLevel)
  148.     {
  149.         if (!$user $this->getUser()) {
  150.             $this->addFlash('info''Please login or create an account before making a donation.');
  151.             throw $this->createAccessDeniedException('Unable to access this page!');
  152.         }
  153.         $stripeEnabled $organization->isStripeAllowed() && $organization->hasPaymentGatewayAccountType(Account::STRIPE);
  154.         $proxy $this->createCashDonationProxy($donationLevel$campaign);
  155.         $proxy->setDonorName($this->getUser()->getName());
  156.         $form $this->getCashDonationLevelForm($proxy$stripeEnabled);
  157.         return $this->render('@BidcozFrontend/Campaign/Donate/donate.html.twig', [
  158.             'organization'  => $organization,
  159.             'campaign'      => $campaign,
  160.             'donationLevel' => $donationLevel,
  161.             'form'          => $form->createView(),
  162.             'stripeEnabled' => $stripeEnabled,
  163.         ]);
  164.     }
  165.     /**
  166.      * @Route("/money/level/{donation_level_id}", name="campaign_donate_cash_level_save", methods={"POST"})
  167.      * @ParamConverter("donationLevel", class="Bidcoz\Bundle\CoreBundle\Entity\Donation\DonationLevel", options={"id" = "donation_level_id"})
  168.      */
  169.     public function makeDonationAction(Request $requestOrganization $organizationCampaign $campaignDonationLevel $donationLevel)
  170.     {
  171.         if (!$user $this->getUser()) {
  172.             $this->addFlash('info''Please login or create an account before purchasing a ticket.');
  173.             throw $this->createAccessDeniedException('Unable to access this page!');
  174.         }
  175.         $stripeEnabled $organization->isStripeAllowed() && $organization->hasPaymentGatewayAccountType(Account::STRIPE);
  176.         $proxy $this->createCashDonationProxy($donationLevel$campaign);
  177.         $form $this->getCashDonationLevelForm($proxy$stripeEnabled);
  178.         $form->handleRequest($request);
  179.         if (!$organization->isStripeAddressCheck()) {
  180.             $address     $user->getAddress();
  181.             $addressForm $this->getAddressForm($address);
  182.             $addressForm->handleRequest($request);
  183.             if ($addressForm->isSubmitted() && $addressForm->isValid()) {
  184.                 $user->setAddress($address);
  185.             }
  186.         }
  187.         try {
  188.             if ($form->isSubmitted() && $form->isValid()) {
  189.                 $user   $this->getUser();
  190.                 $level  $proxy->getDonationLevel();
  191.                 $amount $level->getAmount() + $proxy->getAdditionalAmount();
  192.                 if ($proxy->getStripeToken() && $proxy->getInterval()) {
  193.                     $intervalAmount round($amount $proxy->getIntervalsCnt(), 2);
  194.                     $realAmount     $intervalAmount $proxy->getIntervalsCnt();      // real amount after round
  195.                     $proxy->setAdditionalAmount($realAmount $level->getAmount()); // update AdditionalAmount according to realAmount
  196.                     $subscriptionId $this->getStripeManager()->createSubscriptionAndSubscribeUser(
  197.                         $campaign,
  198.                         $user,
  199.                         $proxy->getInterval(),
  200.                         $proxy->getStripeToken(),
  201.                         $intervalAmount
  202.                     );
  203.                 } else {
  204.                     $subscriptionId null;
  205.                 }
  206.                 /** @var CashDonation $donation */
  207.                 $donation $this->getDonationManager()->createCashDonation($campaign$user$proxy$subscriptionId);
  208.                 //stripe payment for one-time donation
  209.                 if (!$subscriptionId && $proxy->getStripeToken()) {
  210.                     $transaction $this->getStripeManager()->createStripeTransactionForPurchases(
  211.                             $campaign,
  212.                             $user,
  213.                             $donation->getPurchases()->toArray(),
  214.                             $proxy->getStripeToken()
  215.                         );
  216.                 }
  217.                 $this->getEntityManager()->flush();
  218.                 $this->addFlash('success''Donation successful');
  219.                 // If not recurring payments, send to cart with Appeal item
  220.                 if (!$subscriptionId) {
  221.                     $redirect_path 'account_campaign_purchases';
  222.                 } else {
  223.                     $redirect_path 'account_campaign_purchase_stripe_success';
  224.                 }
  225.                 // Send to cart instead of campaign homepage.
  226.                 // return $this->redirectToRoute('campaign_home', [
  227.                 return $this->redirectToRoute($redirect_path, [
  228.                     'orgSlug'      => $organization->getSlug(),
  229.                     'campaignSlug' => $campaign->getSlug(),
  230.                 ]);
  231.             }
  232.         } catch (\Stripe\Error\Base $e) {
  233.             $this->addFlash('danger'$e->getMessage());
  234.             $this->getEntityManager()->clear();
  235.         }
  236.         return $this->render('@BidcozFrontend/Campaign/Donate/donate.html.twig', [
  237.             'organization'  => $organization,
  238.             'campaign'      => $campaign,
  239.             'donationLevel' => $donationLevel,
  240.             'form'          => $form->createView(),
  241.             'stripeEnabled' => $stripeEnabled,
  242.         ]);
  243.     }
  244.     /**
  245.      * @Route("/crypto", name="campaign_donate_crypto")
  246.      * @IsGranted("DONATION_CRYPTO", subject="organization")
  247.      */
  248.     public function donateCryptoAction(Request $requestOrganization $organizationCampaign $campaign)
  249.     {
  250.         $cryptoWallets $campaign->getCryptoWallets();
  251.         return $this->render('@BidcozFrontend/Campaign/Donate/donate_crypto.html.twig', [
  252.             'organization'   => $organization,
  253.             'campaign'       => $campaign,
  254.             'crypto_wallets' => $cryptoWallets,
  255.         ]);
  256.     }
  257.     /**
  258.      * @Route("/levels", name="campaign_donate_levels")
  259.      */
  260.     public function donationLevelsAction(Request $requestOrganization $organizationCampaign $campaign)
  261.     {
  262.         $donationLevels $this->getRepository('Donation\DonationLevel')->findCampaignDonationLevels($campaign);
  263.         return $this->render('@BidcozFrontend/Campaign/Donate/levels.html.twig', [
  264.             'organization'   => $organization,
  265.             'campaign'       => $campaign,
  266.             'donationLevels' => $donationLevels,
  267.         ]);
  268.     }
  269.     /**
  270.      * @Route("/item", name="campaign_donate_item")
  271.      */
  272.     public function donateItemAction(Request $requestOrganization $organizationCampaign $campaignAuction $auction)
  273.     {
  274.         // Login is required BEFORE the form is shown, not at submit (CAU-330).
  275.         //
  276.         // This check used to sit inside the POST branch. A donor could load the
  277.         // page anonymously, fill in every field, upload photos, and only then be
  278.         // thrown to the login screen — losing the lot. That was the reported
  279.         // "submitting sends me to login" bug: the redirect was a symptom, the
  280.         // unguarded GET was the cause.
  281.         //
  282.         // Gating on GET matches how the rest of the app already works — ticket
  283.         // purchase gates as soon as a ticket type is chosen, and the membership
  284.         // and donation-level flows gate on the GET that renders their form.
  285.         if (!$this->getUser()) {
  286.             $this->addFlash('info''Please login or create an account before donating an item.');
  287.             throw $this->createAccessDeniedException('Unable to access this page!');
  288.         }
  289.         $itemProxy = new ItemDonationProxy($campaign);
  290.         $form      $this->getItemDonationForm($itemProxy);
  291.         if ('POST' === $request->getMethod()) {
  292.             $form->handleRequest($request);
  293.             if ($form->isSubmitted() && $form->isValid()) {
  294.                 $this->getDonationManager()->createItemDonation($itemProxy$this->getUser());
  295.                 // CAU-321: hand off to the real confirmation page instead of
  296.                 // re-rendering the empty form with a flash. A one-time flag gates
  297.                 // that route so a direct visit never shows a false thank-you.
  298.                 $this->addFlash('itemDonationComplete'true);
  299.                 return $this->redirectToRoute('campaign_donate_item_confirmation', [
  300.                     'orgSlug'      => $organization->getSlug(),
  301.                     'campaignSlug' => $campaign->getSlug(),
  302.                 ]);
  303.             }
  304.         }
  305.         return $this->render('@BidcozFrontend/Campaign/Donate/donate_item.html.twig', [
  306.             'organization' => $organization,
  307.             'campaign'     => $campaign,
  308.             'form'         => $form->createView(),
  309.         ]);
  310.     }
  311.     /**
  312.      * Item-donation confirmation / thank-you page (CAU-321, Figma 506:1903).
  313.      *
  314.      * A real page now, replacing the flash-driven branch of donate_item.html.twig.
  315.      * It is reached only right after a successful submission: donateItemAction sets
  316.      * a one-time flag and redirects here. A direct visit has no donation to confirm,
  317.      * so the donor is sent back to the item form rather than shown a false thank-you.
  318.      *
  319.      * @Route("/item/thank-you", name="campaign_donate_item_confirmation")
  320.      */
  321.     public function donateItemConfirmationAction(Request $requestOrganization $organizationCampaign $campaign)
  322.     {
  323.         $campaignParams = [
  324.             'orgSlug'      => $organization->getSlug(),
  325.             'campaignSlug' => $campaign->getSlug(),
  326.         ];
  327.         if (!count($request->getSession()->getFlashBag()->get('itemDonationComplete'))) {
  328.             return $this->redirectToRoute('campaign_donate_item'$campaignParams);
  329.         }
  330.         return $this->render('@BidcozFrontend/Campaign/Donate/donate_item_confirmation.html.twig', [
  331.             'organization' => $organization,
  332.             'campaign'     => $campaign,
  333.         ]);
  334.     }
  335.     protected function createItem(Auction $auctionUser $user)
  336.     {
  337.         $item $this->getItemManager()->createItem($auction);
  338.         $item->setActive(false);
  339.         $item->setDonor($user);
  340.         return $item;
  341.     }
  342.     protected function getCashDonationForm()
  343.     {
  344.         return $this->createForm(CashType::class);
  345.     }
  346.     protected function getCashDonationLevelForm(CashDonationProxy $cashDonationProxy$withInterval)
  347.     {
  348.         return $this->createForm(CashDonationLevelType::class, $cashDonationProxy, [
  349.             'withInterval' => $withInterval,
  350.             'showMessage'  => $cashDonationProxy->getDonationLevel()->getShowMessage(),
  351.         ]);
  352.     }
  353.     protected function getItemDonationForm(ItemDonationProxy $item)
  354.     {
  355.         return $this->createForm(ItemType::class, $item);
  356.     }
  357.     protected function createCashDonationProxy(DonationLevel $donationLevelCampaign $campaign)
  358.     {
  359.         $user      $this->getUser();
  360.         $questions $this->getRepository('Donation\DonationQuestion')->findCampaignDonationQuestions($campaigntrue);
  361.         $answers array_map(function ($q) use ($user) {
  362.             $question $q->getQuestion();
  363.             return $this->getQuestionManager()->createDonationAnswer($question$user);
  364.         }, $questions);
  365.         $proxy = new CashDonationProxy($donationLevel$user);
  366.         $proxy->setAnswers($answers);
  367.         return $proxy;
  368.     }
  369.     protected function getAddressForm(Address $address)
  370.     {
  371.         return $this->createForm(AddressType::class, $address);
  372.     }
  373. }