<?php
namespace Bidcoz\Bundle\FrontendBundle\Controller;
use Bidcoz\Bundle\CoreBundle\Controller\CoreController;
use Bidcoz\Bundle\CoreBundle\Entity\Address;
use Bidcoz\Bundle\CoreBundle\Entity\Auction\Auction;
use Bidcoz\Bundle\CoreBundle\Entity\Campaign;
use Bidcoz\Bundle\CoreBundle\Entity\Donation\CashDonation;
use Bidcoz\Bundle\CoreBundle\Entity\Donation\DonationLevel;
use Bidcoz\Bundle\CoreBundle\Entity\Organization;
use Bidcoz\Bundle\CoreBundle\Entity\PaymentGateway\Account\Account;
use Bidcoz\Bundle\CoreBundle\Entity\Proxy\CashDonationProxy;
use Bidcoz\Bundle\CoreBundle\Entity\Proxy\ItemDonationProxy;
use Bidcoz\Bundle\CoreBundle\Entity\User;
use Bidcoz\Bundle\FrontendBundle\Form\Type\AddressType;
use Bidcoz\Bundle\FrontendBundle\Form\Type\Donation\CashDonationLevelType;
use Bidcoz\Bundle\FrontendBundle\Form\Type\Donation\CashType;
use Bidcoz\Bundle\FrontendBundle\Form\Type\Donation\ItemType;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route("/{orgSlug}/{campaignSlug}/donate")
* @IsGranted("VIEW", subject="organization")
* @IsGranted("FRONT_END", subject="campaign")
*/
class DonationController extends CoreController
{
/**
* Default one-time donation amounts for the merged /donate/money page.
* DECISIONS.md #8: Donations ship with defaults so an admin who changes
* nothing still gets a working page; per-campaign amounts become
* admin-configurable later. Eight amounts, rendered as a 2×4 grid.
*/
private const DEFAULT_CASH_AMOUNTS = [25, 50, 100, 250, 500, 1000, 2500, 5000];
/**
* The "which type of donation?" chooser page has been removed (CAU-384).
* Navigation now links straight to Donate (/money) and Item Donations
* (/item), so there is no intermediate cash-or-item step.
*
* This route is kept only as a smart redirect, so existing generic
* "Donate" CTAs (legacy campaign templates, the membership page, the
* auction menu) and any bookmarked /donate links still resolve: it sends
* the donor to the first active way to give.
*
* This supersedes CAU-317, which redirected past the chooser only when a
* single option was active — the redirect is now unconditional.
*
* @Route("", name="campaign_donate")
*/
public function donateAction(Organization $organization, Campaign $campaign)
{
$campaignParams = [
'orgSlug' => $organization->getSlug(),
'campaignSlug' => $campaign->getSlug(),
];
if ($this->isGranted('DONATION', $organization) && $campaign->getHasCashDonations() && $campaign->getShowCashDonations()) {
return $this->redirectToRoute('campaign_donate_cash', $campaignParams);
}
if ($this->isGranted('PROCUREMENT', $organization) && $campaign->getHasItemDonations()) {
return $this->redirectToRoute('campaign_donate_item', $campaignParams);
}
// Crypto is a real (beta) option, kept last so a cash/item campaign is
// never sent to it first, but a crypto-only campaign still reaches it.
if ($this->isGranted('DONATION', $organization) && $this->isGranted('DONATION_CRYPTO', $organization) && count($campaign->getCryptoWallets())) {
return $this->redirectToRoute('campaign_donate_crypto', $campaignParams);
}
// No active way to give: back to the campaign.
return $this->redirectToRoute('campaign_home', $campaignParams);
}
/**
* @Route("/money", name="campaign_donate_cash")
*/
public function donateMoneyAction(Request $request, Organization $organization, Campaign $campaign)
{
$stripeEnabled = $organization->isStripeAllowed() && $organization->hasPaymentGatewayAccountType(Account::STRIPE);
$form = $this->getCashDonationForm();
if ('POST' === $request->getMethod()) {
if (!$user = $this->getUser()) {
$this->addFlash('info', 'Please login or create an account before making a donation.');
throw $this->createAccessDeniedException('Unable to access this page!');
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
// CAU-380: a monthly gift becomes a recurring Stripe subscription,
// reusing the exact StripeManager::createSubscriptionAndSubscribeUser
// + DonationManager::createCashDonation path that Fundraising and
// Memberships already use. The one-time path is unchanged.
$isMonthly = $stripeEnabled
&& 'monthly' === ($data['frequency'] ?? null)
&& !empty($data['stripeToken']);
if ($isMonthly) {
/** @var \Bidcoz\Bundle\CoreBundle\Entity\Donation\Interval $interval */
$interval = $this->getRepository('Donation\Interval')
->findOneBy(['type' => 'month', 'interval' => 1]);
// A stand-alone Donate gift has no level; the donor's monthly
// amount is carried as the additional amount, one payment per
// interval (intervalsCnt = 1), so createCashDonation records the
// monthly amount directly rather than dividing a pledged total.
$proxy = new CashDonationProxy(null, $user);
$proxy->setRequiresLevel(false);
$proxy->setAdditionalAmount($data['amount']);
$proxy->setInterval($interval);
$proxy->setIntervalsCnt(1);
$proxy->setStripeToken($data['stripeToken']);
// CAU-403: Donate-monthly is open-ended (no committed total) —
// the subscription runs until the donor cancels, and it must not
// trip the pledge auto-close webhook.
$proxy->setOpenEnded(true);
$subscriptionId = $this->getStripeManager()->createSubscriptionAndSubscribeUser(
$campaign,
$user,
$interval,
$data['stripeToken'],
$data['amount']
);
$this->getDonationManager()->createCashDonation($campaign, $user, $proxy, $subscriptionId);
} else {
$this->getDonationManager()->createSimpleCashDonation($campaign, $user, $data['amount']);
}
$this->getEntityManager()->flush();
$this->addFlash('success', 'Thank you for your donation');
return $this->redirectToRoute('account_campaign_purchases', [
'orgSlug' => $organization->getSlug(),
'campaignSlug' => $campaign->getSlug(),
]);
}
}
return $this->render('@BidcozFrontend/Campaign/Donate/donate_cash.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
'form' => $form->createView(),
'donationAmounts' => self::DEFAULT_CASH_AMOUNTS,
'stripeEnabled' => $stripeEnabled,
// Impact tiers ("Your donation at work") and the generated contextual
// message are admin-authored (DECISIONS.md #7); the backend for them
// does not exist yet, so this is empty and the sections stay hidden.
'impactTiers' => [],
]);
}
/**
* @Route("/money/level/{donation_level_id}", name="campaign_donate_cash_level", methods={"GET"})
* @ParamConverter("donationLevel", class="Bidcoz\Bundle\CoreBundle\Entity\Donation\DonationLevel", options={"id" = "donation_level_id"})
*/
public function viewDonationLevelAction(Request $request, Organization $organization, Campaign $campaign, DonationLevel $donationLevel)
{
if (!$user = $this->getUser()) {
$this->addFlash('info', 'Please login or create an account before making a donation.');
throw $this->createAccessDeniedException('Unable to access this page!');
}
$stripeEnabled = $organization->isStripeAllowed() && $organization->hasPaymentGatewayAccountType(Account::STRIPE);
$proxy = $this->createCashDonationProxy($donationLevel, $campaign);
$proxy->setDonorName($this->getUser()->getName());
$form = $this->getCashDonationLevelForm($proxy, $stripeEnabled);
return $this->render('@BidcozFrontend/Campaign/Donate/donate.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
'donationLevel' => $donationLevel,
'form' => $form->createView(),
'stripeEnabled' => $stripeEnabled,
]);
}
/**
* @Route("/money/level/{donation_level_id}", name="campaign_donate_cash_level_save", methods={"POST"})
* @ParamConverter("donationLevel", class="Bidcoz\Bundle\CoreBundle\Entity\Donation\DonationLevel", options={"id" = "donation_level_id"})
*/
public function makeDonationAction(Request $request, Organization $organization, Campaign $campaign, DonationLevel $donationLevel)
{
if (!$user = $this->getUser()) {
$this->addFlash('info', 'Please login or create an account before purchasing a ticket.');
throw $this->createAccessDeniedException('Unable to access this page!');
}
$stripeEnabled = $organization->isStripeAllowed() && $organization->hasPaymentGatewayAccountType(Account::STRIPE);
$proxy = $this->createCashDonationProxy($donationLevel, $campaign);
$form = $this->getCashDonationLevelForm($proxy, $stripeEnabled);
$form->handleRequest($request);
if (!$organization->isStripeAddressCheck()) {
$address = $user->getAddress();
$addressForm = $this->getAddressForm($address);
$addressForm->handleRequest($request);
if ($addressForm->isSubmitted() && $addressForm->isValid()) {
$user->setAddress($address);
}
}
try {
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
$level = $proxy->getDonationLevel();
$amount = $level->getAmount() + $proxy->getAdditionalAmount();
if ($proxy->getStripeToken() && $proxy->getInterval()) {
$intervalAmount = round($amount / $proxy->getIntervalsCnt(), 2);
$realAmount = $intervalAmount * $proxy->getIntervalsCnt(); // real amount after round
$proxy->setAdditionalAmount($realAmount - $level->getAmount()); // update AdditionalAmount according to realAmount
$subscriptionId = $this->getStripeManager()->createSubscriptionAndSubscribeUser(
$campaign,
$user,
$proxy->getInterval(),
$proxy->getStripeToken(),
$intervalAmount
);
} else {
$subscriptionId = null;
}
/** @var CashDonation $donation */
$donation = $this->getDonationManager()->createCashDonation($campaign, $user, $proxy, $subscriptionId);
//stripe payment for one-time donation
if (!$subscriptionId && $proxy->getStripeToken()) {
$transaction = $this->getStripeManager()->createStripeTransactionForPurchases(
$campaign,
$user,
$donation->getPurchases()->toArray(),
$proxy->getStripeToken()
);
}
$this->getEntityManager()->flush();
$this->addFlash('success', 'Donation successful');
// If not recurring payments, send to cart with Appeal item
if (!$subscriptionId) {
$redirect_path = 'account_campaign_purchases';
} else {
$redirect_path = 'account_campaign_purchase_stripe_success';
}
// Send to cart instead of campaign homepage.
// return $this->redirectToRoute('campaign_home', [
return $this->redirectToRoute($redirect_path, [
'orgSlug' => $organization->getSlug(),
'campaignSlug' => $campaign->getSlug(),
]);
}
} catch (\Stripe\Error\Base $e) {
$this->addFlash('danger', $e->getMessage());
$this->getEntityManager()->clear();
}
return $this->render('@BidcozFrontend/Campaign/Donate/donate.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
'donationLevel' => $donationLevel,
'form' => $form->createView(),
'stripeEnabled' => $stripeEnabled,
]);
}
/**
* @Route("/crypto", name="campaign_donate_crypto")
* @IsGranted("DONATION_CRYPTO", subject="organization")
*/
public function donateCryptoAction(Request $request, Organization $organization, Campaign $campaign)
{
$cryptoWallets = $campaign->getCryptoWallets();
return $this->render('@BidcozFrontend/Campaign/Donate/donate_crypto.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
'crypto_wallets' => $cryptoWallets,
]);
}
/**
* @Route("/levels", name="campaign_donate_levels")
*/
public function donationLevelsAction(Request $request, Organization $organization, Campaign $campaign)
{
$donationLevels = $this->getRepository('Donation\DonationLevel')->findCampaignDonationLevels($campaign);
return $this->render('@BidcozFrontend/Campaign/Donate/levels.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
'donationLevels' => $donationLevels,
]);
}
/**
* @Route("/item", name="campaign_donate_item")
*/
public function donateItemAction(Request $request, Organization $organization, Campaign $campaign, Auction $auction)
{
// Login is required BEFORE the form is shown, not at submit (CAU-330).
//
// This check used to sit inside the POST branch. A donor could load the
// page anonymously, fill in every field, upload photos, and only then be
// thrown to the login screen — losing the lot. That was the reported
// "submitting sends me to login" bug: the redirect was a symptom, the
// unguarded GET was the cause.
//
// Gating on GET matches how the rest of the app already works — ticket
// purchase gates as soon as a ticket type is chosen, and the membership
// and donation-level flows gate on the GET that renders their form.
if (!$this->getUser()) {
$this->addFlash('info', 'Please login or create an account before donating an item.');
throw $this->createAccessDeniedException('Unable to access this page!');
}
$itemProxy = new ItemDonationProxy($campaign);
$form = $this->getItemDonationForm($itemProxy);
if ('POST' === $request->getMethod()) {
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->getDonationManager()->createItemDonation($itemProxy, $this->getUser());
// CAU-321: hand off to the real confirmation page instead of
// re-rendering the empty form with a flash. A one-time flag gates
// that route so a direct visit never shows a false thank-you.
$this->addFlash('itemDonationComplete', true);
return $this->redirectToRoute('campaign_donate_item_confirmation', [
'orgSlug' => $organization->getSlug(),
'campaignSlug' => $campaign->getSlug(),
]);
}
}
return $this->render('@BidcozFrontend/Campaign/Donate/donate_item.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
'form' => $form->createView(),
]);
}
/**
* Item-donation confirmation / thank-you page (CAU-321, Figma 506:1903).
*
* A real page now, replacing the flash-driven branch of donate_item.html.twig.
* It is reached only right after a successful submission: donateItemAction sets
* a one-time flag and redirects here. A direct visit has no donation to confirm,
* so the donor is sent back to the item form rather than shown a false thank-you.
*
* @Route("/item/thank-you", name="campaign_donate_item_confirmation")
*/
public function donateItemConfirmationAction(Request $request, Organization $organization, Campaign $campaign)
{
$campaignParams = [
'orgSlug' => $organization->getSlug(),
'campaignSlug' => $campaign->getSlug(),
];
if (!count($request->getSession()->getFlashBag()->get('itemDonationComplete'))) {
return $this->redirectToRoute('campaign_donate_item', $campaignParams);
}
return $this->render('@BidcozFrontend/Campaign/Donate/donate_item_confirmation.html.twig', [
'organization' => $organization,
'campaign' => $campaign,
]);
}
protected function createItem(Auction $auction, User $user)
{
$item = $this->getItemManager()->createItem($auction);
$item->setActive(false);
$item->setDonor($user);
return $item;
}
protected function getCashDonationForm()
{
return $this->createForm(CashType::class);
}
protected function getCashDonationLevelForm(CashDonationProxy $cashDonationProxy, $withInterval)
{
return $this->createForm(CashDonationLevelType::class, $cashDonationProxy, [
'withInterval' => $withInterval,
'showMessage' => $cashDonationProxy->getDonationLevel()->getShowMessage(),
]);
}
protected function getItemDonationForm(ItemDonationProxy $item)
{
return $this->createForm(ItemType::class, $item);
}
protected function createCashDonationProxy(DonationLevel $donationLevel, Campaign $campaign)
{
$user = $this->getUser();
$questions = $this->getRepository('Donation\DonationQuestion')->findCampaignDonationQuestions($campaign, true);
$answers = array_map(function ($q) use ($user) {
$question = $q->getQuestion();
return $this->getQuestionManager()->createDonationAnswer($question, $user);
}, $questions);
$proxy = new CashDonationProxy($donationLevel, $user);
$proxy->setAnswers($answers);
return $proxy;
}
protected function getAddressForm(Address $address)
{
return $this->createForm(AddressType::class, $address);
}
}