diff --git a/migrations/Version20241230144600.php b/migrations/Version20241230144600.php new file mode 100644 index 0000000..3711dd2 --- /dev/null +++ b/migrations/Version20241230144600.php @@ -0,0 +1,36 @@ +addSql('CREATE TABLE membership (ends_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, card_last4_digits VARCHAR(255) DEFAULT NULL, id UUID NOT NULL, stripe_subscription_id VARCHAR(255) DEFAULT NULL, stripe_plan_id VARCHAR(255) DEFAULT NULL, billing_period_ends_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, player_id UUID NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_86FFD28599E6F5DF ON membership (player_id)'); + $this->addSql('ALTER TABLE membership ADD CONSTRAINT FK_86FFD28599E6F5DF FOREIGN KEY (player_id) REFERENCES player (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE player ADD stripe_customer_id VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE membership DROP CONSTRAINT FK_86FFD28599E6F5DF'); + $this->addSql('DROP TABLE membership'); + $this->addSql('ALTER TABLE player DROP stripe_customer_id'); + } +} diff --git a/migrations/Version20241230144632.php b/migrations/Version20241230144632.php new file mode 100644 index 0000000..a9fae56 --- /dev/null +++ b/migrations/Version20241230144632.php @@ -0,0 +1,33 @@ +addSql('DROP INDEX idx_86ffd28599e6f5df'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_86FFD28599E6F5DF ON membership (player_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_86FFD28599E6F5DF'); + $this->addSql('CREATE INDEX idx_86ffd28599e6f5df ON membership (player_id)'); + } +} diff --git a/migrations/Version20241230165038.php b/migrations/Version20241230165038.php new file mode 100644 index 0000000..dfb03d5 --- /dev/null +++ b/migrations/Version20241230165038.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE membership DROP card_last4_digits'); + $this->addSql('ALTER TABLE membership DROP stripe_plan_id'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE membership ADD card_last4_digits VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE membership ADD stripe_plan_id VARCHAR(255) DEFAULT NULL'); + } +} diff --git a/src/Controller/BillingPortalController.php b/src/Controller/BillingPortalController.php new file mode 100644 index 0000000..95314d6 --- /dev/null +++ b/src/Controller/BillingPortalController.php @@ -0,0 +1,47 @@ + '/clenstvi/platebni-portal', + 'en' => '/en/memberstip/billing-portal', + ], + name: 'billing_portal', + )] + public function __invoke(#[CurrentUser] User $user): Response + { + $player = $this->retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + return $this->redirectToRoute('homepage'); + } + + $customerId = $player->stripeCustomerId; + + if ($customerId === null) { + return $this->redirectToRoute('membership'); + } + + $portalUrl = $this->membershipManagement->getBillingPortalUrl($customerId); + + return $this->redirect($portalUrl, 303); + } +} diff --git a/src/Controller/BuyMembershipController.php b/src/Controller/BuyMembershipController.php new file mode 100644 index 0000000..2315248 --- /dev/null +++ b/src/Controller/BuyMembershipController.php @@ -0,0 +1,41 @@ + '/koupit-clenstvi', + 'en' => '/en/buy-membership', + ], + name: 'buy_membership', + )] + public function __invoke(#[CurrentUser] User $user): Response + { + $player = $this->retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + return $this->redirectToRoute('homepage'); + } + + $paymentUrl = $this->membershipManagement->getMembershipPaymentUrl(); + + return $this->redirect($paymentUrl, 303); + } +} diff --git a/src/Controller/MembershipController.php b/src/Controller/MembershipController.php new file mode 100644 index 0000000..90f3282 --- /dev/null +++ b/src/Controller/MembershipController.php @@ -0,0 +1,48 @@ + '/clenstvi/', + 'en' => '/en/membership', + ], + name: 'membership', + )] + public function __invoke(#[CurrentUser] User $user): Response + { + $player = $this->retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + return $this->redirectToRoute('homepage'); + } + + try { + $membership = $this->getPlayerMembership->byId($player->playerId); + } catch (MembershipNotFound) { + $membership = null; + } + + return $this->render('membership.html.twig', [ + 'membership' => $membership, + ]); + } +} diff --git a/src/Controller/RemovePuzzleFromCollectionController.php b/src/Controller/RemovePuzzleFromCollectionController.php index 3c92142..82adb26 100644 --- a/src/Controller/RemovePuzzleFromCollectionController.php +++ b/src/Controller/RemovePuzzleFromCollectionController.php @@ -4,14 +4,7 @@ namespace SpeedPuzzling\Web\Controller; use Auth0\Symfony\Models\User; -use SpeedPuzzling\Web\Exceptions\CanNotFavoriteYourself; -use SpeedPuzzling\Web\Exceptions\PlayerIsAlreadyInFavorites; -use SpeedPuzzling\Web\Exceptions\PlayerIsNotInFavorites; -use SpeedPuzzling\Web\Exceptions\PlayerNotFound; use SpeedPuzzling\Web\Exceptions\PuzzleNotFound; -use SpeedPuzzling\Web\Message\AddPlayerToFavorites; -use SpeedPuzzling\Web\Message\AddPuzzleToCollection; -use SpeedPuzzling\Web\Message\RemovePlayerFromFavorites; use SpeedPuzzling\Web\Message\RemovePuzzleFromCollection; use SpeedPuzzling\Web\Services\RetrieveLoggedUserProfile; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; diff --git a/src/Controller/StripeCheckoutCancelController.php b/src/Controller/StripeCheckoutCancelController.php new file mode 100644 index 0000000..9b89834 --- /dev/null +++ b/src/Controller/StripeCheckoutCancelController.php @@ -0,0 +1,23 @@ + '/nakup-clenstvi-zrusen', + 'en' => '/en/membership-checkout-cancel', + ], + name: 'stripe_checkout_cancel', + )] + public function __invoke(null|string $code): Response + { + return $this->render('membership-checkout-cancel.html.twig'); + } +} diff --git a/src/Controller/StripeCheckoutSuccessController.php b/src/Controller/StripeCheckoutSuccessController.php new file mode 100644 index 0000000..fb3fc9a --- /dev/null +++ b/src/Controller/StripeCheckoutSuccessController.php @@ -0,0 +1,49 @@ + '/uspesny-nakup-clenstvi/{sessionId}', + 'en' => '/en/membership-checkout-success/{sessionId}', + ], + name: 'stripe_checkout_success', + )] + public function __invoke(#[CurrentUser] UserInterface $user, string $sessionId): Response + { + $player = $this->retrieveLoggedUserProfile->getProfile(); + + if ($player === null) { + return $this->redirectToRoute('my_profile'); + } + + $this->messageBus->dispatch( + new SubscribeMembership( + $player->playerId, + $sessionId, + ), + ); + + $this->addFlash('success', 'flashes.membership_subscribed_successfully'); + + return $this->redirectToRoute('membership'); + } +} diff --git a/src/Controller/StripeWebhookController.php b/src/Controller/StripeWebhookController.php new file mode 100644 index 0000000..c26661e --- /dev/null +++ b/src/Controller/StripeWebhookController.php @@ -0,0 +1,22 @@ + '/scan-puzzli/{code}', + 'en' => '/en/scan-puzzle/{code}', + ], + name: 'stripe_webhook', + )] + public function __invoke(null|string $code): Response + { + } +} diff --git a/src/Entity/Membership.php b/src/Entity/Membership.php new file mode 100644 index 0000000..e5aa911 --- /dev/null +++ b/src/Entity/Membership.php @@ -0,0 +1,51 @@ +stripeSubscriptionId = $stripeSubscriptionId; + $this->billingPeriodEndsAt = $billingPeriodEndsAt; + $this->endsAt = null; + } +} diff --git a/src/Entity/Player.php b/src/Entity/Player.php index fa557aa..4d130d3 100644 --- a/src/Entity/Player.php +++ b/src/Entity/Player.php @@ -53,6 +53,10 @@ class Player #[Column(type: Types::BOOLEAN, options: ['default' => '0'])] public bool $wjpcModalDisplayed = false; + #[Immutable(Immutable::PRIVATE_WRITE_SCOPE)] + #[Column(nullable: true)] + public null|string $stripeCustomerId = null; + public function __construct( #[Id] #[Immutable] @@ -139,4 +143,9 @@ public function markWjpcModalAsDisplayed(): void { $this->wjpcModalDisplayed = true; } + + public function updateStripeCustomerId(string $stripeCustomerId): void + { + $this->stripeCustomerId = $stripeCustomerId; + } } diff --git a/src/Events/MembershipCancelled.php b/src/Events/MembershipCancelled.php new file mode 100644 index 0000000..77d982c --- /dev/null +++ b/src/Events/MembershipCancelled.php @@ -0,0 +1,15 @@ +playerRepository->get($message->playerId); + + $customer = $this->stripeClient->customers->create([ + 'email' => $player->email, + 'metadata' => [ + 'player_id' => $player->id->toString(), + ] + ]); + + $player->updateStripeCustomerId($customer->id); + } +} diff --git a/src/MessageHandler/SubscribeMembershipHandler.php b/src/MessageHandler/SubscribeMembershipHandler.php new file mode 100644 index 0000000..85ddfaa --- /dev/null +++ b/src/MessageHandler/SubscribeMembershipHandler.php @@ -0,0 +1,53 @@ +playerRepository->get($message->playerId); + + $checkoutSession = $this->stripeClient->checkout->sessions->retrieve($message->stripeSessionId); + $subscriptionId = $checkoutSession->subscription; + assert(is_string($subscriptionId)); + + $subscription = $this->stripeClient->subscriptions->retrieve($subscriptionId); + $billingPeriodEnd = DateTimeImmutable::createFromFormat('U', (string) $subscription->current_period_end); + assert($billingPeriodEnd instanceof DateTimeImmutable); + + try { + $membership = $this->membershipRepository->get($message->playerId); + $membership->renewStripeSubscription($subscriptionId, $billingPeriodEnd); + } catch (MembershipNotFound) { + $membership = new Membership( + Uuid::uuid7(), + $player, + $subscriptionId, + $billingPeriodEnd, + ); + + $this->membershipRepository->save($membership); + } + } +} diff --git a/src/Query/GetPlayerMembership.php b/src/Query/GetPlayerMembership.php new file mode 100644 index 0000000..66d0771 --- /dev/null +++ b/src/Query/GetPlayerMembership.php @@ -0,0 +1,55 @@ +database + ->executeQuery($query, [ + 'playerId' => $playerId, + ]) + ->fetchAssociative(); + + if (is_array($row) === false) { + throw new MembershipNotFound(); + } + + return PlayerMembership::fromDatabaseRow($row); + } +} diff --git a/src/Query/GetPlayerProfile.php b/src/Query/GetPlayerProfile.php index ddfd383..36cb5f7 100644 --- a/src/Query/GetPlayerProfile.php +++ b/src/Query/GetPlayerProfile.php @@ -39,6 +39,7 @@ public function byId(string $playerId): PlayerProfile bio, facebook, instagram, + stripe_customer_id, wjpc_modal_displayed FROM player WHERE player.id = :playerId @@ -58,6 +59,7 @@ public function byId(string $playerId): PlayerProfile * bio: null|string, * facebook: null|string, * instagram: null|string, + * stripe_customer_id: null|string, * wjpc_modal_displayed: bool, * } $row */ @@ -93,6 +95,7 @@ public function byUserId(string $userId): PlayerProfile bio, facebook, instagram, + stripe_customer_id, wjpc_modal_displayed FROM player WHERE player.user_id = :userId @@ -112,6 +115,7 @@ public function byUserId(string $userId): PlayerProfile * bio: null|string, * facebook: null|string, * instagram: null|string, + * stripe_customer_id: null|string, * wjpc_modal_displayed: bool, * } $row */ diff --git a/src/Repository/MembershipRepository.php b/src/Repository/MembershipRepository.php new file mode 100644 index 0000000..14ed972 --- /dev/null +++ b/src/Repository/MembershipRepository.php @@ -0,0 +1,50 @@ +entityManager->createQueryBuilder(); + + try { + $membership = $queryBuilder->select('membership') + ->from(Membership::class, 'membership') + ->where('membership.player = :playerId') + ->setParameter('playerId', $playerId) + ->getQuery() + ->getSingleResult(); + + assert($membership instanceof Membership); + return $membership; + } catch (NoResultException) { + throw new MembershipNotFound(); + } + } + + public function save(Membership $membership): void + { + $this->entityManager->persist($membership); + } +} diff --git a/src/Results/PlayerMembership.php b/src/Results/PlayerMembership.php new file mode 100644 index 0000000..8839002 --- /dev/null +++ b/src/Results/PlayerMembership.php @@ -0,0 +1,42 @@ +stripeClient->prices->all([ + 'lookup_keys' => [$priceLookupKey], + 'expand' => ['data.product'] + ]); + + $price = $prices->data[0] ?? null; + assert($price instanceof Price); + + $successUrl = $this->router->generate('stripe_checkout_success', ['sessionId' => 'CHECKOUT_SESSION_ID'], referenceType: UrlGeneratorInterface::ABSOLUTE_URL); + $successUrl = str_replace('CHECKOUT_SESSION_ID', '{CHECKOUT_SESSION_ID}', $successUrl); + $cancelUrl = $this->router->generate('stripe_checkout_cancel', referenceType: UrlGeneratorInterface::ABSOLUTE_URL); + + $userProfile = $this->retrieveLoggedUserProfile->getProfile(); + assert($userProfile !== null); + $stripeCustomerId = $userProfile->stripeCustomerId ?? $this->createCustomerId($userProfile->playerId); + + $checkoutSession = $this->stripeClient->checkout->sessions->create([ + 'customer' => $stripeCustomerId, + 'mode' => 'subscription', + 'success_url' => $successUrl, + 'cancel_url' => $cancelUrl, + 'line_items' => [[ + 'price' => $price->id, + 'quantity' => 1, + ]], + ]); + + $checkoutUrl = $checkoutSession->url; + assert($checkoutUrl !== null); + + return $checkoutUrl; + } + + public function getBillingPortalUrl(string $stripeCustomerId): string + { + $returnUrl = $this->router->generate('membership', referenceType: UrlGeneratorInterface::ABSOLUTE_URL); + + $session = $this->stripeClient->billingPortal->sessions->create([ + 'customer' => $stripeCustomerId, + 'return_url' => $returnUrl, + ]); + + return $session->url; + } + + private function createCustomerId(string $playerId): string + { + $playerProfile = $this->getPlayerProfile->byId($playerId); + + if ($playerProfile->stripeCustomerId === null) { + $this->messageBus->dispatch( + new CreatePlayerStripeCustomer($playerId), + ); + + // Refetch after creation + $playerProfile = $this->getPlayerProfile->byId($playerId); + + assert($playerProfile->stripeCustomerId !== null); + } + + return $playerProfile->stripeCustomerId; + } +} diff --git a/src/Services/RetrieveLoggedUserProfile.php b/src/Services/RetrieveLoggedUserProfile.php index 2937189..516330b 100644 --- a/src/Services/RetrieveLoggedUserProfile.php +++ b/src/Services/RetrieveLoggedUserProfile.php @@ -42,10 +42,6 @@ public function getProfile(): null|PlayerProfile try { $this->foundProfile = $this->getPlayerProfile->byUserId($userId); - - $this->messageBus->dispatch( - new HideWjpcModal($this->foundProfile->playerId) - ); } catch (PlayerNotFound) { // Case that user just came from registration -> has userId but no Player exists in db yet $this->messageBus->dispatch( @@ -58,10 +54,6 @@ public function getProfile(): null|PlayerProfile try { $this->foundProfile = $this->getPlayerProfile->byUserId($userId); - - $this->messageBus->dispatch( - new HideWjpcModal($this->foundProfile->playerId) - ); } catch (PlayerNotFound $e) { $this->logger->critical('Could not create player profile for logged in user.', [ 'user_id' => $userId, diff --git a/templates/membership-checkout-cancel.html.twig b/templates/membership-checkout-cancel.html.twig new file mode 100644 index 0000000..84467d3 --- /dev/null +++ b/templates/membership-checkout-cancel.html.twig @@ -0,0 +1,10 @@ +{% extends 'base.html.twig' %} + +{% block content %} + +{% endblock %} + +{% block title %} + +{% endblock %} + diff --git a/templates/membership-checkout-success.html.twig b/templates/membership-checkout-success.html.twig new file mode 100644 index 0000000..84467d3 --- /dev/null +++ b/templates/membership-checkout-success.html.twig @@ -0,0 +1,10 @@ +{% extends 'base.html.twig' %} + +{% block content %} + +{% endblock %} + +{% block title %} + +{% endblock %} + diff --git a/templates/membership.html.twig b/templates/membership.html.twig new file mode 100644 index 0000000..c6a204a --- /dev/null +++ b/templates/membership.html.twig @@ -0,0 +1,40 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'membership.meta.title'|trans }}{% endblock %} +{% block meta_description %}{{ 'membership.meta.description'|trans }}{% endblock %} + +{% block content %} +
{{ 'membership.intro_text'|trans }}
+ + {% if membership %} + {% if membership.billingPeriodEndsAt %} + Příští platba: {{ membership.billingPeriodEndsAt|date('d.m.Y') }} + {% endif %} + + {% if membership.endsAt %} + Platnost členství do: {{ membership.billingPeriodEndsAt|date('d.m.Y') }} + {% endif %} + + + {% else %} + Koupit členství + +Seznam věcí, které získám jako člen..
+