diff --git a/compose.yml b/compose.yml index 52bef0e..8c94408 100644 --- a/compose.yml +++ b/compose.yml @@ -22,22 +22,6 @@ services: XDEBUG_MODE: debug PHP_IDE_CONFIG: "serverName=speedpuzzling" - messenger-consumer: - image: ghcr.io/myspeedpuzzling/web-base:main - restart: unless-stopped - tty: true - command: "bash -c 'wait-for-it postgres:5432 -- sleep 5 && bin/console messenger:consume async -vv --time-limit 3600 --memory-limit 256M'" - volumes: - - .:/app - depends_on: - - postgres - - redis - - minio - environment: - XDEBUG_CONFIG: "client_host=host.docker.internal" - XDEBUG_MODE: debug - PHP_IDE_CONFIG: "serverName=speedpuzzling" - postgres: image: postgres:16.0 environment: diff --git a/composer.json b/composer.json index 88b417b..3b4cfcf 100644 --- a/composer.json +++ b/composer.json @@ -85,8 +85,8 @@ "symfony/web-link": "^7.0", "symfony/webpack-encore-bundle": "^2.0", "symfony/yaml": "^7.0", - "twig/cssinliner-extra": "^3.11", - "twig/extra-bundle": "^3.11", + "twig/cssinliner-extra": "^3.18", + "twig/extra-bundle": "^3.18", "twig/intl-extra": "^3.8", "twig/string-extra": "^3.7", "twig/twig": "^3.0" diff --git a/composer.lock b/composer.lock index 1219ebf..e629420 100755 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b1243dd74ba49135200db638e9e5ebb1", + "content-hash": "2971448661ff63726e8eb6cb8670f4dd", "packages": [ { "name": "async-aws/core", @@ -10117,7 +10117,7 @@ }, { "name": "twig/cssinliner-extra", - "version": "v3.13.0", + "version": "v3.18.0", "source": { "type": "git", "url": "https://github.com/twigphp/cssinliner-extra.git", @@ -10170,7 +10170,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.13.0" + "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.18.0" }, "funding": [ { @@ -10186,23 +10186,23 @@ }, { "name": "twig/extra-bundle", - "version": "v3.13.0", + "version": "v3.18.0", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", - "reference": "21a9a7aa9f79d4493bb6fed4eb2794339f9551f5" + "reference": "9746573ca4bc1cd03a767a183faadaf84e0c31fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/21a9a7aa9f79d4493bb6fed4eb2794339f9551f5", - "reference": "21a9a7aa9f79d4493bb6fed4eb2794339f9551f5", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/9746573ca4bc1cd03a767a183faadaf84e0c31fa", + "reference": "9746573ca4bc1cd03a767a183faadaf84e0c31fa", "shasum": "" }, "require": { "php": ">=8.0.2", "symfony/framework-bundle": "^5.4|^6.4|^7.0", "symfony/twig-bundle": "^5.4|^6.4|^7.0", - "twig/twig": "^3.0|^4.0" + "twig/twig": "^3.2|^4.0" }, "require-dev": { "league/commonmark": "^1.0|^2.0", @@ -10244,7 +10244,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.13.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.18.0" }, "funding": [ { @@ -10256,7 +10256,7 @@ "type": "tidelift" } ], - "time": "2024-09-01T20:39:12+00:00" + "time": "2024-09-26T19:22:23+00:00" }, { "name": "twig/intl-extra", @@ -13812,6 +13812,6 @@ "ext-simplexml": "*", "ext-uuid": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/packages/twig.php b/config/packages/twig.php index f604bd6..73b9286 100644 --- a/config/packages/twig.php +++ b/config/packages/twig.php @@ -22,4 +22,7 @@ $twig->global('get_notifications') ->value(service(GetNotifications::class)); + + $twig->path('%kernel.project_dir%/public/img', 'images'); + $twig->path('%kernel.project_dir%/public/css', 'styles'); }; diff --git a/public/css/email.css b/public/css/email.css new file mode 100644 index 0000000..4c2df2b --- /dev/null +++ b/public/css/email.css @@ -0,0 +1,76 @@ +body { + color: #111; + font-family: Arial, Tahoma, Sans-serif, serif; + background: #ebebeb; + font-size: 15px; +} + +h1 { + font-weight: normal; + font-size: 26px; +} + +h2 { + font-weight: normal; + font-size: 22px; +} + +h3 { + font-weight: normal; + font-size: 18px; +} + +p { + margin: 1em 0; +} + +a { + color: #f34770; + text-decoration: underline; +} + +hr { + border-bottom: 1px solid #ccc; + margin: 15px 0; + padding: 0; + height: 1px; +} + + +h1.newsletter { + color: #f34770; + font-size: 24px; + font-weight: normal; +} + +.admin-h1 { + font-size: 24px; + font-weight: normal; +} + +.logo { + width: 80px; +} + +.newsletterContainer { + width: 100%; + max-width: 600px; + margin: 15px auto; + border: 1px solid #f34770; + padding: 15px; + background: #fff; +} + +.border-bottom { + border-bottom: 1px solid #333; + padding-bottom: 15px; + margin-bottom: 15px; +} + +.text-center { + text-align: center; +} + +.text-end { + text-align: right; +} diff --git a/src/Events/SubscriptionPaymentFailed.php b/src/Events/SubscriptionPaymentFailed.php deleted file mode 100644 index 67e13c6..0000000 --- a/src/Events/SubscriptionPaymentFailed.php +++ /dev/null @@ -1,13 +0,0 @@ -stripeSubscriptionId; + $subscription = $this->stripeClient->subscriptions->retrieve($subscriptionId); + $billingPeriodEnd = DateTimeImmutable::createFromFormat('U', (string) $subscription->current_period_end); + assert($billingPeriodEnd instanceof DateTimeImmutable); + + $customerId = $subscription->customer; + assert(is_string($customerId)); + + $customer = $this->stripeClient->customers->retrieve($customerId); + $playerId = $customer->metadata->player_id ?? null; + + if (!is_string($playerId)) { + // Can not continue without playerId + return; + } + + try { + $this->membershipRepository->getByPlayerId($playerId); + $this->messageBus->dispatch( + new UpdateMembershipSubscription($subscriptionId), + ); + } catch (MembershipNotFound) { + $player = $this->playerRepository->get($playerId); + $membership = new Membership( + Uuid::uuid7(), + $player, + $this->clock->now(), + $subscriptionId, + $billingPeriodEnd, + ); + + $this->membershipRepository->save($membership); + } + } +} diff --git a/src/MessageHandler/NotifyWhenMembershipStarted.php b/src/MessageHandler/NotifyWhenMembershipStarted.php new file mode 100644 index 0000000..9768c17 --- /dev/null +++ b/src/MessageHandler/NotifyWhenMembershipStarted.php @@ -0,0 +1,61 @@ +membershipRepository->get($message->membershipId->toString()); + $player = $membership->player; + + if ($player->email === null) { + return; + } + + if ($membership->billingPeriodEndsAt === null) { + $email = (new TemplatedEmail()) + ->to($player->email) + ->locale('en') // TODO: take locale from user object + ->subject('Congratulations to your MySpeedPuzzling membership!') + ->htmlTemplate('emails/membership_granted.html.twig') + ->context([ + 'membershipExpiresAt' => $membership->endsAt?->format('d.m.Y'), + ]); + + $this->mailer->send($email); + } + + if ($membership->billingPeriodEndsAt !== null) { + $email = (new TemplatedEmail()) + ->to($player->email) + ->locale('en') // TODO: take locale from user object + ->subject('Congratulations to your MySpeedPuzzling membership!') + ->htmlTemplate('emails/membership_subscribed.html.twig') + ->context([ + 'nextBillingPeriod' => $membership->billingPeriodEndsAt->format('d.m.Y H:i'), + ]); + + $this->mailer->send($email); + } + } +} diff --git a/src/MessageHandler/NotifyWhenSubscriptionPaymentFailed.php b/src/MessageHandler/NotifyWhenSubscriptionPaymentFailed.php deleted file mode 100644 index ef034ab..0000000 --- a/src/MessageHandler/NotifyWhenSubscriptionPaymentFailed.php +++ /dev/null @@ -1,13 +0,0 @@ -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->updateStripeSubscription($subscriptionId, $billingPeriodEnd); - } catch (MembershipNotFound) { - $membership = new Membership( - Uuid::uuid7(), - $player, - $this->clock->now(), - $subscriptionId, - $billingPeriodEnd, - ); - - $this->membershipRepository->save($membership); - } + $this->messageBus->dispatch( + new CreateMembershipSubscription($subscriptionId), + ); } } diff --git a/src/MessageHandler/UpdateMembershipSubscriptionHandler.php b/src/MessageHandler/UpdateMembershipSubscriptionHandler.php new file mode 100644 index 0000000..7387d48 --- /dev/null +++ b/src/MessageHandler/UpdateMembershipSubscriptionHandler.php @@ -0,0 +1,45 @@ +stripeSubscriptionId; + + try { + $membership = $this->membershipRepository->getByStripeSubscriptionId($subscriptionId); + } catch (MembershipNotFound) { + $this->logger->warning('Attempted to update unknown membership', [ + 'subscription_id' => $subscriptionId, + ]); + + return; + } + + $subscription = $this->stripeClient->subscriptions->retrieve($subscriptionId); + $billingPeriodEnd = DateTimeImmutable::createFromFormat('U', (string) $subscription->current_period_end); + assert($billingPeriodEnd instanceof DateTimeImmutable); + + $membership->updateStripeSubscription($subscriptionId, $billingPeriodEnd); + } +} diff --git a/src/Repository/MembershipRepository.php b/src/Repository/MembershipRepository.php index a546481..5558433 100644 --- a/src/Repository/MembershipRepository.php +++ b/src/Repository/MembershipRepository.php @@ -20,7 +20,25 @@ public function __construct( /** * @throws MembershipNotFound */ - public function get(string $playerId): Membership + public function get(string $membershipId): Membership + { + if (!Uuid::isValid($membershipId)) { + throw new MembershipNotFound(); + } + + $membership = $this->entityManager->find(Membership::class, $membershipId); + + if ($membership === null) { + throw new MembershipNotFound(); + } + + return $membership; + } + + /** + * @throws MembershipNotFound + */ + public function getByPlayerId(string $playerId): Membership { if (!Uuid::isValid($playerId)) { throw new MembershipNotFound(); diff --git a/src/Services/StripeWebhookHandler.php b/src/Services/StripeWebhookHandler.php index b7d15b1..c31ae51 100644 --- a/src/Services/StripeWebhookHandler.php +++ b/src/Services/StripeWebhookHandler.php @@ -5,9 +5,11 @@ namespace SpeedPuzzling\Web\Services; use Psr\Log\LoggerInterface; -use SpeedPuzzling\Web\Events\SubscriptionPaymentFailed; +use SpeedPuzzling\Web\Message\CancelMembershipSubscription; +use SpeedPuzzling\Web\Message\CreateMembershipSubscription; +use SpeedPuzzling\Web\Message\NotifyAboutFailedPayment; +use SpeedPuzzling\Web\Message\UpdateMembershipSubscription; use Stripe\Invoice; -use Stripe\StripeClient; use Stripe\Subscription; use Stripe\Webhook; use Symfony\Component\Messenger\MessageBusInterface; @@ -18,7 +20,6 @@ public function __construct( private string $stripeWebhookSecret, private MessageBusInterface $messageBus, private LoggerInterface $logger, - private StripeClient $stripeClient, ) { } @@ -69,27 +70,26 @@ public function handleWebhook(string $body, string $signHeader): void private function handleSubscriptionCreated(Subscription $stripeSubscription): void { + $this->messageBus->dispatch(new CreateMembershipSubscription($stripeSubscription->id)); } private function handleSubscriptionDeleted(Subscription $stripeSubscription): void { + $this->messageBus->dispatch(new CancelMembershipSubscription($stripeSubscription->id)); } - private function handlePaymentSucceeded(string $subscriptionId): void + private function handlePaymentSucceeded(string $stripeSubscriptionId): void { - $subscriptionId = $invoice->subscription; - assert(is_string($subscriptionId)); - - $this->messageBus->dispatch(new SubscriptionPaymentFailed($subscriptionId)); + $this->messageBus->dispatch(new UpdateMembershipSubscription($stripeSubscriptionId)); } private function handlePaymentFailed(Invoice $invoice): void { if ($invoice->attempt_count === 1) { - $subscriptionId = $invoice->subscription; - assert(is_string($subscriptionId)); + $stripeSubscriptionId = $invoice->subscription; + assert(is_string($stripeSubscriptionId)); - $this->messageBus->dispatch(new SubscriptionPaymentFailed($subscriptionId)); + $this->messageBus->dispatch(new NotifyAboutFailedPayment($stripeSubscriptionId)); } } } diff --git a/templates/emails/membership_cancelled.html.twig b/templates/emails/membership_cancelled.html.twig new file mode 100644 index 0000000..b8024d0 --- /dev/null +++ b/templates/emails/membership_cancelled.html.twig @@ -0,0 +1,22 @@ +{% apply inline_css(source('@styles/email.css')) %} + +
+ + + + + +{% endapply %} diff --git a/templates/emails/membership_granted.html.twig b/templates/emails/membership_granted.html.twig new file mode 100644 index 0000000..556f6bf --- /dev/null +++ b/templates/emails/membership_granted.html.twig @@ -0,0 +1,22 @@ +{% apply inline_css(source('@styles/email.css')) %} + + + + + + + +{% endapply %} diff --git a/templates/emails/membership_subscribed.html.twig b/templates/emails/membership_subscribed.html.twig new file mode 100644 index 0000000..0567774 --- /dev/null +++ b/templates/emails/membership_subscribed.html.twig @@ -0,0 +1,22 @@ +{% apply inline_css(source('@styles/email.css')) %} + + + + + + + +{% endapply %} diff --git a/templates/emails/subscription_payment_failed.html.twig b/templates/emails/subscription_payment_failed.html.twig new file mode 100644 index 0000000..b54e36b --- /dev/null +++ b/templates/emails/subscription_payment_failed.html.twig @@ -0,0 +1,17 @@ +{% apply inline_css(source('@styles/email.css')) %} + + + + + + + +{% endapply %} diff --git a/templates/emails/subscription_payment_succeeded.html.twig b/templates/emails/subscription_payment_succeeded.html.twig new file mode 100644 index 0000000..3ddde41 --- /dev/null +++ b/templates/emails/subscription_payment_succeeded.html.twig @@ -0,0 +1,22 @@ +{% apply inline_css(source('@styles/email.css')) %} + + + + + + + +{% endapply %} diff --git a/templates/membership.html.twig b/templates/membership.html.twig index a68560a..2f2c5bf 100644 --- a/templates/membership.html.twig +++ b/templates/membership.html.twig @@ -6,39 +6,55 @@ {% block content %}- {{ 'membership.membership_enabled'|trans }} -
- - {% if membership.billingPeriodEndsAt %} -- {{ 'membership.next_payment'|trans }}: {{ membership.billingPeriodEndsAt|date('d.m.Y H:i') }} -
- {% endif %} - - {% if membership.endsAt %} - {{ 'membership.membership_expiration'|trans }}: {{ membership.billingPeriodEndsAt|date('d.m.Y') }} - {% endif %} - -- {{ 'membership.payment_portal_button'|trans }} -
- {% else %} -{{ 'membership.become_member'|trans }}
- - - {% endif %} ++ {{ 'membership.subscription_active'|trans }} +
+ ++ {{ 'membership.next_payment'|trans }}: {{ membership.billingPeriodEndsAt|date('d.m.Y H:i') }} +
+ {% elseif membership.endsAt %} ++ {{ 'membership.membership_cancelling'|trans }} +
+ {% elseif membership.endsAt is null %} ++ {{ 'membership.membership_inactive'|trans }} +
+ {% endif %} + + {% if membership.endsAt %} + {{ 'membership.membership_expiration'|trans }}: {{ membership.billingPeriodEndsAt|date('d.m.Y') }} + {% endif %} + ++ {{ 'membership.payment_portal_button'|trans }} +
+ ++ {{ 'membership.billing_portal_info'|trans }} +
+ {% else %} +{{ 'membership.become_member'|trans }}
+ + + {% endif %} +To brzy doplníme ... :-)
" subscribe_for: "Předplatit za" original_price_per_month: "150 Kč / měsíc" price_per_month: "100 Kč / měsíc" + billing_portal_info: "Na platebním portále můžete měnit platební své údaje, zrušit členství, stáhnout faktury nebo účtenky." membership_payment_cancelled: meta: diff --git a/translations/messages.en.yml b/translations/messages.en.yml index 8e81fa9..5e0191b 100644 --- a/translations/messages.en.yml +++ b/translations/messages.en.yml @@ -363,6 +363,7 @@ flashes: puzzle_removed_from_collection: "Good bye puzzle..." wjpc2024_duplicate_connection: "Something is wrong :-( you have chosen WJPC account, that is already connected to some MySpeedPuzzling profile. Worry not, we were automatically notified and we will check out what is wrong." wjpc2024_connection_saved: "Nice! WJPC account successfully connected to your MySpeedPuzzling profile!" + membership_subscribed_successfully: "Payment was successful, your membership is now active!" hub: title: "Speed Puzzling Hub" @@ -448,15 +449,19 @@ membership: description: "" title: "MySpeedPuzzling Membership" become_member: "Subscribe to MySpeedPuzzling Membership - support development and gain access to exclusive member-only features. Now at an introductory price that stays with you for the duration of your membership." - membership_enabled: "Thank you for being part of the community!" + subscription_active: "Active subscription, thank you for being part of the community!" + membership_active: "Active membership, thank you for being part of the community!" + membership_inactive: "Membership is not active" + membership_cancelling: "Subscription has been canceled, membership is active until the end of the period" next_payment: "Next payment" membership_expiration: "Membership valid until" payment_portal_button: "Manage subscription (payment portal)" - description_title: "Why become a member?" + description_title: "What are the benefits of membership?" full_description: "We will fill this in soon ... :-)
" subscribe_for: "Subscribe for" original_price_per_month: "6€ / month" price_per_month: "4€ / month" + billing_portal_info: "In the payment portal, you can update your payment details, cancel your membership, or download invoices or receipts." membership_payment_cancelled: meta: