From 2d52b3042f69ba9dccf7fb96bc0303d6151f3733 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sun, 25 Oct 2026 07:54:40 +0000 Subject: [PATCH 1/5] Limit the events sitemap to upcoming and recently ended events (#1446) --- .gitignore | 1 + .../Repository/Eloquent/EventRepository.php | 26 ++++-- .../Sitemap/SitemapGeneratorService.php | 45 +++-------- .../Eloquent/EventRepositoryTest.php | 68 +++++++++++++++- .../Sitemap/SitemapGeneratorServiceTest.php | 79 +++---------------- .../management/recurring-occurrences.spec.ts | 37 ++++++++- .../RecurrenceScheduleDrawer/index.tsx | 2 +- 7 files changed, 143 insertions(+), 115 deletions(-) diff --git a/.gitignore b/.gitignore index 1299f2b5a4..791177d879 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ test_results/ /release-notes.md .amp/portals/* +.cloudflare/ diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php index d2ac8882cb..e18c6b80d7 100644 --- a/backend/app/Repository/Eloquent/EventRepository.php +++ b/backend/app/Repository/Eloquent/EventRepository.php @@ -11,6 +11,7 @@ use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract; use HiEvents\DomainObjects\Generated\EventSettingDomainObjectAbstract; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Http\DTO\QueryParamsDTO; @@ -27,6 +28,8 @@ */ class EventRepository extends BaseRepository implements EventRepositoryInterface { + private const SITEMAP_ENDED_EVENT_RETENTION_DAYS = 120; + protected function getModel(): string { return Event::class; @@ -204,16 +207,12 @@ public function getAllEventsForAdmin( public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator { - return $this->handleResults($this->model + return $this->handleResults($this->sitemapEventsQuery() ->select([ 'events.'.EventDomainObjectAbstract::ID, 'events.'.EventDomainObjectAbstract::TITLE, 'events.'.EventDomainObjectAbstract::UPDATED_AT, ]) - ->join('event_settings', 'events.id', '=', 'event_settings.event_id') - ->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) - ->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) - ->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT) ->orderBy('events.'.EventDomainObjectAbstract::ID) ->paginate($perPage, ['*'], 'page', $page)); } @@ -229,6 +228,11 @@ public function findByIdLocked(int $id): EventDomainObject } public function getSitemapEventCount(): int + { + return $this->sitemapEventsQuery()->count(); + } + + private function sitemapEventsQuery(): Builder { return $this->model ->newQuery() @@ -236,7 +240,17 @@ public function getSitemapEventCount(): int ->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) ->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) ->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT) - ->count(); + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('event_occurrences') + ->whereColumn('event_occurrences.event_id', 'events.id') + ->whereNull('event_occurrences.deleted_at') + ->where('event_occurrences.status', '!=', EventOccurrenceStatus::CANCELLED->name) + ->whereRaw( + 'COALESCE(event_occurrences.end_date, event_occurrences.start_date) >= ?', + [now()->subDays(self::SITEMAP_ENDED_EVENT_RETENTION_DAYS)] + ); + }); } public function getUpcomingEventsWithCompletedOrders(int $accountId): Collection diff --git a/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php b/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php index 38f187fdaa..5feafccba6 100644 --- a/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php +++ b/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php @@ -21,16 +21,6 @@ class SitemapGeneratorService private const INDENT_STRING = ' '; - private const CHANGEFREQ_DAILY = 'daily'; - - private const CHANGEFREQ_WEEKLY = 'weekly'; - - private const PRIORITY_HIGH = '0.8'; - - private const PRIORITY_MEDIUM = '0.6'; - - private const PRIORITY_LOW = '0.5'; - private const DEFAULT_EVENT_SLUG = 'event'; private const DEFAULT_ORGANIZER_SLUG = 'organizer'; @@ -88,10 +78,8 @@ public function generateEventsSitemap(Collection $events, string $baseUrl): stri $writer->startElement('urlset'); $writer->writeAttribute('xmlns', self::SITEMAP_NAMESPACE); - $now = now(); - foreach ($events as $event) { - $this->writeEventUrl($writer, $event, $baseUrl, $now); + $this->writeEventUrl($writer, $event, $baseUrl); } $writer->endElement(); @@ -110,27 +98,12 @@ private function createXmlWriter(): XMLWriter return $writer; } - private function writeEventUrl(XMLWriter $writer, EventDomainObject $event, string $baseUrl, Carbon $now): void + private function writeEventUrl(XMLWriter $writer, EventDomainObject $event, string $baseUrl): void { $slug = Str::slug($event->getTitle()) ?: self::DEFAULT_EVENT_SLUG; $eventUrl = $baseUrl.sprintf(self::EVENT_URL_PATTERN, $event->getId(), $slug); - $isUpcoming = $this->isEventUpcoming($event, $now); - $lastMod = Carbon::parse($event->getUpdatedAt())->toAtomString(); - - $writer->startElement('url'); - $writer->writeElement('loc', $eventUrl); - $writer->writeElement('lastmod', $lastMod); - $writer->writeElement('changefreq', $isUpcoming ? self::CHANGEFREQ_DAILY : self::CHANGEFREQ_WEEKLY); - $writer->writeElement('priority', $isUpcoming ? self::PRIORITY_HIGH : self::PRIORITY_LOW); - $writer->endElement(); - } - - private function isEventUpcoming(EventDomainObject $event, Carbon $now): bool - { - $startDate = $event->getStartDate(); - - return $startDate !== null && Carbon::parse($startDate)->gte($now); + $this->writeUrlEntry($writer, $eventUrl, $event->getUpdatedAt()); } /** @@ -158,13 +131,15 @@ private function writeOrganizerUrl(XMLWriter $writer, OrganizerDomainObject $org { $slug = Str::slug($organizer->getName()) ?: self::DEFAULT_ORGANIZER_SLUG; $organizerUrl = $baseUrl.sprintf(self::ORGANIZER_URL_PATTERN, $organizer->getId(), $slug); - $lastMod = Carbon::parse($organizer->getUpdatedAt())->toAtomString(); + $this->writeUrlEntry($writer, $organizerUrl, $organizer->getUpdatedAt()); + } + + private function writeUrlEntry(XMLWriter $writer, string $loc, string $updatedAt): void + { $writer->startElement('url'); - $writer->writeElement('loc', $organizerUrl); - $writer->writeElement('lastmod', $lastMod); - $writer->writeElement('changefreq', self::CHANGEFREQ_WEEKLY); - $writer->writeElement('priority', self::PRIORITY_MEDIUM); + $writer->writeElement('loc', $loc); + $writer->writeElement('lastmod', Carbon::parse($updatedAt)->toAtomString()); $writer->endElement(); } } diff --git a/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php index ded1263efd..b6bbcf138f 100644 --- a/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php +++ b/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php @@ -194,6 +194,70 @@ public function test_unknown_sort_column_falls_back_to_created_at_desc(): void $this->assertLessThan(array_search($olderId, $ids, true), array_search($newerId, $ids, true)); } + public function test_sitemap_includes_live_indexable_events_with_upcoming_or_recently_ended_occurrences(): void + { + $countBefore = $this->app->make(EventRepository::class)->getSitemapEventCount(); + + $upcomingId = $this->createSitemapEvent(); + $this->createOccurrence($upcomingId, now()->addDay(), now()->addDay()->addHours(2)); + + $recentlyEndedId = $this->createSitemapEvent(); + $this->createOccurrence($recentlyEndedId, now()->subDays(100), null); + + $longEndedId = $this->createSitemapEvent(); + $this->createOccurrence($longEndedId, now()->subDays(130), now()->subDays(130)->addHours(2)); + + $cancelledId = $this->createSitemapEvent(); + $this->createOccurrence($cancelledId, now()->addDay(), now()->addDay()->addHours(2), status: 'CANCELLED'); + + $withoutOccurrencesId = $this->createSitemapEvent(); + + $ids = $this->sitemapEventIds(); + + $this->assertContains($upcomingId, $ids); + $this->assertContains($recentlyEndedId, $ids); + $this->assertNotContains($longEndedId, $ids); + $this->assertNotContains($cancelledId, $ids); + $this->assertNotContains($withoutOccurrencesId, $ids); + $this->assertSame($countBefore + 2, $this->app->make(EventRepository::class)->getSitemapEventCount()); + } + + public function test_sitemap_excludes_unpublished_and_non_indexable_events(): void + { + $draftId = $this->createSitemapEvent(status: 'DRAFT'); + $this->createOccurrence($draftId, now()->addDay(), now()->addDay()->addHours(2)); + + $noIndexId = $this->createSitemapEvent(allowIndexing: false); + $this->createOccurrence($noIndexId, now()->addDay(), now()->addDay()->addHours(2)); + + $ids = $this->sitemapEventIds(); + + $this->assertNotContains($draftId, $ids); + $this->assertNotContains($noIndexId, $ids); + } + + private function sitemapEventIds(): array + { + return collect($this->app->make(EventRepository::class)->getSitemapEvents(1, 10000)->items()) + ->map(fn (EventDomainObject $event) => $event->getId()) + ->all(); + } + + private function createSitemapEvent(string $status = 'LIVE', bool $allowIndexing = true): int + { + $eventId = $this->createEvent('Sitemap event '.uniqid(), status: $status); + $now = now()->toDateTimeString(); + + DB::table('event_settings')->insert([ + 'event_id' => $eventId, + 'allow_search_engine_indexing' => $allowIndexing, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return $eventId; + } + private function findEventIds(string $eventsStatus, string $sortBy = 'created_at', string $sortDirection = 'desc'): array { $params = QueryParamsDTO::fromArray([ @@ -234,7 +298,7 @@ private function createEvent(string $title, string $status = 'DRAFT', $createdAt ]); } - private function createOccurrence(int $eventId, $startDate, $endDate): void + private function createOccurrence(int $eventId, $startDate, $endDate, string $status = 'ACTIVE'): void { $now = now()->toDateTimeString(); @@ -243,7 +307,7 @@ private function createOccurrence(int $eventId, $startDate, $endDate): void 'event_id' => $eventId, 'start_date' => $startDate->toDateTimeString(), 'end_date' => $endDate?->toDateTimeString(), - 'status' => 'ACTIVE', + 'status' => $status, 'used_capacity' => 0, 'is_overridden' => false, 'created_at' => $now, diff --git a/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php b/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php index 4c2ad15d7c..638c919855 100644 --- a/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php @@ -4,7 +4,6 @@ namespace Tests\Unit\Services\Domain\Sitemap; -use Carbon\Carbon; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Services\Domain\Sitemap\SitemapGeneratorService; @@ -63,14 +62,11 @@ public function test_generate_sitemap_index_is_valid_xml(): void $this->assertTrue($result, 'Generated XML should be valid'); } - public function test_generate_events_sitemap_with_upcoming_event(): void + public function test_generate_events_sitemap_with_event(): void { - Carbon::setTestNow('2025-01-15 10:00:00'); - $event = $this->createMockEvent( id: 123, title: 'My Amazing Event', - startDate: '2025-02-01 18:00:00', updatedAt: '2025-01-10 12:00:00' ); @@ -82,33 +78,8 @@ public function test_generate_events_sitemap_with_upcoming_event(): void $this->assertStringContainsString('', $xml); $this->assertStringContainsString('', $xml); $this->assertStringContainsString('https://example.com/event/123/my-amazing-event', $xml); - $this->assertStringContainsString('daily', $xml); - $this->assertStringContainsString('0.8', $xml); - - Carbon::setTestNow(); - } - - public function test_generate_events_sitemap_with_past_event(): void - { - Carbon::setTestNow('2025-01-15 10:00:00'); - - $event = $this->createMockEvent( - id: 456, - title: 'Past Concert', - startDate: '2024-12-01 18:00:00', - updatedAt: '2024-11-15 12:00:00' - ); - - $events = new Collection([$event]); - $baseUrl = 'https://example.com'; - - $xml = $this->service->generateEventsSitemap($events, $baseUrl); - - $this->assertStringContainsString('https://example.com/event/456/past-concert', $xml); - $this->assertStringContainsString('weekly', $xml); - $this->assertStringContainsString('0.5', $xml); - - Carbon::setTestNow(); + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); } public function test_generate_events_sitemap_with_special_characters_in_title(): void @@ -116,7 +87,6 @@ public function test_generate_events_sitemap_with_special_characters_in_title(): $event = $this->createMockEvent( id: 789, title: 'Event with Special & "Quotes"', - startDate: '2025-02-01 18:00:00', updatedAt: '2025-01-10 12:00:00' ); @@ -135,7 +105,6 @@ public function test_generate_events_sitemap_with_empty_slug_falls_back_to_defau $event = $this->createMockEvent( id: 101, title: '日本語タイトル', - startDate: '2025-02-01 18:00:00', updatedAt: '2025-01-10 12:00:00' ); @@ -148,34 +117,11 @@ public function test_generate_events_sitemap_with_empty_slug_falls_back_to_defau $this->assertTrue($dom->loadXML($xml)); } - public function test_generate_events_sitemap_with_null_start_date(): void - { - Carbon::setTestNow('2025-01-15 10:00:00'); - - $event = $this->createMockEvent( - id: 202, - title: 'TBD Event', - startDate: null, - updatedAt: '2025-01-10 12:00:00' - ); - - $events = new Collection([$event]); - $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); - - $this->assertStringContainsString('weekly', $xml); - $this->assertStringContainsString('0.5', $xml); - - Carbon::setTestNow(); - } - public function test_generate_events_sitemap_includes_last_mod_from_updated_at(): void { - Carbon::setTestNow(Carbon::parse('2025-01-15 10:00:00', 'UTC')); - $event = $this->createMockEvent( id: 303, title: 'New Event', - startDate: '2025-02-01 18:00:00', updatedAt: '2025-01-12 15:30:00' ); @@ -187,8 +133,6 @@ public function test_generate_events_sitemap_includes_last_mod_from_updated_at() $dom = new \DOMDocument; $this->assertTrue($dom->loadXML($xml)); - - Carbon::setTestNow(); } public function test_generate_events_sitemap_with_empty_collection(): void @@ -206,12 +150,10 @@ public function test_generate_events_sitemap_with_empty_collection(): void public function test_generate_events_sitemap_with_multiple_events(): void { - Carbon::setTestNow('2025-01-15 10:00:00'); - $events = new Collection([ - $this->createMockEvent(1, 'Event One', '2025-02-01 18:00:00', '2025-01-10 12:00:00'), - $this->createMockEvent(2, 'Event Two', '2025-03-01 18:00:00', '2025-01-11 12:00:00'), - $this->createMockEvent(3, 'Event Three', '2024-12-01 18:00:00', '2024-11-15 12:00:00'), + $this->createMockEvent(1, 'Event One', '2025-01-10 12:00:00'), + $this->createMockEvent(2, 'Event Two', '2025-01-11 12:00:00'), + $this->createMockEvent(3, 'Event Three', '2024-11-15 12:00:00'), ]); $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); @@ -224,8 +166,6 @@ public function test_generate_events_sitemap_with_multiple_events(): void $dom->loadXML($xml); $urls = $dom->getElementsByTagName('url'); $this->assertEquals(3, $urls->length); - - Carbon::setTestNow(); } public function test_generate_organizers_sitemap_with_organizer(): void @@ -244,8 +184,8 @@ public function test_generate_organizers_sitemap_with_organizer(): void $this->assertStringContainsString('', $xml); $this->assertStringContainsString('', $xml); $this->assertStringContainsString('https://example.com/events/123/my-amazing-organizer', $xml); - $this->assertStringContainsString('weekly', $xml); - $this->assertStringContainsString('0.6', $xml); + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); } public function test_generate_organizers_sitemap_with_special_characters_in_name(): void @@ -334,12 +274,11 @@ public function test_generate_organizers_sitemap_includes_last_mod_from_updated_ $this->assertTrue($dom->loadXML($xml)); } - private function createMockEvent(int $id, string $title, ?string $startDate, ?string $updatedAt): EventDomainObject + private function createMockEvent(int $id, string $title, string $updatedAt): EventDomainObject { $event = m::mock(EventDomainObject::class); $event->shouldReceive('getId')->andReturn($id); $event->shouldReceive('getTitle')->andReturn($title); - $event->shouldReceive('getStartDate')->andReturn($startDate); $event->shouldReceive('getUpdatedAt')->andReturn($updatedAt); return $event; diff --git a/e2e/tests/management/recurring-occurrences.spec.ts b/e2e/tests/management/recurring-occurrences.spec.ts index fea1ac3404..ca1b00340c 100644 --- a/e2e/tests/management/recurring-occurrences.spec.ts +++ b/e2e/tests/management/recurring-occurrences.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../fixtures'; import { OccurrencePage } from '../../pages/occurrence.page'; -import { createRecurringLiveEvent } from '../../api/factory'; +import { createRecurringLiveEvent, tomorrowUtcDate } from '../../api/factory'; import { uniqueName } from '../../utils/unique'; const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; @@ -33,6 +33,41 @@ test.describe('recurring occurrences', () => { await expect(occurrences.occurrenceRows()).toHaveCount(4); }); + test('an organizer edits a schedule whose end date was saved through the API with a time', async ({ authedPage, api, account }) => { + const event = await api.createEvent({ + title: uniqueName('E2E Recurring API until'), + type: 'RECURRING', + organizer_id: account.organizerId, + start_date: new Date(Date.now() + 86_400_000).toISOString(), + category: 'MUSIC', + currency: 'USD', + timezone: 'UTC', + }); + const start = tomorrowUtcDate(); + const until = new Date(`${start}T00:00:00Z`); + until.setUTCDate(until.getUTCDate() + 27); + const untilDate = until.toISOString().slice(0, 10); + await api.generateOccurrences(event.id, { + frequency: 'weekly', + range: { type: 'until', start: `${start}T00:00:00`, until: `${untilDate}T23:59:00` }, + days_of_week: ['monday', 'thursday'], + times_of_day: ['07:00'], + duration_minutes: 60, + }); + + await authedPage.goto(`/manage/event/${event.id}/occurrences`); + await authedPage.getByRole('button', { name: 'Add Dates' }).click(); + await authedPage.getByRole('menuitem', { name: /Edit Schedule/i }).click(); + + const drawer = authedPage.getByRole('dialog'); + await expect(drawer.getByText('Edit schedule')).toBeVisible(); + await expect(drawer.getByText('Until a date')).toBeVisible(); + await expect(authedPage.getByText('Something went wrong')).toHaveCount(0); + const [y, m, d] = untilDate.split('-').map(Number); + const shown = new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); + await expect(drawer.locator(`input[value="${shown}"], button:has-text("${shown}")`).first()).toBeVisible(); + }); + test('an organizer cancels and reactivates an occurrence', async ({ authedPage, api, account }) => { const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); diff --git a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx index e1e8449fc0..b1852bd662 100644 --- a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx +++ b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx @@ -581,7 +581,7 @@ export const RecurrenceScheduleDrawer = ({onClose, onGenerationStarted}: Recurre time_slots: parseTimeSlotsFromRule(rule), range_start: startFromRule ? startFromRule.slice(0, 10) : fallbackStart, range_type: rule.range?.type || 'until', - range_until: rule.range?.until || '', + range_until: rule.range?.until ? rule.range.until.slice(0, 10) : '', range_count: rule.range?.count || 10, default_capacity: rule.default_capacity ?? undefined, monthly_pattern: rule.monthly_pattern || 'by_day_of_month', From 3f9eb7547bfc9f4c78667d50f22aedeef800481b Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sun, 25 Oct 2026 14:00:19 +0000 Subject: [PATCH 2/5] Add branding removal subscription for Hi.Events accounts (#1447) --- .claude/rules/backend/account-deletion.md | 1 + .claude/rules/backend/mail.md | 1 + .claude/rules/backend/openapi.md | 3 +- .../AccountSubscriptionDomainObject.php | 23 + .../Enums/SubscriptionBillingInterval.php | 19 + ...ccountSubscriptionDomainObjectAbstract.php | 146 ++++++ .../DomainObjects/OrganizerDomainObject.php | 14 + .../Status/AccountSubscriptionStatus.php | 34 ++ backend/app/Helper/Url.php | 4 + ...reateSubscriptionCheckoutSessionAction.php | 64 +++ .../CreateSubscriptionPortalSessionAction.php | 52 ++ .../GetAccountSubscriptionAction.php | 26 + .../Organizers/GetPublicOrganizerAction.php | 2 +- ...eateSubscriptionCheckoutSessionRequest.php | 17 + .../app/Mail/Attendee/AttendeeTicketMail.php | 5 + backend/app/Mail/BaseMail.php | 31 +- backend/app/Mail/Order/OrderSummary.php | 5 + .../Organizer/OrderSummaryForOrganizer.php | 5 + backend/app/Models/AccountSubscription.php | 35 ++ .../Providers/RepositoryServiceProvider.php | 3 + .../app/Providers/ScrambleServiceProvider.php | 27 +- .../AccountSubscriptionRepository.php | 25 + ...AccountSubscriptionRepositoryInterface.php | 12 + .../Account/AccountSubscriptionResource.php | 32 ++ .../Organizer/OrganizerResourcePublic.php | 4 + ...eateSubscriptionCheckoutSessionHandler.php | 165 +++++++ ...CreateSubscriptionPortalSessionHandler.php | 50 ++ .../DTO/AccountSubscriptionSummaryDTO.php | 19 + .../CreateSubscriptionCheckoutSessionDTO.php | 14 + .../GetAccountSubscriptionHandler.php | 74 +++ .../Handlers/Event/GetPublicEventHandler.php | 6 + .../Handlers/Order/GetOrderPublicHandler.php | 7 + .../Payment/Stripe/IncomingWebhookHandler.php | 10 + .../Organizer/GetPublicOrganizerHandler.php | 14 +- .../Domain/Account/AccountDeletionService.php | 8 + .../Account/AccountHardDeletionService.php | 4 + .../Account/AccountSuspensionService.php | 4 + .../Domain/Mail/DTO/EmailAccentColorDTO.php | 14 + .../Domain/Mail/DTO/MailBrandingDTO.php | 21 + .../Domain/Mail/EmailAccentColorService.php | 70 +++ .../Domain/Mail/MailBrandingService.php | 143 ++++++ .../CustomerSubscriptionUpdatedHandler.php | 28 ++ .../AccountSubscriptionAccessService.php | 44 ++ ...AccountSubscriptionCancellationService.php | 143 ++++++ .../AccountSubscriptionSyncService.php | 121 +++++ .../Subscription/DTO/SubscriptionPriceDTO.php | 16 + .../Subscription/SubscriptionPriceService.php | 141 ++++++ .../Stripe/StripeClientFactory.php | 8 + .../Stripe/StripeConfigurationService.php | 2 +- backend/config/app.php | 2 + backend/config/services.php | 1 + ...000_create_account_subscriptions_table.php | 35 ++ backend/lang/de.json | 12 +- backend/lang/el.json | 12 +- backend/lang/es.json | 12 +- backend/lang/fi.json | 12 +- backend/lang/fr.json | 12 +- backend/lang/hu.json | 12 +- backend/lang/it.json | 12 +- backend/lang/ja.json | 12 +- backend/lang/ko.json | 12 +- backend/lang/nl.json | 12 +- backend/lang/pl.json | 12 +- backend/lang/pt-br.json | 12 +- backend/lang/pt.json | 12 +- backend/lang/ru.json | 12 +- backend/lang/se.json | 12 +- backend/lang/sk.json | 12 +- backend/lang/tr.json | 12 +- backend/lang/vi.json | 12 +- backend/lang/zh-cn.json | 12 +- backend/lang/zh-hk.json | 12 +- .../views/vendor/mail/html/button.blade.php | 2 +- .../views/vendor/mail/html/header.blade.php | 4 + .../views/vendor/mail/html/layout.blade.php | 7 + .../views/vendor/mail/html/message.blade.php | 16 + .../views/vendor/mail/html/promo.blade.php | 14 + .../views/vendor/mail/html/themes/default.css | 47 ++ .../views/vendor/mail/text/header.blade.php | 5 + .../views/vendor/mail/text/message.blade.php | 12 +- .../views/vendor/mail/text/promo.blade.php | 3 + backend/routes/api.php | 6 + .../Http/Accounts/AccountSubscriptionTest.php | 311 ++++++++++++ .../Actions/Admin/AccountSuspensionTest.php | 34 ++ .../Http/ApiKeys/ApiKeyRouteExposureTest.php | 3 + .../AttendeeTicketMailOccurrenceDatesTest.php | 7 + .../GroupedAttendeeTicketMailTest.php | 2 + .../Unit/Mail/MailBrandingLayoutTest.php | 200 ++++++++ .../Unit/Mail/Order/OrderSummaryTest.php | 7 + ...SubscriptionCheckoutSessionHandlerTest.php | 270 +++++++++++ ...teSubscriptionPortalSessionHandlerTest.php | 76 +++ .../Event/GetPublicEventHandlerTest.php | 25 + .../IncomingWebhookSubscriptionEventsTest.php | 113 +++++ .../Account/AccountDeletionServiceTest.php | 36 +- .../Mail/EmailAccentColorServiceTest.php | 80 ++++ .../Domain/Mail/MailBrandingServiceTest.php | 253 ++++++++++ .../AccountSubscriptionAccessServiceTest.php | 79 ++++ ...untSubscriptionCancellationServiceTest.php | 168 +++++++ .../AccountSubscriptionSyncServiceTest.php | 178 +++++++ .../SubscriptionPriceServiceTest.php | 150 ++++++ .../Stripe/StripeConfigurationServiceTest.php | 11 + frontend/src/api/account.client.ts | 25 +- .../common/AttendeeTicket/index.tsx | 2 +- .../BrandingAnimation.module.scss | 135 ++++++ .../BrandingUpsell/BrandingAnimation.tsx | 55 +++ .../BrandingDesignerSection.tsx | 46 ++ .../BrandingUpsell/BrandingUpgradeModal.tsx | 51 ++ .../BrandingUpsell/BrandingUpsell.module.scss | 110 +++++ .../BrandingUpsell/IncludedFeatures.tsx | 24 + .../BrandingUpsell/PlanIntervalPicker.tsx | 87 ++++ .../components/common/BrandingUpsell/index.ts | 4 + .../common/PoweredByFooter/index.tsx | 10 +- .../components/common/WidgetEditor/index.tsx | 3 + .../src/components/layouts/Checkout/index.tsx | 2 +- .../layouts/EventContentWidget/index.tsx | 2 +- .../layouts/EventHomepage/index.tsx | 1 + .../layouts/HomepageFrame/index.tsx | 9 +- .../layouts/OrganizerEventsWidget/index.tsx | 2 +- .../layouts/OrganizerHomepage/index.tsx | 1 + .../layouts/ProductWidget/index.tsx | 2 +- .../layouts/SettingsLayout/index.tsx | 10 + .../routes/event/HomepageDesigner/index.tsx | 3 + .../routes/event/Lineup/RunSheetPrint.tsx | 4 +- .../TicketDesigner/TicketDesignerPrint.tsx | 8 +- .../routes/event/TicketDesigner/index.tsx | 3 + .../OrganizerHomepageDesigner/index.tsx | 3 + .../AttendeeProductAndInformation/index.tsx | 2 +- .../OrderSummaryAndProducts/index.tsx | 2 +- .../product-widget/PrintOrder/index.tsx | 3 +- .../product-widget/PrintProduct/index.tsx | 5 +- .../product-widget/SelectProducts/index.tsx | 9 +- .../settings/Billing/Billing.module.scss | 30 ++ .../routes/settings/Billing/index.tsx | 206 ++++++++ .../components/widget/WidgetFrame/index.tsx | 5 +- frontend/src/hooks/useBrandingSubscription.ts | 21 + frontend/src/hooks/useSubscriptionRedirect.ts | 43 ++ frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 443 +++++++++++++----- frontend/src/locales/el.js | 2 +- frontend/src/locales/el.po | 443 +++++++++++++----- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 443 +++++++++++++----- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 443 +++++++++++++----- frontend/src/locales/fi.js | 2 +- frontend/src/locales/fi.po | 443 +++++++++++++----- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 443 +++++++++++++----- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 443 +++++++++++++----- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 443 +++++++++++++----- frontend/src/locales/ja.js | 2 +- frontend/src/locales/ja.po | 443 +++++++++++++----- frontend/src/locales/ko.js | 2 +- frontend/src/locales/ko.po | 443 +++++++++++++----- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 443 +++++++++++++----- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 443 +++++++++++++----- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 443 +++++++++++++----- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 443 +++++++++++++----- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 443 +++++++++++++----- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 443 +++++++++++++----- frontend/src/locales/sk.js | 2 +- frontend/src/locales/sk.po | 443 +++++++++++++----- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 443 +++++++++++++----- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 443 +++++++++++++----- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 443 +++++++++++++----- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 443 +++++++++++++----- .../useCreateSubscriptionCheckoutSession.ts | 12 + .../useCreateSubscriptionPortalSession.ts | 9 + .../src/queries/useGetAccountSubscription.ts | 21 + frontend/src/router.tsx | 7 + frontend/src/types.ts | 28 ++ 183 files changed, 11670 insertions(+), 2793 deletions(-) create mode 100644 backend/app/DomainObjects/AccountSubscriptionDomainObject.php create mode 100644 backend/app/DomainObjects/Enums/SubscriptionBillingInterval.php create mode 100644 backend/app/DomainObjects/Generated/AccountSubscriptionDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/Status/AccountSubscriptionStatus.php create mode 100644 backend/app/Http/Actions/Accounts/Subscription/CreateSubscriptionCheckoutSessionAction.php create mode 100644 backend/app/Http/Actions/Accounts/Subscription/CreateSubscriptionPortalSessionAction.php create mode 100644 backend/app/Http/Actions/Accounts/Subscription/GetAccountSubscriptionAction.php create mode 100644 backend/app/Http/Request/Account/CreateSubscriptionCheckoutSessionRequest.php create mode 100644 backend/app/Models/AccountSubscription.php create mode 100644 backend/app/Repository/Eloquent/AccountSubscriptionRepository.php create mode 100644 backend/app/Repository/Interfaces/AccountSubscriptionRepositoryInterface.php create mode 100644 backend/app/Resources/Account/AccountSubscriptionResource.php create mode 100644 backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandler.php create mode 100644 backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandler.php create mode 100644 backend/app/Services/Application/Handlers/Account/Subscription/DTO/AccountSubscriptionSummaryDTO.php create mode 100644 backend/app/Services/Application/Handlers/Account/Subscription/DTO/CreateSubscriptionCheckoutSessionDTO.php create mode 100644 backend/app/Services/Application/Handlers/Account/Subscription/GetAccountSubscriptionHandler.php create mode 100644 backend/app/Services/Domain/Mail/DTO/EmailAccentColorDTO.php create mode 100644 backend/app/Services/Domain/Mail/DTO/MailBrandingDTO.php create mode 100644 backend/app/Services/Domain/Mail/EmailAccentColorService.php create mode 100644 backend/app/Services/Domain/Mail/MailBrandingService.php create mode 100644 backend/app/Services/Domain/Payment/Stripe/EventHandlers/CustomerSubscriptionUpdatedHandler.php create mode 100644 backend/app/Services/Domain/Subscription/AccountSubscriptionAccessService.php create mode 100644 backend/app/Services/Domain/Subscription/AccountSubscriptionCancellationService.php create mode 100644 backend/app/Services/Domain/Subscription/AccountSubscriptionSyncService.php create mode 100644 backend/app/Services/Domain/Subscription/DTO/SubscriptionPriceDTO.php create mode 100644 backend/app/Services/Domain/Subscription/SubscriptionPriceService.php create mode 100644 backend/database/migrations/2026_10_22_100000_create_account_subscriptions_table.php create mode 100644 backend/resources/views/vendor/mail/html/promo.blade.php create mode 100644 backend/resources/views/vendor/mail/text/promo.blade.php create mode 100644 backend/tests/Feature/Http/Accounts/AccountSubscriptionTest.php create mode 100644 backend/tests/Unit/Mail/MailBrandingLayoutTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookSubscriptionEventsTest.php create mode 100644 backend/tests/Unit/Services/Domain/Mail/EmailAccentColorServiceTest.php create mode 100644 backend/tests/Unit/Services/Domain/Mail/MailBrandingServiceTest.php create mode 100644 backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionAccessServiceTest.php create mode 100644 backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionCancellationServiceTest.php create mode 100644 backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionSyncServiceTest.php create mode 100644 backend/tests/Unit/Services/Domain/Subscription/SubscriptionPriceServiceTest.php create mode 100644 frontend/src/components/common/BrandingUpsell/BrandingAnimation.module.scss create mode 100644 frontend/src/components/common/BrandingUpsell/BrandingAnimation.tsx create mode 100644 frontend/src/components/common/BrandingUpsell/BrandingDesignerSection.tsx create mode 100644 frontend/src/components/common/BrandingUpsell/BrandingUpgradeModal.tsx create mode 100644 frontend/src/components/common/BrandingUpsell/BrandingUpsell.module.scss create mode 100644 frontend/src/components/common/BrandingUpsell/IncludedFeatures.tsx create mode 100644 frontend/src/components/common/BrandingUpsell/PlanIntervalPicker.tsx create mode 100644 frontend/src/components/common/BrandingUpsell/index.ts create mode 100644 frontend/src/components/routes/settings/Billing/Billing.module.scss create mode 100644 frontend/src/components/routes/settings/Billing/index.tsx create mode 100644 frontend/src/hooks/useBrandingSubscription.ts create mode 100644 frontend/src/hooks/useSubscriptionRedirect.ts create mode 100644 frontend/src/mutations/useCreateSubscriptionCheckoutSession.ts create mode 100644 frontend/src/mutations/useCreateSubscriptionPortalSession.ts create mode 100644 frontend/src/queries/useGetAccountSubscription.ts diff --git a/.claude/rules/backend/account-deletion.md b/.claude/rules/backend/account-deletion.md index 7f017080af..dc33729d20 100644 --- a/.claude/rules/backend/account-deletion.md +++ b/.claude/rules/backend/account-deletion.md @@ -10,6 +10,7 @@ paths: - Hard delete can't rely on cascades (many foreign keys to events, orders and organizers are NO ACTION). A new account-owned table needs an anonymizer in `Services/Domain/Account/Anonymization/` and a place in the `AccountHardDeletionService` delete order. - `account_deletion_requests` has no foreign keys on purpose, so rows survive the hard delete. +- `account_subscriptions` holds no personal data and has no anonymizer on purpose: anonymization keeps the row (and the Stripe customer, for invoices); hard delete removes it. Deletion cancels the Stripe subscriptions first (`AccountSubscriptionCancellationService`). - Never delete `organizer_configurations` or `account_configuration` rows (shared plan rows) or `ticket_lookup_tokens` (buyer-email scoped across accounts). `stripe_customers` has no account foreign key; match it by Stripe account id. - Users can belong to several accounts: a sole-account user is scrubbed and soft-deleted, and their `user_identities` rows are deleted so they can't sign back in with single sign-on; a shared user only loses the `account_users` row. - An account's SSO connection, its domains and the `user_identities` tagged with the connection are deleted before `accounts` (`SsoConnectionAnonymizer` mirrors this). diff --git a/.claude/rules/backend/mail.md b/.claude/rules/backend/mail.md index 9db299ae66..76a05a8038 100644 --- a/.claude/rules/backend/mail.md +++ b/.claude/rules/backend/mail.md @@ -17,3 +17,4 @@ paths: - Link `email_deliveries`, `outgoing_messages` and `account_lifecycle_nudges` through `provider_message_id`, never with foreign keys. - Don't add Symfony `TagHeader`s for tracking: Postmark rejects more than one tag per email. - Pick a delivery webhook receiver by the `{provider}` route segment (`EmailDeliveryWebhookReceiverResolver`), never by sniffing the payload. The webhook route is unthrottled on purpose: unauthenticated calls never touch the DB, and throttling Postmark's few IPs during a broadcast would drop events. +- Event-scoped mails (`ContainsEventContent`) for Hi.Events Pro accounts go out under the organizer's name and event accent colour (`MailBrandingService`); only the display name changes, the address stays `MAIL_FROM_ADDRESS`. A new event mail sent to the organizer or team, not buyers, must override `isSentToBuyers()` to return false (see `OrderSummaryForOrganizer`). diff --git a/.claude/rules/backend/openapi.md b/.claude/rules/backend/openapi.md index 015bb377c0..0b753f7b4a 100644 --- a/.claude/rules/backend/openapi.md +++ b/.claude/rules/backend/openapi.md @@ -17,4 +17,5 @@ paths: - Adding a `DomainEventType` case means updating the webhook payload table in `config/scramble.php`. - `backend/resources/api-reference/openapi.json` is the generated in-app API reference (Settings → API keys → API reference): Scramble output minus session-only operations and `/auth/*`, without `servers` and `info.version` (added when served). Regenerate it with `php artisan api-reference:export`; `IntegrationApiReferenceTest` fails when it drifts. Never edit it by hand. - The docs intro in `config/scramble.php` promises that endpoints API keys can call stay backward compatible (see "Public API compatibility" in AGENTS.md) and lists the error codes. Update its error table when adding an `error_code`. Session-only operations are marked "Not available to API keys" automatically by `ScrambleServiceProvider`. -- Admin routes and the licensing endpoints (`Enterprise\Licensing` actions) are excluded from all generated docs in `ScrambleServiceProvider`. +- Admin routes, the licensing endpoints (`Enterprise\Licensing` actions) and the Hi.Events-only subscription endpoints (`Accounts\Subscription` actions) are excluded from all generated docs via `UNDOCUMENTED_ACTION_NAMESPACES` in `ScrambleServiceProvider`. +- Hi.Events-only response fields (e.g. `branding_removed` on public organizers) stay out of the generated docs: list them in `UNDOCUMENTED_PROPERTIES` in `ScrambleServiceProvider`. diff --git a/backend/app/DomainObjects/AccountSubscriptionDomainObject.php b/backend/app/DomainObjects/AccountSubscriptionDomainObject.php new file mode 100644 index 0000000000..aba280e188 --- /dev/null +++ b/backend/app/DomainObjects/AccountSubscriptionDomainObject.php @@ -0,0 +1,23 @@ +status()?->grantsAccess() ?? false; + } + + public function hasOpenSubscription(): bool + { + return $this->status()?->isOpen() ?? false; + } + + private function status(): ?AccountSubscriptionStatus + { + return AccountSubscriptionStatus::tryFrom((string) $this->getStatus()); + } +} diff --git a/backend/app/DomainObjects/Enums/SubscriptionBillingInterval.php b/backend/app/DomainObjects/Enums/SubscriptionBillingInterval.php new file mode 100644 index 0000000000..1936b46fef --- /dev/null +++ b/backend/app/DomainObjects/Enums/SubscriptionBillingInterval.php @@ -0,0 +1,19 @@ + 'hi_events_pro_monthly', + self::YEAR => 'hi_events_pro_yearly', + }; + } +} diff --git a/backend/app/DomainObjects/Generated/AccountSubscriptionDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountSubscriptionDomainObjectAbstract.php new file mode 100644 index 0000000000..7b364ba20b --- /dev/null +++ b/backend/app/DomainObjects/Generated/AccountSubscriptionDomainObjectAbstract.php @@ -0,0 +1,146 @@ + $this->id ?? null, + 'account_id' => $this->account_id ?? null, + 'stripe_customer_id' => $this->stripe_customer_id ?? null, + 'stripe_subscription_id' => $this->stripe_subscription_id ?? null, + 'status' => $this->status ?? null, + 'current_period_end' => $this->current_period_end ?? null, + 'cancel_at_period_end' => $this->cancel_at_period_end ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setStripeCustomerId(string $stripe_customer_id): self + { + $this->stripe_customer_id = $stripe_customer_id; + return $this; + } + + public function getStripeCustomerId(): string + { + return $this->stripe_customer_id; + } + + public function setStripeSubscriptionId(?string $stripe_subscription_id): self + { + $this->stripe_subscription_id = $stripe_subscription_id; + return $this; + } + + public function getStripeSubscriptionId(): ?string + { + return $this->stripe_subscription_id; + } + + public function setStatus(?string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setCurrentPeriodEnd(?string $current_period_end): self + { + $this->current_period_end = $current_period_end; + return $this; + } + + public function getCurrentPeriodEnd(): ?string + { + return $this->current_period_end; + } + + public function setCancelAtPeriodEnd(bool $cancel_at_period_end): self + { + $this->cancel_at_period_end = $cancel_at_period_end; + return $this; + } + + public function getCancelAtPeriodEnd(): bool + { + return $this->cancel_at_period_end; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/OrganizerDomainObject.php b/backend/app/DomainObjects/OrganizerDomainObject.php index 1e10111588..7ad01eaefd 100644 --- a/backend/app/DomainObjects/OrganizerDomainObject.php +++ b/backend/app/DomainObjects/OrganizerDomainObject.php @@ -26,6 +26,20 @@ class OrganizerDomainObject extends Generated\OrganizerDomainObjectAbstract private ?LocationDomainObject $locationRecord = null; + private ?bool $brandingRemoved = null; + + public function getBrandingRemoved(): ?bool + { + return $this->brandingRemoved; + } + + public function setBrandingRemoved(?bool $brandingRemoved): self + { + $this->brandingRemoved = $brandingRemoved; + + return $this; + } + public function getImages(): ?Collection { return $this->images; diff --git a/backend/app/DomainObjects/Status/AccountSubscriptionStatus.php b/backend/app/DomainObjects/Status/AccountSubscriptionStatus.php new file mode 100644 index 0000000000..51bce619e5 --- /dev/null +++ b/backend/app/DomainObjects/Status/AccountSubscriptionStatus.php @@ -0,0 +1,34 @@ +isActionAuthorized($accountId, AccountDomainObject::class); + + if ($this->isImpersonating()) { + return $this->impersonationForbiddenResponse(__('You cannot start a subscription while impersonating a user.')); + } + + try { + $url = $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO( + accountId: $accountId, + interval: SubscriptionBillingInterval::from($request->validated('interval')), + )); + } catch (ResourceConflictException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_CONFLICT, + ); + } catch (StripeClientConfigurationException|ApiErrorException $exception) { + $this->logger->error('Failed to create a subscription checkout session', [ + 'account_id' => $accountId, + 'error' => $exception->getMessage(), + ]); + + return $this->errorResponse( + message: __('We couldn\'t open the payment page. Please try again later.'), + statusCode: Response::HTTP_SERVICE_UNAVAILABLE, + ); + } + + return $this->jsonResponse(['url' => $url]); + } +} diff --git a/backend/app/Http/Actions/Accounts/Subscription/CreateSubscriptionPortalSessionAction.php b/backend/app/Http/Actions/Accounts/Subscription/CreateSubscriptionPortalSessionAction.php new file mode 100644 index 0000000000..ff6477451e --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Subscription/CreateSubscriptionPortalSessionAction.php @@ -0,0 +1,52 @@ +isActionAuthorized($accountId, AccountDomainObject::class); + + if ($this->isImpersonating()) { + return $this->impersonationForbiddenResponse(__('You cannot manage a subscription while impersonating a user.')); + } + + try { + $url = $this->handler->handle($accountId); + } catch (StripeClientConfigurationException|ApiErrorException $exception) { + $this->logger->error('Failed to create a subscription portal session', [ + 'account_id' => $accountId, + 'error' => $exception->getMessage(), + ]); + + return $this->errorResponse( + message: __('We couldn\'t open the billing portal. Please try again later.'), + statusCode: Response::HTTP_SERVICE_UNAVAILABLE, + ); + } + + return $this->jsonResponse(['url' => $url]); + } +} diff --git a/backend/app/Http/Actions/Accounts/Subscription/GetAccountSubscriptionAction.php b/backend/app/Http/Actions/Accounts/Subscription/GetAccountSubscriptionAction.php new file mode 100644 index 0000000000..ffd2fb9952 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Subscription/GetAccountSubscriptionAction.php @@ -0,0 +1,26 @@ +isAccountReadAuthorized($accountId); + + return $this->resourceResponse( + AccountSubscriptionResource::class, + $this->handler->handle($accountId, $request->boolean('refresh')), + ); + } +} diff --git a/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php index eecf707d60..dfec962ac0 100644 --- a/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php +++ b/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php @@ -34,7 +34,7 @@ public function __invoke(int $organizerId): Response|JsonResponse return $this->resourceResponse( resource: OrganizerResourcePublic::class, - data: $this->handler->handle($organizerId), + data: $organizer, ); } diff --git a/backend/app/Http/Request/Account/CreateSubscriptionCheckoutSessionRequest.php b/backend/app/Http/Request/Account/CreateSubscriptionCheckoutSessionRequest.php new file mode 100644 index 0000000000..dda71854ab --- /dev/null +++ b/backend/app/Http/Request/Account/CreateSubscriptionCheckoutSessionRequest.php @@ -0,0 +1,17 @@ + ['required', Rule::enum(SubscriptionBillingInterval::class)], + ]; + } +} diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index c8a80a96f6..93cc8ed7e8 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -55,6 +55,11 @@ public function __construct( $this->lineup = $lineup; } + public function showsHiEventsPromo(): bool + { + return true; + } + public function envelope(): Envelope { $replacements = ['event' => Str::limit($this->event->getTitle(), 50)]; diff --git a/backend/app/Mail/BaseMail.php b/backend/app/Mail/BaseMail.php index fcbb42a73c..d88de749f5 100644 --- a/backend/app/Mail/BaseMail.php +++ b/backend/app/Mail/BaseMail.php @@ -3,6 +3,7 @@ namespace HiEvents\Mail; use HiEvents\Mail\Concerns\ContainsEventContent; +use HiEvents\Services\Domain\Mail\MailBrandingService; use HiEvents\Services\Domain\Mail\UnpublishedEventMailPolicy; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -25,6 +26,16 @@ abstract public function envelope(): Envelope; abstract public function content(): Content; + public function showsHiEventsPromo(): bool + { + return false; + } + + public function isSentToBuyers(): bool + { + return true; + } + public function send($mailer): ?SentMessage { if ($this instanceof ContainsEventContent @@ -45,7 +56,25 @@ public function send($mailer): ?SentMessage return $replacement->send($mailer); } - return parent::send($mailer); + return $this->withMailBranding(fn () => parent::send($mailer)); + } + + private function withMailBranding(callable $callback): mixed + { + $branding = app(MailBrandingService::class)->forMail($this); + + if ($branding->senderName !== null && $this->from === []) { + $this->from(config('mail.from.address'), $branding->senderName); + } + + $view = app('view'); + $view->share('mailBranding', $branding); + + try { + return $callback(); + } finally { + $view->share('mailBranding', null); + } } private function asRecipients(array $addresses): array diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php index b7f1390c05..29de0704b4 100644 --- a/backend/app/Mail/Order/OrderSummary.php +++ b/backend/app/Mail/Order/OrderSummary.php @@ -44,6 +44,11 @@ public function __construct( parent::__construct(); } + public function showsHiEventsPromo(): bool + { + return true; + } + public function envelope(): Envelope { $subject = $this->renderedTemplate?->subject ?? __('Your Order is Confirmed!').' 🎉'; diff --git a/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php b/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php index f2c606ff72..64be144e32 100644 --- a/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php +++ b/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php @@ -32,6 +32,11 @@ public function __construct(OrderDomainObject $order, EventDomainObject $event) $this->event = $event; } + public function isSentToBuyers(): bool + { + return false; + } + public function envelope(): Envelope { $subject = $this->order->getTotalGross() > 0 diff --git a/backend/app/Models/AccountSubscription.php b/backend/app/Models/AccountSubscription.php new file mode 100644 index 0000000000..3573f081c8 --- /dev/null +++ b/backend/app/Models/AccountSubscription.php @@ -0,0 +1,35 @@ + 'datetime', + 'cancel_at_period_end' => 'boolean', + ]; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 37e0b4199a..3e04537bf8 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -27,6 +27,7 @@ use HiEvents\Repository\Eloquent\AccountLifecycleNudgeRepository; use HiEvents\Repository\Eloquent\AccountMessagingTierRepository; use HiEvents\Repository\Eloquent\AccountRepository; +use HiEvents\Repository\Eloquent\AccountSubscriptionRepository; use HiEvents\Repository\Eloquent\AccountUserAccessGrantRepository; use HiEvents\Repository\Eloquent\AccountUserPermissionRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; @@ -103,6 +104,7 @@ use HiEvents\Repository\Interfaces\AccountLifecycleNudgeRepositoryInterface; use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; +use HiEvents\Repository\Interfaces\AccountSubscriptionRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserAccessGrantRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserPermissionRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; @@ -246,6 +248,7 @@ class RepositoryServiceProvider extends ServiceProvider OrganizerConfigurationRepositoryInterface::class => OrganizerConfigurationRepository::class, TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, + AccountSubscriptionRepositoryInterface::class => AccountSubscriptionRepository::class, AnnouncementRepositoryInterface::class => AnnouncementRepository::class, AnnouncementUserRepositoryInterface::class => AnnouncementUserRepository::class, WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, diff --git a/backend/app/Providers/ScrambleServiceProvider.php b/backend/app/Providers/ScrambleServiceProvider.php index a15c7f2503..734b834165 100644 --- a/backend/app/Providers/ScrambleServiceProvider.php +++ b/backend/app/Providers/ScrambleServiceProvider.php @@ -10,6 +10,7 @@ use Dedoc\Scramble\Support\Generator\Operation; use Dedoc\Scramble\Support\Generator\Path; use Dedoc\Scramble\Support\Generator\SecurityScheme; +use Dedoc\Scramble\Support\Generator\Types\ObjectType; use Dedoc\Scramble\Support\RouteInfo; use HiEvents\OpenApi\IntegrationApiReference; use Illuminate\Contracts\Auth\Authenticatable; @@ -21,10 +22,17 @@ class ScrambleServiceProvider extends ServiceProvider { - private const LICENSING_NAMESPACE = 'HiEvents\\Enterprise\\Licensing\\'; + private const UNDOCUMENTED_ACTION_NAMESPACES = [ + 'HiEvents\\Enterprise\\Licensing\\', + 'HiEvents\\Http\\Actions\\Accounts\\Subscription\\', + ]; private const ACTION_NAMESPACE_PATTERN = '/^HiEvents\\\\(Enterprise\\\\(\w+)\\\\)?Http\\\\Actions\\\\/'; + private const UNDOCUMENTED_PROPERTIES = [ + 'OrganizerResourcePublic' => ['branding_removed'], + ]; + public function register(): void { $this->app->bind( @@ -48,7 +56,7 @@ public function boot(): void return false; } - if (str_starts_with($route->getActionName(), self::LICENSING_NAMESPACE)) { + if (Str::startsWith($route->getActionName(), self::UNDOCUMENTED_ACTION_NAMESPACES)) { return false; } @@ -89,9 +97,24 @@ public function boot(): void ->filter(static fn (Collection $operations): bool => $operations->count() > 1) ->flatten() ->each(static fn (Operation $operation) => $operation->setOperationId(self::routeQualifiedOperationId($operation))); + + self::removeUndocumentedProperties($openApi); }); } + private static function removeUndocumentedProperties(OpenApi $openApi): void + { + foreach (self::UNDOCUMENTED_PROPERTIES as $schemaName => $properties) { + $type = $openApi->components->schemas[$schemaName]->type ?? null; + if (! $type instanceof ObjectType) { + continue; + } + + $type->properties = array_diff_key($type->properties, array_flip($properties)); + $type->required = array_values(array_diff($type->required, $properties)); + } + } + private static function routeQualifiedOperationId(Operation $operation): string { return $operation->getAttribute('operationId')->getFallbackEloquent().'.'.Str::camel($operation->method.' '.preg_replace('/[^A-Za-z0-9]+/', ' ', $operation->path)); diff --git a/backend/app/Repository/Eloquent/AccountSubscriptionRepository.php b/backend/app/Repository/Eloquent/AccountSubscriptionRepository.php new file mode 100644 index 0000000000..4bb6b49da6 --- /dev/null +++ b/backend/app/Repository/Eloquent/AccountSubscriptionRepository.php @@ -0,0 +1,25 @@ + + */ +class AccountSubscriptionRepository extends BaseRepository implements AccountSubscriptionRepositoryInterface +{ + protected function getModel(): string + { + return AccountSubscription::class; + } + + public function getDomainObject(): string + { + return AccountSubscriptionDomainObject::class; + } +} diff --git a/backend/app/Repository/Interfaces/AccountSubscriptionRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountSubscriptionRepositoryInterface.php new file mode 100644 index 0000000000..0b8f8a56e3 --- /dev/null +++ b/backend/app/Repository/Interfaces/AccountSubscriptionRepositoryInterface.php @@ -0,0 +1,12 @@ + + */ +interface AccountSubscriptionRepositoryInterface extends RepositoryInterface {} diff --git a/backend/app/Resources/Account/AccountSubscriptionResource.php b/backend/app/Resources/Account/AccountSubscriptionResource.php new file mode 100644 index 0000000000..93b740ccb1 --- /dev/null +++ b/backend/app/Resources/Account/AccountSubscriptionResource.php @@ -0,0 +1,32 @@ + $this->available, + 'active' => $this->active, + /** @var 'INCOMPLETE'|'INCOMPLETE_EXPIRED'|'TRIALING'|'ACTIVE'|'PAST_DUE'|'CANCELED'|'UNPAID'|'PAUSED'|null */ + 'status' => $this->status, + 'current_period_end' => $this->currentPeriodEnd, + 'cancel_at_period_end' => $this->cancelAtPeriodEnd, + 'prices' => array_map(fn (SubscriptionPriceDTO $price) => [ + /** @var 'month'|'year' */ + 'interval' => $price->interval->value, + 'unit_amount' => $price->unitAmount, + 'currency' => $price->currency, + ], $this->prices), + ]; + } +} diff --git a/backend/app/Resources/Organizer/OrganizerResourcePublic.php b/backend/app/Resources/Organizer/OrganizerResourcePublic.php index 7b22f23fee..bd6a1b5f88 100644 --- a/backend/app/Resources/Organizer/OrganizerResourcePublic.php +++ b/backend/app/Resources/Organizer/OrganizerResourcePublic.php @@ -22,6 +22,10 @@ public function toArray($request): array 'description' => $this->getDescription(), 'slug' => $this->getSlug(), 'status' => $this->getStatus(), + 'branding_removed' => $this->when( + $this->getBrandingRemoved() !== null, + fn () => (bool) $this->getBrandingRemoved(), + ), 'location' => $this->when( condition: $this->getLocationRecord() !== null, value: fn () => new LocationPublicResource($this->getLocationRecord()), diff --git a/backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandler.php b/backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandler.php new file mode 100644 index 0000000000..f14171394f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandler.php @@ -0,0 +1,165 @@ +accountSubscriptionAccessService->assertSubscriptionsAvailable(); + + if ($this->accountDeletionService->isAccountPendingDeletion($dto->accountId)) { + throw new ResourceConflictException(__('This account is scheduled for deletion.')); + } + + if ($this->refreshExistingSubscription($dto->accountId)?->hasOpenSubscription()) { + throw new ResourceConflictException(__('This account already has a subscription. Use Manage billing to change it.')); + } + + /** @var AccountDomainObject $account */ + $account = $this->accountRepository->findById($dto->accountId); + $stripeClient = $this->stripeClientFactory->createForBilling(); + $customerId = $this->getOrCreateStripeCustomerId($account, $stripeClient); + $price = $this->findPrice($dto, $this->checkoutCurrency($account, $customerId, $stripeClient)); + + $session = $stripeClient->checkout->sessions->create([ + 'mode' => 'subscription', + 'customer' => $customerId, + 'client_reference_id' => (string) $dto->accountId, + 'currency' => strtolower($price->currency), + 'line_items' => [[ + 'price' => $price->priceId, + 'quantity' => 1, + ]], + 'allow_promotion_codes' => true, + 'automatic_tax' => ['enabled' => true], + 'tax_id_collection' => ['enabled' => true], + 'customer_update' => ['address' => 'auto', 'name' => 'auto'], + 'success_url' => Url::getFrontEndUrlFromConfig(Url::ACCOUNT_BILLING, ['checkout' => 'success']), + 'cancel_url' => Url::getFrontEndUrlFromConfig(Url::ACCOUNT_BILLING), + ]); + + return $session->url; + } + + /** + * @throws ApiErrorException + * @throws StripeClientConfigurationException + */ + private function refreshExistingSubscription(int $accountId): ?AccountSubscriptionDomainObject + { + try { + return $this->accountSubscriptionSyncService->refreshForAccount($accountId); + } catch (InvalidRequestException $exception) { + if ($exception->getStripeCode() !== 'resource_missing') { + throw $exception; + } + + $this->accountSubscriptionRepository->deleteWhere([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + + return null; + } + } + + /** + * @throws StripeClientConfigurationException + */ + private function findPrice(CreateSubscriptionCheckoutSessionDTO $dto, string $currency): SubscriptionPriceDTO + { + $price = collect($this->subscriptionPriceService->getPrices($currency)) + ->first(fn (SubscriptionPriceDTO $price) => $price->interval === $dto->interval); + + if ($price === null) { + throw new StripeClientConfigurationException( + 'No active Stripe price has the lookup key '.$dto->interval->priceLookupKey() + ); + } + + return $price; + } + + /** + * @throws ApiErrorException + */ + private function checkoutCurrency(AccountDomainObject $account, string $customerId, StripeClient $stripeClient): string + { + $customerCurrency = $stripeClient->customers->retrieve($customerId)->currency; + + return strtoupper($customerCurrency ?: $account->getCurrencyCode()); + } + + /** + * @throws ApiErrorException + */ + private function getOrCreateStripeCustomerId(AccountDomainObject $account, StripeClient $stripeClient): string + { + $existing = $this->findAccountSubscription($account->getId()); + if ($existing !== null) { + return $existing->getStripeCustomerId(); + } + + $customer = $stripeClient->customers->create([ + 'email' => $account->getEmail(), + 'name' => $account->getName(), + 'metadata' => ['account_id' => (string) $account->getId()], + ], ['idempotency_key' => 'account-subscription-customer-'.$account->getId()]); + + try { + $this->accountSubscriptionRepository->create([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $account->getId(), + AccountSubscriptionDomainObjectAbstract::STRIPE_CUSTOMER_ID => $customer->id, + ]); + } catch (UniqueConstraintViolationException) { + return $this->findAccountSubscription($account->getId())?->getStripeCustomerId() ?? $customer->id; + } + + return $customer->id; + } + + private function findAccountSubscription(int $accountId): ?AccountSubscriptionDomainObject + { + /** @var AccountSubscriptionDomainObject|null */ + return $this->accountSubscriptionRepository->findFirstWhere([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandler.php b/backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandler.php new file mode 100644 index 0000000000..04179e02aa --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandler.php @@ -0,0 +1,50 @@ +accountSubscriptionAccessService->assertSubscriptionsAvailable(); + + /** @var AccountSubscriptionDomainObject|null $accountSubscription */ + $accountSubscription = $this->accountSubscriptionRepository->findFirstWhere([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + + if ($accountSubscription?->getStripeSubscriptionId() === null) { + throw new ResourceNotFoundException(__('This account has no subscription to manage.')); + } + + $session = $this->stripeClientFactory->createForBilling()->billingPortal->sessions->create([ + 'customer' => $accountSubscription->getStripeCustomerId(), + 'return_url' => Url::getFrontEndUrlFromConfig(Url::ACCOUNT_BILLING), + ]); + + return $session->url; + } +} diff --git a/backend/app/Services/Application/Handlers/Account/Subscription/DTO/AccountSubscriptionSummaryDTO.php b/backend/app/Services/Application/Handlers/Account/Subscription/DTO/AccountSubscriptionSummaryDTO.php new file mode 100644 index 0000000000..2f6a7afd0c --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/Subscription/DTO/AccountSubscriptionSummaryDTO.php @@ -0,0 +1,19 @@ +accountSubscriptionAccessService->areSubscriptionsAvailable()) { + return new AccountSubscriptionSummaryDTO(available: false, active: false); + } + + $accountSubscription = $refreshFromStripe + ? $this->refreshFromStripe($accountId) + : $this->findAccountSubscription($accountId); + + /** @var AccountDomainObject $account */ + $account = $this->accountRepository->findById($accountId); + $prices = $this->subscriptionPriceService->getPrices($account->getCurrencyCode()); + + return new AccountSubscriptionSummaryDTO( + available: $prices !== [] || $accountSubscription?->getStatus() !== null, + active: $accountSubscription?->grantsAccess() ?? false, + status: $accountSubscription?->getStatus(), + currentPeriodEnd: $accountSubscription?->getCurrentPeriodEnd(), + cancelAtPeriodEnd: $accountSubscription?->getCancelAtPeriodEnd() ?? false, + prices: $prices, + ); + } + + private function refreshFromStripe(int $accountId): ?AccountSubscriptionDomainObject + { + try { + return $this->accountSubscriptionSyncService->refreshForAccount($accountId); + } catch (ApiErrorException|StripeClientConfigurationException $exception) { + $this->logger->warning('Failed to refresh the account subscription from Stripe', [ + 'account_id' => $accountId, + 'error' => $exception->getMessage(), + ]); + + return $this->findAccountSubscription($accountId); + } + } + + private function findAccountSubscription(int $accountId): ?AccountSubscriptionDomainObject + { + /** @var AccountSubscriptionDomainObject|null */ + return $this->accountSubscriptionRepository->findFirstWhere([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php index 3d81978ad9..c45f9299ac 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php @@ -32,6 +32,7 @@ use HiEvents\Services\Domain\EventOccurrence\PublicOccurrenceVisibilityService; use HiEvents\Services\Domain\Product\ProductFilterService; use HiEvents\Services\Domain\Product\PublicProductSelectionFilter; +use HiEvents\Services\Domain\Subscription\AccountSubscriptionAccessService; class GetPublicEventHandler { @@ -46,6 +47,7 @@ public function __construct( private readonly PublicOccurrenceVisibilityService $occurrenceVisibilityService, private readonly EventSeatMapLookupService $eventSeatMapLookupService, private readonly PublicProductSelectionFilter $productSelectionFilter, + private readonly AccountSubscriptionAccessService $accountSubscriptionAccessService, ) {} public function handle(GetPublicEventDTO $data): EventDomainObject @@ -110,6 +112,10 @@ public function handle(GetPublicEventDTO $data): EventDomainObject $event->setHasSeatMap($this->eventSeatMapLookupService->existsForEvent($data->eventId)); + $event->getOrganizer()?->setBrandingRemoved( + $this->accountSubscriptionAccessService->hasActiveSubscription($event->getAccountId()) + ); + return $event->setProductCategories($this->productFilterService->filter( productsCategories: $this->productSelectionFilter->filter( categories: $event->getProductCategories(), diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php index 45bb681638..bc2cffe556 100644 --- a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php +++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php @@ -26,6 +26,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Application\Handlers\Order\DTO\GetOrderPublicDTO; use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService; +use HiEvents\Services\Domain\Subscription\AccountSubscriptionAccessService; use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -35,6 +36,7 @@ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly CheckoutSessionManagementService $sessionIdentifierService, private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService, + private readonly AccountSubscriptionAccessService $accountSubscriptionAccessService, ) {} public function handle(GetOrderPublicDTO $getOrderData): OrderDomainObject @@ -56,6 +58,11 @@ public function handle(GetOrderPublicDTO $getOrderData): OrderDomainObject $this->offlinePaymentInstructionsRenderService->renderForOrder($order); + $organizer = $order->getEvent()?->getOrganizer(); + $organizer?->setBrandingRemoved( + $this->accountSubscriptionAccessService->hasActiveSubscription($organizer->getAccountId()) + ); + return $order; } diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php index cb8448494a..badc8cfb52 100644 --- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php +++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php @@ -8,6 +8,7 @@ use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\AccountUpdateHandler; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\ChargeRefundUpdatedHandler; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\ChargeSucceededHandler; +use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\CustomerSubscriptionUpdatedHandler; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentFailedHandler; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentSucceededHandler; use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PayoutPaidHandler; @@ -36,6 +37,9 @@ class IncomingWebhookHandler Event::PAYOUT_PAID, Event::PAYOUT_UPDATED, Event::TERMINAL_READER_ACTION_FAILED, + Event::CUSTOMER_SUBSCRIPTION_CREATED, + Event::CUSTOMER_SUBSCRIPTION_UPDATED, + Event::CUSTOMER_SUBSCRIPTION_DELETED, ]; public function __construct( @@ -46,6 +50,7 @@ public function __construct( private readonly AccountUpdateHandler $accountUpdateHandler, private readonly PayoutPaidHandler $payoutPaidHandler, private readonly TerminalReaderActionFailedHandler $terminalReaderActionFailedHandler, + private readonly CustomerSubscriptionUpdatedHandler $customerSubscriptionUpdatedHandler, private readonly Logger $logger, private readonly Repository $cache, private readonly StripeConfigurationService $stripeConfigurationService, @@ -112,6 +117,11 @@ public function handle(StripeWebhookDTO $webhookDTO): void case Event::TERMINAL_READER_ACTION_FAILED: $this->terminalReaderActionFailedHandler->handleEvent($event->data->object, $event->created); break; + case Event::CUSTOMER_SUBSCRIPTION_CREATED: + case Event::CUSTOMER_SUBSCRIPTION_UPDATED: + case Event::CUSTOMER_SUBSCRIPTION_DELETED: + $this->customerSubscriptionUpdatedHandler->handleEvent($event->data->object, $event->account); + break; } $this->markEventAsHandled($event); diff --git a/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php index 27edcdc8c8..b664ace75d 100644 --- a/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php @@ -4,22 +4,30 @@ use HiEvents\DomainObjects\ImageDomainObject; use HiEvents\DomainObjects\LocationDomainObject; +use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\OrganizerSettingDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use HiEvents\Services\Domain\Subscription\AccountSubscriptionAccessService; class GetPublicOrganizerHandler { public function __construct( - private readonly OrganizerRepositoryInterface $organizerRepository + private readonly OrganizerRepositoryInterface $organizerRepository, + private readonly AccountSubscriptionAccessService $accountSubscriptionAccessService, ) {} - public function handle(int $organizerId) + public function handle(int $organizerId): OrganizerDomainObject { - return $this->organizerRepository + /** @var OrganizerDomainObject $organizer */ + $organizer = $this->organizerRepository ->loadRelation(ImageDomainObject::class) ->loadRelation(OrganizerSettingDomainObject::class) ->loadRelation(new Relationship(LocationDomainObject::class, name: 'location_record')) ->findById($organizerId); + + return $organizer->setBrandingRemoved( + $this->accountSubscriptionAccessService->hasActiveSubscription($organizer->getAccountId()) + ); } } diff --git a/backend/app/Services/Domain/Account/AccountDeletionService.php b/backend/app/Services/Domain/Account/AccountDeletionService.php index a585f39f9d..ead26596a5 100644 --- a/backend/app/Services/Domain/Account/AccountDeletionService.php +++ b/backend/app/Services/Domain/Account/AccountDeletionService.php @@ -22,6 +22,7 @@ use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Services\Domain\Subscription\AccountSubscriptionCancellationService; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -45,6 +46,7 @@ public function __construct( private readonly AccountUserRepositoryInterface $accountUserRepository, private readonly DatabaseManager $databaseManager, private readonly LoggerInterface $logger, + private readonly AccountSubscriptionCancellationService $subscriptionCancellationService, ) {} public function findActiveRequest(int $accountId): ?AccountDeletionRequestDomainObject @@ -168,6 +170,10 @@ public function requestDeletion( Cache::forget($this->getPendingDeletionCacheKey($accountId)); + if (! $executeImmediately) { + $this->subscriptionCancellationService->tryCancelAtPeriodEnd($accountId); + } + $this->logger->info('Account deletion requested', [ 'account_id' => $accountId, 'requested_by_user_id' => $requestedByUserId, @@ -240,6 +246,8 @@ public function executeDeletion(int $deletionRequestId): void $outcome = $this->determineOutcome($accountId); + $this->subscriptionCancellationService->cancelImmediately($accountId); + $manifest = $outcome === AccountDeletionOutcome::HARD_DELETE ? $this->hardDeletionService->deleteAccount($accountId) : $this->anonymizationService->anonymizeAccount($accountId); diff --git a/backend/app/Services/Domain/Account/AccountHardDeletionService.php b/backend/app/Services/Domain/Account/AccountHardDeletionService.php index 53ee0b2089..b052804dec 100644 --- a/backend/app/Services/Domain/Account/AccountHardDeletionService.php +++ b/backend/app/Services/Domain/Account/AccountHardDeletionService.php @@ -198,6 +198,10 @@ private function deleteAccountData(AnonymizationContext $context): array ->update(['reviewed_by_user_id' => null]); } + $manifest['account_subscriptions'] = $connection->table('account_subscriptions') + ->where('account_id', $context->accountId) + ->delete(); + $manifest['accounts'] = $connection->table('accounts') ->where('id', $context->accountId) ->delete(); diff --git a/backend/app/Services/Domain/Account/AccountSuspensionService.php b/backend/app/Services/Domain/Account/AccountSuspensionService.php index 53e9278b8f..2299326549 100644 --- a/backend/app/Services/Domain/Account/AccountSuspensionService.php +++ b/backend/app/Services/Domain/Account/AccountSuspensionService.php @@ -15,6 +15,7 @@ use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use HiEvents\Services\Domain\Subscription\AccountSubscriptionCancellationService; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Mail; @@ -32,6 +33,7 @@ public function __construct( private readonly OrganizerRepositoryInterface $organizerRepository, private readonly DatabaseManager $databaseManager, private readonly LoggerInterface $logger, + private readonly AccountSubscriptionCancellationService $subscriptionCancellationService, ) {} public function isAccountSuspended(int $accountId): bool @@ -99,6 +101,8 @@ public function suspend( Cache::forget($this->getSuspendedCacheKey($accountId)); + $this->subscriptionCancellationService->tryCancelAtPeriodEnd($accountId); + $this->logger->info('Account suspended', [ 'account_id' => $accountId, 'suspended_by_user_id' => $suspendedByUserId, diff --git a/backend/app/Services/Domain/Mail/DTO/EmailAccentColorDTO.php b/backend/app/Services/Domain/Mail/DTO/EmailAccentColorDTO.php new file mode 100644 index 0000000000..4fbf6a67d9 --- /dev/null +++ b/backend/app/Services/Domain/Mail/DTO/EmailAccentColorDTO.php @@ -0,0 +1,14 @@ +normaliseHex($accent); + if ($hex === null) { + return null; + } + + $luminance = $this->relativeLuminance($hex); + $contrastOnWhite = $this->contrastRatio($luminance, 1.0); + + if ($contrastOnWhite < self::MIN_BUTTON_CONTRAST_ON_WHITE) { + return null; + } + + $color = '#'.$hex; + + return new EmailAccentColorDTO( + buttonColor: $color, + buttonTextColor: $contrastOnWhite >= $this->contrastRatio($luminance, 0.0) ? self::LIGHT_TEXT : self::DARK_TEXT, + linkColor: $contrastOnWhite >= self::MIN_LINK_CONTRAST_ON_WHITE ? $color : null, + ); + } + + private function normaliseHex(?string $accent): ?string + { + if ($accent === null || ! preg_match('/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iD', $accent, $matches)) { + return null; + } + + $hex = strtolower($matches[1]); + + if (strlen($hex) <= 4) { + $hex = implode('', array_map(fn (string $digit) => $digit.$digit, str_split(substr($hex, 0, 3)))); + } + + return substr($hex, 0, 6); + } + + private function relativeLuminance(string $hex): float + { + [$red, $green, $blue] = array_map(function (string $channel): float { + $value = hexdec($channel) / 255; + + return $value <= 0.03928 ? $value / 12.92 : (($value + 0.055) / 1.055) ** 2.4; + }, str_split($hex, 2)); + + return 0.2126 * $red + 0.7152 * $green + 0.0722 * $blue; + } + + private function contrastRatio(float $first, float $second): float + { + return (max($first, $second) + 0.05) / (min($first, $second) + 0.05); + } +} diff --git a/backend/app/Services/Domain/Mail/MailBrandingService.php b/backend/app/Services/Domain/Mail/MailBrandingService.php new file mode 100644 index 0000000000..e3dc175050 --- /dev/null +++ b/backend/app/Services/Domain/Mail/MailBrandingService.php @@ -0,0 +1,143 @@ +getContentEvent() : null; + + $isHiEvents = $this->accountSubscriptionAccessService->areSubscriptionsAvailable(); + + $hasSubscription = $isHiEvents + && $event !== null + && $this->accountSubscriptionAccessService->hasActiveSubscription($event->getAccountId()); + + $showPromo = $isHiEvents && $mail->showsHiEventsPromo() && ! $hasSubscription; + + $organizer = $hasSubscription ? $this->findOrganizer($event) : null; + + if ($organizer === null) { + return new MailBrandingDTO( + hideHiEventsBranding: false, + showHiEventsPromo: $showPromo, + promoUrl: $this->promoUrl(), + ); + } + + $accent = $this->emailAccentColorService->fromAccent($this->eventAccent($event)); + + return new MailBrandingDTO( + hideHiEventsBranding: true, + showHiEventsPromo: false, + promoUrl: $this->promoUrl(), + organizerName: $organizer->getName(), + organizerLogoUrl: $this->organizerLogoUrl($organizer), + organizerUrl: $organizer->getWebsite(), + senderName: $mail->isSentToBuyers() ? $this->senderName($organizer->getName()) : null, + buttonColor: $accent?->buttonColor, + buttonTextColor: $accent?->buttonTextColor, + linkColor: $accent?->linkColor, + ); + } + + private function eventAccent(EventDomainObject $event): ?string + { + /** @var EventSettingDomainObject|null $settings */ + $settings = $this->eventSettingsRepository->findFirstWhere([ + EventSettingDomainObjectAbstract::EVENT_ID => $event->getId(), + ]); + + $accent = $settings?->getHomepageThemeSettings()['accent'] ?? null; + + return is_string($accent) ? $accent : null; + } + + private function senderName(string $organizerName): ?string + { + if (str_contains($organizerName, '@') || preg_match('/[\p{Cf}\x{80}-\x{9F}]/u', $organizerName)) { + return null; + } + + $name = trim((string) preg_replace('/[\x00-\x1F\x7F]+/', ' ', $organizerName)); + + if ($name === '' || $this->mentionsPlatform($name)) { + return null; + } + + return Str::limit($name, self::MAX_SENDER_NAME_LENGTH, ''); + } + + private function mentionsPlatform(string $name): bool + { + $comparable = $this->lettersAndDigits($name); + + return collect(['Hi.Events', (string) config('app.name')]) + ->map(fn (string $platformName) => $this->lettersAndDigits($platformName)) + ->filter() + ->contains(fn (string $platformName) => str_contains($comparable, $platformName)); + } + + private function lettersAndDigits(string $value): string + { + return Str::lower((string) preg_replace('/[^\p{L}\p{N}]+/u', '', $value)); + } + + private function findOrganizer(EventDomainObject $event): ?OrganizerDomainObject + { + if ($event->getOrganizerId() === null) { + return null; + } + + /** @var OrganizerDomainObject|null */ + return $this->organizerRepository + ->loadRelation(ImageDomainObject::class) + ->findFirstWhere([ + 'id' => $event->getOrganizerId(), + 'account_id' => $event->getAccountId(), + ]); + } + + private function organizerLogoUrl(OrganizerDomainObject $organizer): ?string + { + $logo = $organizer->getImages()?->first( + fn (ImageDomainObject $image) => $image->getType() === ImageType::ORGANIZER_LOGO->name + ); + + return $logo ? Url::getCdnUrl($logo->getPath()) : null; + } + + private function promoUrl(): string + { + return Url::getFrontEndUrlFromConfig(Url::REGISTER, [ + 'utm_source' => 'email-promo', + 'utm_medium' => 'email', + 'utm_campaign' => 'create-your-event', + ]); + } +} diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/CustomerSubscriptionUpdatedHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/CustomerSubscriptionUpdatedHandler.php new file mode 100644 index 0000000000..6c049bc901 --- /dev/null +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/CustomerSubscriptionUpdatedHandler.php @@ -0,0 +1,28 @@ +accountSubscriptionSyncService->syncCustomer((string) $subscription->customer); + } +} diff --git a/backend/app/Services/Domain/Subscription/AccountSubscriptionAccessService.php b/backend/app/Services/Domain/Subscription/AccountSubscriptionAccessService.php new file mode 100644 index 0000000000..e8527a7eb5 --- /dev/null +++ b/backend/app/Services/Domain/Subscription/AccountSubscriptionAccessService.php @@ -0,0 +1,44 @@ +config->get('app.is_hi_events'); + } + + /** + * @throws FeatureNotEnabledException + */ + public function assertSubscriptionsAvailable(): void + { + if (! $this->areSubscriptionsAvailable()) { + throw new FeatureNotEnabledException; + } + } + + public function hasActiveSubscription(int $accountId): bool + { + if (! $this->areSubscriptionsAvailable()) { + return false; + } + + $subscription = $this->accountSubscriptionRepository->findFirstWhere([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + + return $subscription?->grantsAccess() ?? false; + } +} diff --git a/backend/app/Services/Domain/Subscription/AccountSubscriptionCancellationService.php b/backend/app/Services/Domain/Subscription/AccountSubscriptionCancellationService.php new file mode 100644 index 0000000000..142dc77a88 --- /dev/null +++ b/backend/app/Services/Domain/Subscription/AccountSubscriptionCancellationService.php @@ -0,0 +1,143 @@ +findAccountSubscription($accountId); + if ($accountSubscription === null) { + return; + } + + $stripeClient = $this->stripeClientFactory->createForBilling(); + $renewing = array_filter( + $this->openSubscriptions($stripeClient, $accountSubscription), + fn (Subscription $subscription) => ! $subscription->cancel_at_period_end, + ); + + foreach ($renewing as $subscription) { + $stripeClient->subscriptions->update($subscription->id, ['cancel_at_period_end' => true]); + } + + if ($renewing !== []) { + $this->accountSubscriptionRepository->updateFromArray($accountSubscription->getId(), [ + AccountSubscriptionDomainObjectAbstract::CANCEL_AT_PERIOD_END => true, + ]); + + $this->logger->info('Account subscriptions set to cancel at period end', [ + 'account_id' => $accountId, + 'stripe_subscription_ids' => array_map(fn (Subscription $subscription) => $subscription->id, array_values($renewing)), + ]); + } + } + + public function tryCancelAtPeriodEnd(int $accountId): void + { + try { + $this->cancelAtPeriodEnd($accountId); + } catch (ApiErrorException|StripeClientConfigurationException $exception) { + $this->logger->warning('Failed to stop account subscription renewal', [ + 'account_id' => $accountId, + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * @throws ApiErrorException + * @throws StripeClientConfigurationException + */ + public function cancelImmediately(int $accountId): void + { + $accountSubscription = $this->findAccountSubscription($accountId); + if ($accountSubscription === null) { + return; + } + + $stripeClient = $this->stripeClientFactory->createForBilling(); + $open = $this->openSubscriptions($stripeClient, $accountSubscription); + + foreach ($open as $subscription) { + $stripeClient->subscriptions->cancel($subscription->id); + } + + if ($accountSubscription->hasOpenSubscription() || $open !== []) { + $this->accountSubscriptionRepository->updateFromArray($accountSubscription->getId(), [ + AccountSubscriptionDomainObjectAbstract::STATUS => AccountSubscriptionStatus::CANCELED->name, + ]); + } + + if ($open !== []) { + $this->logger->info('Account subscriptions cancelled', [ + 'account_id' => $accountId, + 'stripe_subscription_ids' => array_map(fn (Subscription $subscription) => $subscription->id, $open), + ]); + } + } + + private function findAccountSubscription(int $accountId): ?AccountSubscriptionDomainObject + { + /** @var AccountSubscriptionDomainObject|null */ + return $this->accountSubscriptionRepository->findFirstWhere([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + } + + /** + * @return Subscription[] + * + * @throws ApiErrorException + */ + private function openSubscriptions(StripeClient $stripeClient, AccountSubscriptionDomainObject $accountSubscription): array + { + try { + $subscriptions = $stripeClient->subscriptions->all([ + 'customer' => $accountSubscription->getStripeCustomerId(), + 'status' => 'all', + 'limit' => self::SUBSCRIPTION_LIST_LIMIT, + ])->data; + } catch (InvalidRequestException $exception) { + if ($exception->getStripeCode() !== 'resource_missing') { + throw $exception; + } + + $this->logger->info('Stripe customer for account subscription no longer exists', [ + 'account_id' => $accountSubscription->getAccountId(), + 'stripe_customer_id' => $accountSubscription->getStripeCustomerId(), + ]); + + return []; + } + + return array_values(array_filter( + $subscriptions, + fn (Subscription $subscription) => AccountSubscriptionStatus::fromStripeStatus($subscription->status)?->isOpen() ?? false, + )); + } +} diff --git a/backend/app/Services/Domain/Subscription/AccountSubscriptionSyncService.php b/backend/app/Services/Domain/Subscription/AccountSubscriptionSyncService.php new file mode 100644 index 0000000000..ba62e34aee --- /dev/null +++ b/backend/app/Services/Domain/Subscription/AccountSubscriptionSyncService.php @@ -0,0 +1,121 @@ +accountSubscriptionRepository->findFirstWhere([ + AccountSubscriptionDomainObjectAbstract::STRIPE_CUSTOMER_ID => $stripeCustomerId, + ]); + + if ($accountSubscription === null) { + $this->logger->info('Ignoring Stripe subscription for a customer with no account subscription', [ + 'stripe_customer_id' => $stripeCustomerId, + ]); + + return; + } + + $this->refresh($accountSubscription); + } + + /** + * @throws ApiErrorException + * @throws StripeClientConfigurationException + */ + public function refreshForAccount(int $accountId): ?AccountSubscriptionDomainObject + { + /** @var AccountSubscriptionDomainObject|null $accountSubscription */ + $accountSubscription = $this->accountSubscriptionRepository->findFirstWhere([ + AccountSubscriptionDomainObjectAbstract::ACCOUNT_ID => $accountId, + ]); + + return $accountSubscription === null ? null : $this->refresh($accountSubscription); + } + + /** + * @throws ApiErrorException + * @throws StripeClientConfigurationException + */ + private function refresh(AccountSubscriptionDomainObject $accountSubscription): AccountSubscriptionDomainObject + { + $subscriptions = $this->stripeClientFactory + ->createForBilling() + ->subscriptions + ->all([ + 'customer' => $accountSubscription->getStripeCustomerId(), + 'status' => 'all', + 'limit' => self::SUBSCRIPTION_LIST_LIMIT, + ]) + ->data; + + $subscription = $this->mostRelevantSubscription($subscriptions); + + if ($subscription === null) { + return $accountSubscription; + } + + $status = AccountSubscriptionStatus::fromStripeStatus($subscription->status); + $currentPeriodEnd = $subscription->items->data[0]->current_period_end ?? null; + + /** @var AccountSubscriptionDomainObject $updated */ + $updated = $this->accountSubscriptionRepository->updateFromArray($accountSubscription->getId(), [ + AccountSubscriptionDomainObjectAbstract::STRIPE_SUBSCRIPTION_ID => $subscription->id, + AccountSubscriptionDomainObjectAbstract::STATUS => $status?->name, + AccountSubscriptionDomainObjectAbstract::CURRENT_PERIOD_END => $currentPeriodEnd + ? Carbon::createFromTimestamp($currentPeriodEnd) + : null, + AccountSubscriptionDomainObjectAbstract::CANCEL_AT_PERIOD_END => (bool) $subscription->cancel_at_period_end, + ]); + + if ($updated->getStatus() !== $accountSubscription->getStatus()) { + $this->logger->info('Account subscription status changed', [ + 'account_id' => $accountSubscription->getAccountId(), + 'stripe_subscription_id' => $subscription->id, + 'from' => $accountSubscription->getStatus(), + 'to' => $updated->getStatus(), + ]); + } + + return $updated; + } + + /** + * @param Subscription[] $subscriptions + */ + private function mostRelevantSubscription(array $subscriptions): ?Subscription + { + $statusOf = fn (Subscription $subscription) => AccountSubscriptionStatus::fromStripeStatus($subscription->status); + + return collect($subscriptions)->first(fn (Subscription $subscription) => $statusOf($subscription)?->grantsAccess()) + ?? collect($subscriptions)->first(fn (Subscription $subscription) => $statusOf($subscription)?->isOpen()) + ?? $subscriptions[0] + ?? null; + } +} diff --git a/backend/app/Services/Domain/Subscription/DTO/SubscriptionPriceDTO.php b/backend/app/Services/Domain/Subscription/DTO/SubscriptionPriceDTO.php new file mode 100644 index 0000000000..06af49e651 --- /dev/null +++ b/backend/app/Services/Domain/Subscription/DTO/SubscriptionPriceDTO.php @@ -0,0 +1,16 @@ +catalog(); + $currency = $this->currencyFor($catalog, strtoupper((string) $preferredCurrency)); + + return collect($catalog) + ->filter(fn (array $entry) => isset($entry['amounts'][$currency])) + ->map(fn (array $entry) => new SubscriptionPriceDTO( + interval: SubscriptionBillingInterval::from($entry['interval']), + priceId: $entry['price_id'], + unitAmount: $entry['amounts'][$currency], + currency: $currency, + )) + ->values() + ->all(); + } + + /** + * @param array}> $catalog + */ + private function currencyFor(array $catalog, string $preferredCurrency): string + { + $offeredEverywhere = $catalog !== [] && collect($catalog) + ->every(fn (array $entry) => isset($entry['amounts'][$preferredCurrency])); + + if ($offeredEverywhere) { + return $preferredCurrency; + } + + return collect($catalog)->first()['default_currency'] ?? $preferredCurrency; + } + + /** + * @return array}> + */ + private function catalog(): array + { + $cached = $this->cache->get(self::CACHE_KEY); + if (is_array($cached)) { + return $cached; + } + + try { + $catalog = $this->fetchCatalog(); + } catch (ApiErrorException|StripeClientConfigurationException $exception) { + $this->logger->warning('Failed to fetch subscription prices from Stripe', [ + 'error' => $exception->getMessage(), + ]); + $catalog = []; + } + + $this->cache->put( + self::CACHE_KEY, + $catalog, + $catalog === [] ? self::EMPTY_CACHE_TTL_SECONDS : self::CACHE_TTL_SECONDS, + ); + + return $catalog; + } + + /** + * @return array}> + * + * @throws ApiErrorException + * @throws StripeClientConfigurationException + */ + private function fetchCatalog(): array + { + $intervalsByLookupKey = collect(SubscriptionBillingInterval::cases()) + ->mapWithKeys(fn (SubscriptionBillingInterval $interval) => [$interval->priceLookupKey() => $interval]); + + $prices = $this->stripeClientFactory->createForBilling()->prices->all([ + 'lookup_keys' => $intervalsByLookupKey->keys()->all(), + 'active' => true, + 'expand' => ['data.currency_options'], + ])->data; + + $catalog = []; + foreach ($intervalsByLookupKey as $lookupKey => $interval) { + $price = collect($prices)->first(fn (Price $price) => $price->lookup_key === $lookupKey && $price->unit_amount !== null); + if ($price === null) { + continue; + } + + $catalog[$interval->value] = [ + 'interval' => $interval->value, + 'price_id' => $price->id, + 'default_currency' => strtoupper($price->currency), + 'amounts' => $this->amountsByCurrency($price), + ]; + } + + return $catalog; + } + + /** + * @return array + */ + private function amountsByCurrency(Price $price): array + { + $amounts = [strtoupper($price->currency) => $price->unit_amount]; + + foreach ($price->currency_options?->toArray() ?? [] as $currency => $option) { + if (isset($option['unit_amount'])) { + $amounts[strtoupper($currency)] = $option['unit_amount']; + } + } + + return $amounts; + } +} diff --git a/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php index ba2f8adbba..91ed1bcafa 100644 --- a/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php +++ b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php @@ -28,4 +28,12 @@ public function createForPlatform(?StripePlatform $platform = null): StripeClien return new StripeClient($secretKey); } + + /** + * @throws StripeClientConfigurationException + */ + public function createForBilling(): StripeClient + { + return $this->createForPlatform($this->configurationService->getPrimaryPlatform()); + } } diff --git a/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php index 6eaef0aab6..6a95951913 100644 --- a/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php +++ b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php @@ -37,9 +37,9 @@ public function getAllWebhookSecrets(): array 'default' => config('services.stripe.webhook_secret'), StripePlatform::CANADA->value => config('services.stripe.ca_webhook_secret'), StripePlatform::IRELAND->value => config('services.stripe.ie_webhook_secret'), + 'billing' => config('services.stripe.billing_webhook_secret'), ]); - // order by primary platform first $primary = $this->getPrimaryPlatform()?->value; if ($primary && isset($secrets[$primary])) { diff --git a/backend/config/app.php b/backend/config/app.php index c8697a916d..68f31bedb6 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -75,6 +75,8 @@ 'account_danger_zone' => '/settings/account#delete-account', 'account_users' => '/settings/team', 'account_settings' => '/settings/account', + 'account_billing' => '/settings/billing', + 'register' => '/auth/register', 'profile_security' => '/settings/security', 'api_keys' => '/settings/api-keys', 'event_dashboard' => '/manage/event/%d/dashboard', diff --git a/backend/config/services.php b/backend/config/services.php index db5062c89d..fbbb96da66 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -37,6 +37,7 @@ 'secret_key' => env('STRIPE_SECRET_KEY'), 'public_key' => env('STRIPE_PUBLIC_KEY'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + 'billing_webhook_secret' => env('STRIPE_BILLING_WEBHOOK_SECRET'), // Canadian platform (Optional) 'ca_secret_key' => env('STRIPE_CA_SECRET_KEY', env('STRIPE_SECRET_KEY')), diff --git a/backend/database/migrations/2026_10_22_100000_create_account_subscriptions_table.php b/backend/database/migrations/2026_10_22_100000_create_account_subscriptions_table.php new file mode 100644 index 0000000000..4901c85491 --- /dev/null +++ b/backend/database/migrations/2026_10_22_100000_create_account_subscriptions_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('account_id'); + $table->string('stripe_customer_id'); + $table->string('stripe_subscription_id')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('current_period_end')->nullable(); + $table->boolean('cancel_at_period_end')->default(false); + $table->timestamps(); + + $table->foreign('account_id') + ->references('id') + ->on('accounts') + ->onDelete('cascade'); + + $table->unique('account_id'); + $table->unique('stripe_customer_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('account_subscriptions'); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index 66d8705b63..64f7c75f60 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Ihr :app_name-Konto wurde gesperrt", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ihr :appName-Konto wurde gesperrt. Ihre Veranstaltungen und Veranstalterseiten wurden zurückgezogen, und Sie können sich nicht mehr bei dem Konto anmelden.", "Reason:": "Grund:", - "If you think this is a mistake, contact us at :email.": "Wenn Sie glauben, dass es sich um einen Fehler handelt, kontaktieren Sie uns unter :email." + "If you think this is a mistake, contact us at :email.": "Wenn Sie glauben, dass es sich um einen Fehler handelt, kontaktieren Sie uns unter :email.", + "This account has no subscription to manage.": "Dieses Konto hat kein Abonnement, das verwaltet werden kann.", + "You cannot start a subscription while impersonating a user.": "Sie können kein Abonnement starten, während Sie sich als ein anderer Benutzer ausgeben.", + "You cannot manage a subscription while impersonating a user.": "Sie können kein Abonnement verwalten, während Sie sich als ein anderer Benutzer ausgeben.", + "Planning an event of your own?": "Planen Sie selbst eine Veranstaltung?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Verkaufen Sie Tickets und verwalten Sie Teilnehmer mit :app_name. Der Einstieg ist kostenlos.", + "Create your event": "Veranstaltung erstellen", + "We couldn't open the payment page. Please try again later.": "Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuchen Sie es später erneut.", + "We couldn't open the billing portal. Please try again later.": "Das Abrechnungsportal konnte nicht geöffnet werden. Bitte versuchen Sie es später erneut.", + "This account is scheduled for deletion.": "Dieses Konto ist zur Löschung vorgesehen.", + "This account already has a subscription. Use Manage billing to change it.": "Dieses Konto hat bereits ein Abonnement. Ändern Sie es über „Abrechnung verwalten“." } diff --git a/backend/lang/el.json b/backend/lang/el.json index 0f3866c53f..0587c12d2c 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Ο λογαριασμός σας στο :app_name έχει ανασταλεί", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ο λογαριασμός σας στο :appName έχει ανασταλεί. Οι εκδηλώσεις και οι σελίδες διοργανωτή σας αποσύρθηκαν και δεν μπορείτε πλέον να συνδεθείτε στον λογαριασμό.", "Reason:": "Αιτία:", - "If you think this is a mistake, contact us at :email.": "Αν πιστεύετε ότι πρόκειται για λάθος, επικοινωνήστε μαζί μας στο :email." + "If you think this is a mistake, contact us at :email.": "Αν πιστεύετε ότι πρόκειται για λάθος, επικοινωνήστε μαζί μας στο :email.", + "This account has no subscription to manage.": "Αυτός ο λογαριασμός δεν έχει συνδρομή για διαχείριση.", + "You cannot start a subscription while impersonating a user.": "Δεν μπορείτε να ξεκινήσετε συνδρομή ενώ υποδύεστε άλλον χρήστη.", + "You cannot manage a subscription while impersonating a user.": "Δεν μπορείτε να διαχειριστείτε συνδρομή ενώ υποδύεστε άλλον χρήστη.", + "Planning an event of your own?": "Σχεδιάζετε τη δική σας εκδήλωση;", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Πουλήστε εισιτήρια και διαχειριστείτε συμμετέχοντες με το :app_name. Η έναρξη είναι δωρεάν.", + "Create your event": "Δημιουργήστε την εκδήλωσή σας", + "We couldn't open the payment page. Please try again later.": "Δεν ήταν δυνατό να ανοίξει η σελίδα πληρωμής. Δοκιμάστε ξανά αργότερα.", + "We couldn't open the billing portal. Please try again later.": "Δεν ήταν δυνατό να ανοίξει η πύλη χρέωσης. Δοκιμάστε ξανά αργότερα.", + "This account is scheduled for deletion.": "Αυτός ο λογαριασμός έχει προγραμματιστεί για διαγραφή.", + "This account already has a subscription. Use Manage billing to change it.": "Αυτός ο λογαριασμός έχει ήδη συνδρομή. Χρησιμοποιήστε τη Διαχείριση χρέωσης για να την αλλάξετε." } diff --git a/backend/lang/es.json b/backend/lang/es.json index 7e07a34d18..1b1c955a7f 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -1252,5 +1252,15 @@ "Your :app_name account has been suspended": "Tu cuenta de :app_name ha sido suspendida", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Tu cuenta de :appName ha sido suspendida. Tus eventos y páginas de organizador se han despublicado y ya no puedes iniciar sesión en la cuenta.", "Reason:": "Motivo:", - "If you think this is a mistake, contact us at :email.": "Si crees que se trata de un error, escríbenos a :email." + "If you think this is a mistake, contact us at :email.": "Si crees que se trata de un error, escríbenos a :email.", + "This account has no subscription to manage.": "Esta cuenta no tiene ninguna suscripción que gestionar.", + "You cannot start a subscription while impersonating a user.": "No puedes iniciar una suscripción mientras suplantas a un usuario.", + "You cannot manage a subscription while impersonating a user.": "No puedes gestionar una suscripción mientras suplantas a un usuario.", + "Planning an event of your own?": "¿Estás planeando tu propio evento?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Vende entradas y gestiona asistentes con :app_name. Empezar es gratis.", + "Create your event": "Crea tu evento", + "We couldn't open the payment page. Please try again later.": "No pudimos abrir la página de pago. Inténtalo de nuevo más tarde.", + "We couldn't open the billing portal. Please try again later.": "No pudimos abrir el portal de facturación. Inténtalo de nuevo más tarde.", + "This account is scheduled for deletion.": "Esta cuenta está programada para eliminarse.", + "This account already has a subscription. Use Manage billing to change it.": "Esta cuenta ya tiene una suscripción. Usa Gestionar facturación para cambiarla." } diff --git a/backend/lang/fi.json b/backend/lang/fi.json index f30736c015..16139b2ee1 100644 --- a/backend/lang/fi.json +++ b/backend/lang/fi.json @@ -1400,5 +1400,15 @@ "Your :app_name account has been suspended": ":app_name-tilisi on jäädytetty", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName-tilisi on jäädytetty. Tapahtumasi ja järjestäjäsivusi on poistettu julkaisusta, etkä voi enää kirjautua tilille.", "Reason:": "Syy:", - "If you think this is a mistake, contact us at :email.": "Jos uskot tämän olevan virhe, ota meihin yhteyttä osoitteessa :email." + "If you think this is a mistake, contact us at :email.": "Jos uskot tämän olevan virhe, ota meihin yhteyttä osoitteessa :email.", + "This account has no subscription to manage.": "Tällä tilillä ei ole hallittavaa tilausta.", + "You cannot start a subscription while impersonating a user.": "Et voi aloittaa tilausta toisena käyttäjänä esiintyessäsi.", + "You cannot manage a subscription while impersonating a user.": "Et voi hallita tilausta toisena käyttäjänä esiintyessäsi.", + "Planning an event of your own?": "Suunnitteletko omaa tapahtumaa?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Myy lippuja ja hallitse osallistujia :app_nameillä. Aloittaminen on ilmaista.", + "Create your event": "Luo tapahtumasi", + "We couldn't open the payment page. Please try again later.": "Maksusivua ei voitu avata. Yritä myöhemmin uudelleen.", + "We couldn't open the billing portal. Please try again later.": "Laskutusportaalia ei voitu avata. Yritä myöhemmin uudelleen.", + "This account is scheduled for deletion.": "Tämä tili on ajastettu poistettavaksi.", + "This account already has a subscription. Use Manage billing to change it.": "Tällä tilillä on jo tilaus. Muuta sitä kohdassa Hallitse laskutusta." } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index 6af58c5c85..00bbea7220 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Votre compte :app_name a été suspendu", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Votre compte :appName a été suspendu. Vos événements et pages d'organisateur ont été dépubliés et vous ne pouvez plus vous connecter au compte.", "Reason:": "Motif :", - "If you think this is a mistake, contact us at :email.": "Si vous pensez qu'il s'agit d'une erreur, contactez-nous à :email." + "If you think this is a mistake, contact us at :email.": "Si vous pensez qu'il s'agit d'une erreur, contactez-nous à :email.", + "This account has no subscription to manage.": "Ce compte n'a aucun abonnement à gérer.", + "You cannot start a subscription while impersonating a user.": "Vous ne pouvez pas démarrer un abonnement en usurpant l'identité d'un utilisateur.", + "You cannot manage a subscription while impersonating a user.": "Vous ne pouvez pas gérer un abonnement en usurpant l'identité d'un utilisateur.", + "Planning an event of your own?": "Vous organisez votre propre événement ?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Vendez des billets et gérez vos participants avec :app_name. C'est gratuit pour commencer.", + "Create your event": "Créez votre événement", + "We couldn't open the payment page. Please try again later.": "Impossible d'ouvrir la page de paiement. Veuillez réessayer plus tard.", + "We couldn't open the billing portal. Please try again later.": "Impossible d'ouvrir le portail de facturation. Veuillez réessayer plus tard.", + "This account is scheduled for deletion.": "Ce compte est programmé pour être supprimé.", + "This account already has a subscription. Use Manage billing to change it.": "Ce compte a déjà un abonnement. Utilisez Gérer la facturation pour le modifier." } diff --git a/backend/lang/hu.json b/backend/lang/hu.json index 7ce24b435d..972a9be9e5 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -1217,5 +1217,15 @@ "Your :app_name account has been suspended": ":app_name fiókodat felfüggesztették", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName fiókodat felfüggesztették. Eseményeidet és szervezői oldalaidat visszavontuk, és többé nem tudsz bejelentkezni a fiókba.", "Reason:": "Indok:", - "If you think this is a mistake, contact us at :email.": "Ha úgy gondolod, hogy ez tévedés, írj nekünk: :email." + "If you think this is a mistake, contact us at :email.": "Ha úgy gondolod, hogy ez tévedés, írj nekünk: :email.", + "This account has no subscription to manage.": "Ennek a fióknak nincs kezelhető előfizetése.", + "You cannot start a subscription while impersonating a user.": "Nem indíthat előfizetést, amíg egy másik felhasználó nevében jár el.", + "You cannot manage a subscription while impersonating a user.": "Nem kezelhet előfizetést, amíg egy másik felhasználó nevében jár el.", + "Planning an event of your own?": "Saját eseményt tervez?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Adjon el jegyeket és kezelje a résztvevőket a :app_name segítségével. Az indulás ingyenes.", + "Create your event": "Hozza létre eseményét", + "We couldn't open the payment page. Please try again later.": "Nem sikerült megnyitni a fizetési oldalt. Kérjük, próbálja újra később.", + "We couldn't open the billing portal. Please try again later.": "Nem sikerült megnyitni a számlázási portált. Kérjük, próbálja újra később.", + "This account is scheduled for deletion.": "Ez a fiók törlésre van ütemezve.", + "This account already has a subscription. Use Manage billing to change it.": "Ennek a fióknak már van előfizetése. A módosításhoz használja a Számlázás kezelése lehetőséget." } diff --git a/backend/lang/it.json b/backend/lang/it.json index aa007e69ad..6b1b2da492 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -1213,5 +1213,15 @@ "Your :app_name account has been suspended": "Il tuo account :app_name è stato sospeso", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Il tuo account :appName è stato sospeso. I tuoi eventi e le tue pagine organizzatore sono stati ritirati e non puoi più accedere all'account.", "Reason:": "Motivo:", - "If you think this is a mistake, contact us at :email.": "Se pensi che si tratti di un errore, contattaci a :email." + "If you think this is a mistake, contact us at :email.": "Se pensi che si tratti di un errore, contattaci a :email.", + "This account has no subscription to manage.": "Questo account non ha alcun abbonamento da gestire.", + "You cannot start a subscription while impersonating a user.": "Non puoi avviare un abbonamento mentre impersoni un utente.", + "You cannot manage a subscription while impersonating a user.": "Non puoi gestire un abbonamento mentre impersoni un utente.", + "Planning an event of your own?": "Stai organizzando un tuo evento?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Vendi biglietti e gestisci i partecipanti con :app_name. Iniziare è gratis.", + "Create your event": "Crea il tuo evento", + "We couldn't open the payment page. Please try again later.": "Non è stato possibile aprire la pagina di pagamento. Riprova più tardi.", + "We couldn't open the billing portal. Please try again later.": "Non è stato possibile aprire il portale di fatturazione. Riprova più tardi.", + "This account is scheduled for deletion.": "Questo account è programmato per l'eliminazione.", + "This account already has a subscription. Use Manage billing to change it.": "Questo account ha già un abbonamento. Usa Gestisci fatturazione per modificarlo." } diff --git a/backend/lang/ja.json b/backend/lang/ja.json index 77e87b5294..440f221996 100644 --- a/backend/lang/ja.json +++ b/backend/lang/ja.json @@ -1400,5 +1400,15 @@ "Your :app_name account has been suspended": ":app_name のアカウントが停止されました", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName のアカウントが停止されました。イベントと主催者ページは非公開になり、このアカウントにはサインインできなくなりました。", "Reason:": "理由:", - "If you think this is a mistake, contact us at :email.": "誤りだと思われる場合は :email までご連絡ください。" + "If you think this is a mistake, contact us at :email.": "誤りだと思われる場合は :email までご連絡ください。", + "This account has no subscription to manage.": "このアカウントには管理できるサブスクリプションがありません。", + "You cannot start a subscription while impersonating a user.": "ユーザーになりすましている間はサブスクリプションを開始できません。", + "You cannot manage a subscription while impersonating a user.": "ユーザーになりすましている間はサブスクリプションを管理できません。", + "Planning an event of your own?": "ご自身でイベントを企画していますか?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": ":app_nameでチケットを販売し、参加者を管理しましょう。無料で始められます。", + "Create your event": "イベントを作成", + "We couldn't open the payment page. Please try again later.": "お支払いページを開けませんでした。後でもう一度お試しください。", + "We couldn't open the billing portal. Please try again later.": "請求ポータルを開けませんでした。後でもう一度お試しください。", + "This account is scheduled for deletion.": "このアカウントは削除が予定されています。", + "This account already has a subscription. Use Manage billing to change it.": "このアカウントには既にサブスクリプションがあります。変更するには「請求を管理」を使用してください。" } diff --git a/backend/lang/ko.json b/backend/lang/ko.json index fe7fcf6049..b11994c372 100644 --- a/backend/lang/ko.json +++ b/backend/lang/ko.json @@ -1401,5 +1401,15 @@ "Your :app_name account has been suspended": ":app_name 계정이 정지되었습니다", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName 계정이 정지되었습니다. 이벤트와 주최자 페이지가 게시 중단되었으며 더 이상 계정에 로그인할 수 없습니다.", "Reason:": "사유:", - "If you think this is a mistake, contact us at :email.": "오류라고 생각되면 :email로 문의해 주세요." + "If you think this is a mistake, contact us at :email.": "오류라고 생각되면 :email로 문의해 주세요.", + "This account has no subscription to manage.": "이 계정에는 관리할 구독이 없습니다.", + "You cannot start a subscription while impersonating a user.": "다른 사용자로 가장하는 동안에는 구독을 시작할 수 없습니다.", + "You cannot manage a subscription while impersonating a user.": "다른 사용자로 가장하는 동안에는 구독을 관리할 수 없습니다.", + "Planning an event of your own?": "직접 이벤트를 계획하고 계신가요?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": ":app_name로 티켓을 판매하고 참석자를 관리하세요. 무료로 시작할 수 있습니다.", + "Create your event": "이벤트 만들기", + "We couldn't open the payment page. Please try again later.": "결제 페이지를 열 수 없습니다. 나중에 다시 시도해 주세요.", + "We couldn't open the billing portal. Please try again later.": "결제 포털을 열 수 없습니다. 나중에 다시 시도해 주세요.", + "This account is scheduled for deletion.": "이 계정은 삭제가 예약되어 있습니다.", + "This account already has a subscription. Use Manage billing to change it.": "이 계정에는 이미 구독이 있습니다. 변경하려면 결제 관리를 사용하세요." } diff --git a/backend/lang/nl.json b/backend/lang/nl.json index 2c81e3f402..76af5bd89d 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Je :app_name-account is opgeschort", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Je :appName-account is opgeschort. Je evenementen en organisatorpagina's zijn offline gehaald en je kunt niet meer inloggen op het account.", "Reason:": "Reden:", - "If you think this is a mistake, contact us at :email.": "Denk je dat dit een vergissing is? Neem contact met ons op via :email." + "If you think this is a mistake, contact us at :email.": "Denk je dat dit een vergissing is? Neem contact met ons op via :email.", + "This account has no subscription to manage.": "Dit account heeft geen abonnement om te beheren.", + "You cannot start a subscription while impersonating a user.": "Je kunt geen abonnement starten terwijl je een gebruiker imiteert.", + "You cannot manage a subscription while impersonating a user.": "Je kunt geen abonnement beheren terwijl je een gebruiker imiteert.", + "Planning an event of your own?": "Organiseer je zelf een evenement?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Verkoop tickets en beheer deelnemers met :app_name. Gratis om mee te beginnen.", + "Create your event": "Maak je evenement", + "We couldn't open the payment page. Please try again later.": "We konden de betaalpagina niet openen. Probeer het later opnieuw.", + "We couldn't open the billing portal. Please try again later.": "We konden het factureringsportaal niet openen. Probeer het later opnieuw.", + "This account is scheduled for deletion.": "Dit account staat gepland voor verwijdering.", + "This account already has a subscription. Use Manage billing to change it.": "Dit account heeft al een abonnement. Gebruik Facturering beheren om het te wijzigen." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index d3f3504f59..457533a7cb 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1400,5 +1400,15 @@ "Your :app_name account has been suspended": "Twoje konto :app_name zostało zawieszone", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Twoje konto :appName zostało zawieszone. Twoje wydarzenia i strony organizatora zostały wycofane z publikacji i nie możesz już logować się na to konto.", "Reason:": "Powód:", - "If you think this is a mistake, contact us at :email.": "Jeśli uważasz, że to pomyłka, napisz do nas na :email." + "If you think this is a mistake, contact us at :email.": "Jeśli uważasz, że to pomyłka, napisz do nas na :email.", + "This account has no subscription to manage.": "To konto nie ma subskrypcji do zarządzania.", + "You cannot start a subscription while impersonating a user.": "Nie możesz rozpocząć subskrypcji, podszywając się pod użytkownika.", + "You cannot manage a subscription while impersonating a user.": "Nie możesz zarządzać subskrypcją, podszywając się pod użytkownika.", + "Planning an event of your own?": "Planujesz własne wydarzenie?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Sprzedawaj bilety i zarządzaj uczestnikami z :app_name. Start jest bezpłatny.", + "Create your event": "Utwórz swoje wydarzenie", + "We couldn't open the payment page. Please try again later.": "Nie udało się otworzyć strony płatności. Spróbuj ponownie później.", + "We couldn't open the billing portal. Please try again later.": "Nie udało się otworzyć portalu rozliczeń. Spróbuj ponownie później.", + "This account is scheduled for deletion.": "To konto jest zaplanowane do usunięcia.", + "This account already has a subscription. Use Manage billing to change it.": "To konto ma już subskrypcję. Użyj opcji Zarządzaj płatnościami, aby ją zmienić." } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 8b5b15d2d8..d1c8c59d49 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Sua conta :app_name foi suspensa", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Sua conta :appName foi suspensa. Seus eventos e páginas de organizador foram despublicados e você não pode mais entrar na conta.", "Reason:": "Motivo:", - "If you think this is a mistake, contact us at :email.": "Se achar que é um engano, fale com a gente em :email." + "If you think this is a mistake, contact us at :email.": "Se achar que é um engano, fale com a gente em :email.", + "This account has no subscription to manage.": "Esta conta não tem nenhuma assinatura para gerenciar.", + "You cannot start a subscription while impersonating a user.": "Você não pode iniciar uma assinatura enquanto personifica um usuário.", + "You cannot manage a subscription while impersonating a user.": "Você não pode gerenciar uma assinatura enquanto personifica um usuário.", + "Planning an event of your own?": "Planejando seu próprio evento?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Venda ingressos e gerencie participantes com o :app_name. É grátis para começar.", + "Create your event": "Crie seu evento", + "We couldn't open the payment page. Please try again later.": "Não foi possível abrir a página de pagamento. Tente novamente mais tarde.", + "We couldn't open the billing portal. Please try again later.": "Não foi possível abrir o portal de cobrança. Tente novamente mais tarde.", + "This account is scheduled for deletion.": "Esta conta está programada para ser excluída.", + "This account already has a subscription. Use Manage billing to change it.": "Esta conta já tem uma assinatura. Use Gerenciar cobrança para alterá-la." } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 2061ac69bb..937f251363 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "A sua conta :app_name foi suspensa", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "A sua conta :appName foi suspensa. Os seus eventos e páginas de organizador foram despublicados e já não pode iniciar sessão na conta.", "Reason:": "Motivo:", - "If you think this is a mistake, contact us at :email.": "Se achar que se trata de um erro, contacte-nos através de :email." + "If you think this is a mistake, contact us at :email.": "Se achar que se trata de um erro, contacte-nos através de :email.", + "This account has no subscription to manage.": "Esta conta não tem nenhuma subscrição para gerir.", + "You cannot start a subscription while impersonating a user.": "Não pode iniciar uma subscrição enquanto se faz passar por um utilizador.", + "You cannot manage a subscription while impersonating a user.": "Não pode gerir uma subscrição enquanto se faz passar por um utilizador.", + "Planning an event of your own?": "Está a planear o seu próprio evento?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Venda bilhetes e faça a gestão dos participantes com o :app_name. Começar é grátis.", + "Create your event": "Crie o seu evento", + "We couldn't open the payment page. Please try again later.": "Não foi possível abrir a página de pagamento. Tente novamente mais tarde.", + "We couldn't open the billing portal. Please try again later.": "Não foi possível abrir o portal de faturação. Tente novamente mais tarde.", + "This account is scheduled for deletion.": "Esta conta está agendada para ser eliminada.", + "This account already has a subscription. Use Manage billing to change it.": "Esta conta já tem uma subscrição. Use Gerir faturação para a alterar." } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 275b37722e..ba658d5e6c 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -1154,5 +1154,15 @@ "Your :app_name account has been suspended": "Ваш аккаунт :app_name заблокирован", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ваш аккаунт :appName заблокирован. Ваши мероприятия и страницы организатора сняты с публикации, и вы больше не можете войти в аккаунт.", "Reason:": "Причина:", - "If you think this is a mistake, contact us at :email.": "Если вы считаете, что это ошибка, напишите нам на :email." + "If you think this is a mistake, contact us at :email.": "Если вы считаете, что это ошибка, напишите нам на :email.", + "This account has no subscription to manage.": "У этого аккаунта нет подписки для управления.", + "You cannot start a subscription while impersonating a user.": "Нельзя оформить подписку, действуя от имени другого пользователя.", + "You cannot manage a subscription while impersonating a user.": "Нельзя управлять подпиской, действуя от имени другого пользователя.", + "Planning an event of your own?": "Планируете собственное мероприятие?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Продавайте билеты и управляйте участниками с :app_name. Начать можно бесплатно.", + "Create your event": "Создайте мероприятие", + "We couldn't open the payment page. Please try again later.": "Не удалось открыть страницу оплаты. Попробуйте позже.", + "We couldn't open the billing portal. Please try again later.": "Не удалось открыть портал оплаты. Попробуйте позже.", + "This account is scheduled for deletion.": "Этот аккаунт запланирован к удалению.", + "This account already has a subscription. Use Manage billing to change it.": "У этого аккаунта уже есть подписка. Измените её через «Управление оплатой»." } diff --git a/backend/lang/se.json b/backend/lang/se.json index 5c3025e109..0ad98ef8f6 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Ditt :app_name-konto har stängts av", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ditt :appName-konto har stängts av. Dina evenemang och arrangörssidor har avpublicerats och du kan inte längre logga in på kontot.", "Reason:": "Anledning:", - "If you think this is a mistake, contact us at :email.": "Om du tror att det är ett misstag, kontakta oss på :email." + "If you think this is a mistake, contact us at :email.": "Om du tror att det är ett misstag, kontakta oss på :email.", + "This account has no subscription to manage.": "Det här kontot har ingen prenumeration att hantera.", + "You cannot start a subscription while impersonating a user.": "Du kan inte starta en prenumeration medan du agerar som en annan användare.", + "You cannot manage a subscription while impersonating a user.": "Du kan inte hantera en prenumeration medan du agerar som en annan användare.", + "Planning an event of your own?": "Planerar du ett eget evenemang?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Sälj biljetter och hantera deltagare med :app_name. Det är gratis att komma igång.", + "Create your event": "Skapa ditt evenemang", + "We couldn't open the payment page. Please try again later.": "Vi kunde inte öppna betalningssidan. Försök igen senare.", + "We couldn't open the billing portal. Please try again later.": "Vi kunde inte öppna faktureringsportalen. Försök igen senare.", + "This account is scheduled for deletion.": "Det här kontot är schemalagt för radering.", + "This account already has a subscription. Use Manage billing to change it.": "Det här kontot har redan en prenumeration. Använd Hantera fakturering för att ändra den." } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index 7b7bd48df8..e3536d9879 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Váš účet :app_name bol pozastavený", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Váš účet :appName bol pozastavený. Vaše udalosti a stránky organizátora boli stiahnuté zo zverejnenia a do účtu sa už nemôžete prihlásiť.", "Reason:": "Dôvod:", - "If you think this is a mistake, contact us at :email.": "Ak si myslíte, že ide o omyl, kontaktujte nás na :email." + "If you think this is a mistake, contact us at :email.": "Ak si myslíte, že ide o omyl, kontaktujte nás na :email.", + "This account has no subscription to manage.": "Tento účet nemá žiadne predplatné na správu.", + "You cannot start a subscription while impersonating a user.": "Predplatné nemôžete začať, keď vystupujete ako iný používateľ.", + "You cannot manage a subscription while impersonating a user.": "Predplatné nemôžete spravovať, keď vystupujete ako iný používateľ.", + "Planning an event of your own?": "Plánujete vlastné podujatie?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Predávajte vstupenky a spravujte účastníkov s :app_name. Začať je zadarmo.", + "Create your event": "Vytvorte svoje podujatie", + "We couldn't open the payment page. Please try again later.": "Platobnú stránku sa nepodarilo otvoriť. Skúste to znova neskôr.", + "We couldn't open the billing portal. Please try again later.": "Fakturačný portál sa nepodarilo otvoriť. Skúste to znova neskôr.", + "This account is scheduled for deletion.": "Tento účet je naplánovaný na odstránenie.", + "This account already has a subscription. Use Manage billing to change it.": "Tento účet už má predplatné. Na jeho zmenu použite Spravovať fakturáciu." } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 3d17deb73c..57b18da121 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -1227,5 +1227,15 @@ "Your :app_name account has been suspended": ":app_name hesabınız askıya alındı", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName hesabınız askıya alındı. Etkinlikleriniz ve organizatör sayfalarınız yayından kaldırıldı ve artık hesaba giriş yapamazsınız.", "Reason:": "Neden:", - "If you think this is a mistake, contact us at :email.": "Bunun bir hata olduğunu düşünüyorsanız :email adresinden bize ulaşın." + "If you think this is a mistake, contact us at :email.": "Bunun bir hata olduğunu düşünüyorsanız :email adresinden bize ulaşın.", + "This account has no subscription to manage.": "Bu hesabın yönetilecek bir aboneliği yok.", + "You cannot start a subscription while impersonating a user.": "Bir kullanıcının kimliğine bürünürken abonelik başlatamazsınız.", + "You cannot manage a subscription while impersonating a user.": "Bir kullanıcının kimliğine bürünürken aboneliği yönetemezsiniz.", + "Planning an event of your own?": "Kendi etkinliğinizi mi planlıyorsunuz?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": ":app_name ile bilet satın ve katılımcıları yönetin. Başlamak ücretsiz.", + "Create your event": "Etkinliğinizi oluşturun", + "We couldn't open the payment page. Please try again later.": "Ödeme sayfası açılamadı. Lütfen daha sonra tekrar deneyin.", + "We couldn't open the billing portal. Please try again later.": "Faturalandırma portalı açılamadı. Lütfen daha sonra tekrar deneyin.", + "This account is scheduled for deletion.": "Bu hesabın silinmesi planlandı.", + "This account already has a subscription. Use Manage billing to change it.": "Bu hesabın zaten bir aboneliği var. Değiştirmek için Faturalandırmayı yönet'i kullanın." } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index c1da48acea..2e75d19105 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "Tài khoản :app_name của bạn đã bị tạm ngưng", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Tài khoản :appName của bạn đã bị tạm ngưng. Các sự kiện và trang nhà tổ chức của bạn đã bị gỡ xuất bản và bạn không thể đăng nhập vào tài khoản nữa.", "Reason:": "Lý do:", - "If you think this is a mistake, contact us at :email.": "Nếu bạn cho rằng đây là nhầm lẫn, hãy liên hệ với chúng tôi qua :email." + "If you think this is a mistake, contact us at :email.": "Nếu bạn cho rằng đây là nhầm lẫn, hãy liên hệ với chúng tôi qua :email.", + "This account has no subscription to manage.": "Tài khoản này không có gói đăng ký nào để quản lý.", + "You cannot start a subscription while impersonating a user.": "Bạn không thể bắt đầu gói đăng ký khi đang mạo danh người dùng.", + "You cannot manage a subscription while impersonating a user.": "Bạn không thể quản lý gói đăng ký khi đang mạo danh người dùng.", + "Planning an event of your own?": "Bạn đang lên kế hoạch cho sự kiện của riêng mình?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "Bán vé và quản lý người tham dự với :app_name. Bắt đầu miễn phí.", + "Create your event": "Tạo sự kiện của bạn", + "We couldn't open the payment page. Please try again later.": "Không thể mở trang thanh toán. Vui lòng thử lại sau.", + "We couldn't open the billing portal. Please try again later.": "Không thể mở cổng thanh toán. Vui lòng thử lại sau.", + "This account is scheduled for deletion.": "Tài khoản này đã được lên lịch xóa.", + "This account already has a subscription. Use Manage billing to change it.": "Tài khoản này đã có gói đăng ký. Hãy dùng Quản lý thanh toán để thay đổi." } diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 86683a72ca..5ed78e7ff5 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "您的 :app_name 账户已被暂停", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "您的 :appName 账户已被暂停。您的活动和主办方页面已取消发布,您将无法再登录该账户。", "Reason:": "原因:", - "If you think this is a mistake, contact us at :email.": "如果您认为这是误操作,请通过 :email 联系我们。" + "If you think this is a mistake, contact us at :email.": "如果您认为这是误操作,请通过 :email 联系我们。", + "This account has no subscription to manage.": "此账户没有可管理的订阅。", + "You cannot start a subscription while impersonating a user.": "模拟其他用户时无法开始订阅。", + "You cannot manage a subscription while impersonating a user.": "模拟其他用户时无法管理订阅。", + "Planning an event of your own?": "正在策划自己的活动?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "使用 :app_name 售票并管理参与者。免费开始使用。", + "Create your event": "创建您的活动", + "We couldn't open the payment page. Please try again later.": "无法打开付款页面,请稍后再试。", + "We couldn't open the billing portal. Please try again later.": "无法打开账单门户,请稍后再试。", + "This account is scheduled for deletion.": "此账户已计划删除。", + "This account already has a subscription. Use Manage billing to change it.": "此账户已有订阅。请使用“管理账单”进行更改。" } diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index 76c5ed1b5f..dc78256f28 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -1212,5 +1212,15 @@ "Your :app_name account has been suspended": "您的 :app_name 帳戶已被暫停", "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "您的 :appName 帳戶已被暫停。您的活動和主辦方頁面已取消發佈,您將無法再登入該帳戶。", "Reason:": "原因:", - "If you think this is a mistake, contact us at :email.": "如果您認為這是誤會,請透過 :email 聯絡我們。" + "If you think this is a mistake, contact us at :email.": "如果您認為這是誤會,請透過 :email 聯絡我們。", + "This account has no subscription to manage.": "此帳戶沒有可管理的訂閱。", + "You cannot start a subscription while impersonating a user.": "模擬其他用戶時無法開始訂閱。", + "You cannot manage a subscription while impersonating a user.": "模擬其他用戶時無法管理訂閱。", + "Planning an event of your own?": "正在籌劃自己的活動?", + "Sell tickets and manage attendees with :app_name. It's free to get started.": "使用 :app_name 售票並管理參加者。免費開始使用。", + "Create your event": "建立您的活動", + "We couldn't open the payment page. Please try again later.": "無法開啟付款頁面,請稍後再試。", + "We couldn't open the billing portal. Please try again later.": "無法開啟帳單入口,請稍後再試。", + "This account is scheduled for deletion.": "此帳戶已安排刪除。", + "This account already has a subscription. Use Manage billing to change it.": "此帳戶已有訂閱。請使用「管理帳單」進行更改。" } diff --git a/backend/resources/views/vendor/mail/html/button.blade.php b/backend/resources/views/vendor/mail/html/button.blade.php index bbeeabe9f1..7eececa9e0 100644 --- a/backend/resources/views/vendor/mail/html/button.blade.php +++ b/backend/resources/views/vendor/mail/html/button.blade.php @@ -13,7 +13,7 @@ {{ $slot }} + rel="noopener"@if($color === 'primary' && ($mailBranding ?? null)?->buttonColor) style="background-color: {{ $mailBranding->buttonColor }}; color: {{ $mailBranding->buttonTextColor }}; box-shadow: none;"@endif>{{ $slot }} diff --git a/backend/resources/views/vendor/mail/html/header.blade.php b/backend/resources/views/vendor/mail/html/header.blade.php index 7de65ac147..3f8c6a5d0d 100644 --- a/backend/resources/views/vendor/mail/html/header.blade.php +++ b/backend/resources/views/vendor/mail/html/header.blade.php @@ -1,8 +1,12 @@ @props(['url']) +@if($url) {{ $slot }} +@else + {{ $slot }} +@endif diff --git a/backend/resources/views/vendor/mail/html/layout.blade.php b/backend/resources/views/vendor/mail/html/layout.blade.php index 692cf993b1..d8bf4b46de 100644 --- a/backend/resources/views/vendor/mail/html/layout.blade.php +++ b/backend/resources/views/vendor/mail/html/layout.blade.php @@ -34,6 +34,13 @@ } } +@if(($mailBranding ?? null)?->linkColor) + +@endif diff --git a/backend/resources/views/vendor/mail/html/message.blade.php b/backend/resources/views/vendor/mail/html/message.blade.php index b5dd57811b..b118169236 100644 --- a/backend/resources/views/vendor/mail/html/message.blade.php +++ b/backend/resources/views/vendor/mail/html/message.blade.php @@ -1,6 +1,16 @@ +@php($mailBranding ??= null) {{-- Header --}} +@if($mailBranding?->hideHiEventsBranding) + +@if($mailBranding->organizerLogoUrl) + +@else + {{ $mailBranding->organizerName }} +@endif + +@else @if($appLogo = config('app.email_logo_url')) @@ -8,6 +18,7 @@ @endif +@endif {{-- Body --}} @@ -24,11 +35,16 @@ {{-- Footer --}} +@if($mailBranding?->showHiEventsPromo) + +@endif @if($appEmailFooter = config('app.email_footer_text')) {{ $appEmailFooter }} @elseif(app(\HiEvents\Enterprise\Licensing\LicenceService::class)->allows(\HiEvents\Enterprise\Licensing\LicensedFeature::WHITE_LABEL)) © {{ date('Y') }} {{ config('app.name') }} + @elseif($mailBranding?->hideHiEventsBranding) + © {{ date('Y') }} {{ $mailBranding->organizerName }} @else {{-- (c) Hi.Events Ltd 2024-present --}} {{-- Hi.Events is licensed under the GNU Affero General Public License (AGPL) version 3. --}} diff --git a/backend/resources/views/vendor/mail/html/promo.blade.php b/backend/resources/views/vendor/mail/html/promo.blade.php new file mode 100644 index 0000000000..9c897a55c2 --- /dev/null +++ b/backend/resources/views/vendor/mail/html/promo.blade.php @@ -0,0 +1,14 @@ +@props(['url']) + + + + + + + + + diff --git a/backend/resources/views/vendor/mail/html/themes/default.css b/backend/resources/views/vendor/mail/html/themes/default.css index 5ad747f517..bb2f8db6df 100644 --- a/backend/resources/views/vendor/mail/html/themes/default.css +++ b/backend/resources/views/vendor/mail/html/themes/default.css @@ -238,6 +238,53 @@ img { text-decoration: underline; } +/* Promo */ + +.promo { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 600px; + margin: 0 auto; + padding: 0; + width: 600px; +} + +.promo-cell { + border-bottom: 1px solid #e6e1ee; + padding: 28px 32px 24px; + text-align: center; +} + +.promo-heading { + color: #2b2140; + font-size: 14px; + font-weight: 600; + line-height: 1.5; + margin: 0 0 4px; + text-align: center; +} + +.promo-text { + color: #6e6782; + font-size: 13px; + line-height: 1.6; + margin: 0 0 10px; + text-align: center; +} + +.promo-link { + color: #40296c; + font-size: 13px; + font-weight: 600; + text-decoration: none; +} + +.organizer-name { + color: #191029; + font-size: 20px; + font-weight: 700; +} + /* Tables */ .table table { diff --git a/backend/resources/views/vendor/mail/text/header.blade.php b/backend/resources/views/vendor/mail/text/header.blade.php index aaa3e57544..c4855b4e2c 100644 --- a/backend/resources/views/vendor/mail/text/header.blade.php +++ b/backend/resources/views/vendor/mail/text/header.blade.php @@ -1 +1,6 @@ +@props(['url']) +@if($url) [{{ $slot }}]({{ $url }}) +@else +{{ $slot }} +@endif diff --git a/backend/resources/views/vendor/mail/text/message.blade.php b/backend/resources/views/vendor/mail/text/message.blade.php index 80bce21126..349e01f317 100644 --- a/backend/resources/views/vendor/mail/text/message.blade.php +++ b/backend/resources/views/vendor/mail/text/message.blade.php @@ -1,9 +1,16 @@ +@php($mailBranding ??= null) {{-- Header --}} +@if($mailBranding?->hideHiEventsBranding) + + {{ $mailBranding->organizerName }} + +@else {{ config('app.name') }} +@endif {{-- Body --}} @@ -20,8 +27,11 @@ {{-- Footer --}} +@if($mailBranding?->showHiEventsPromo) + +@endif - © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + © {{ date('Y') }} {{ $mailBranding?->hideHiEventsBranding ? $mailBranding->organizerName : config('app.name') }}. @lang('All rights reserved.') diff --git a/backend/resources/views/vendor/mail/text/promo.blade.php b/backend/resources/views/vendor/mail/text/promo.blade.php new file mode 100644 index 0000000000..a685d3b54d --- /dev/null +++ b/backend/resources/views/vendor/mail/text/promo.blade.php @@ -0,0 +1,3 @@ +@props(['url']) +{{ __('Planning an event of your own?') }} {{ __('Sell tickets and manage attendees with :app_name. It\'s free to get started.', ['app_name' => config('app.name')]) }} +{{ __('Create your event') }}: {{ $url }} diff --git a/backend/routes/api.php b/backend/routes/api.php index ff99abf013..0ba6d80050 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -71,6 +71,9 @@ use HiEvents\Http\Actions\Accounts\DeletionRequest\GetAccountDeletionStatusAction; use HiEvents\Http\Actions\Accounts\DeletionRequest\RequestAccountDeletionAction; use HiEvents\Http\Actions\Accounts\GetAccountAction; +use HiEvents\Http\Actions\Accounts\Subscription\CreateSubscriptionCheckoutSessionAction; +use HiEvents\Http\Actions\Accounts\Subscription\CreateSubscriptionPortalSessionAction; +use HiEvents\Http\Actions\Accounts\Subscription\GetAccountSubscriptionAction; use HiEvents\Http\Actions\Accounts\UpdateAccountAction; use HiEvents\Http\Actions\Accounts\UpdateAccountTwoFactorRequirementAction; use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction; @@ -491,6 +494,9 @@ function (Router $router): void { $router->put('/accounts/{account_id}/sso/enforcement', UpdateSsoEnforcementAction::class)->whereNumber('account_id'); $router->put('/accounts/{account_id}/sso/members/{account_user_id}', UpdateSsoMemberEnforcementAction::class)->whereNumber(['account_id', 'account_user_id']); $router->put('/accounts/{account_id?}', UpdateAccountAction::class)->whereNumber('account_id'); + $router->get('/accounts/{account_id}/subscription', GetAccountSubscriptionAction::class)->whereNumber('account_id')->middleware('throttle:60,1,account-subscription'); + $router->post('/accounts/{account_id}/subscription/checkout-session', CreateSubscriptionCheckoutSessionAction::class)->whereNumber('account_id')->middleware('throttle:10,1,account-subscription-checkout'); + $router->post('/accounts/{account_id}/subscription/portal-session', CreateSubscriptionPortalSessionAction::class)->whereNumber('account_id')->middleware('throttle:20,1,account-subscription-portal'); $router->get('/organizers/{organizer_id}/team', GetOrganizerTeamAction::class); $router->get('/organizers/{organizerId}/stripe/connect_accounts', GetStripeConnectAccountsAction::class); $router->post('/organizers/{organizerId}/stripe/connect', CreateStripeConnectAccountAction::class); diff --git a/backend/tests/Feature/Http/Accounts/AccountSubscriptionTest.php b/backend/tests/Feature/Http/Accounts/AccountSubscriptionTest.php new file mode 100644 index 0000000000..e835194ecd --- /dev/null +++ b/backend/tests/Feature/Http/Accounts/AccountSubscriptionTest.php @@ -0,0 +1,311 @@ +stripeSubscriptions = Mockery::mock(SubscriptionService::class); + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('subscriptions')->andReturn($this->stripeSubscriptions); + $this->stripePrices = Mockery::mock(PriceService::class); + $this->stripePrices->shouldReceive('all')->byDefault()->andReturn(Collection::constructFrom(['object' => 'list', 'data' => [ + Price::constructFrom([ + 'id' => 'price_yearly', + 'lookup_key' => 'hi_events_pro_yearly', + 'unit_amount' => 29999, + 'currency' => 'eur', + 'currency_options' => ['eur' => ['unit_amount' => 29999], 'usd' => ['unit_amount' => 33599]], + ]), + Price::constructFrom([ + 'id' => 'price_monthly', + 'lookup_key' => 'hi_events_pro_monthly', + 'unit_amount' => 2999, + 'currency' => 'eur', + 'currency_options' => ['eur' => ['unit_amount' => 2999], 'usd' => ['unit_amount' => 3399]], + ]), + ]])); + $stripeClient->shouldReceive('getService')->with('prices')->andReturn($this->stripePrices); + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForBilling')->andReturn($stripeClient); + $this->app->instance(StripeClientFactory::class, $stripeClientFactory); + } + + public function test_subscriptions_are_unavailable_outside_hi_events(): void + { + config(['app.is_hi_events' => false]); + [$owner, $accountId] = $this->createAccountOwner(); + + $this->getJson("/accounts/$accountId/subscription", $this->headersFor($owner, $accountId)) + ->assertOk() + ->assertJsonPath('data.available', false) + ->assertJsonPath('data.active', false); + + $this->postJson("/accounts/$accountId/subscription/checkout-session", ['interval' => 'month'], $this->headersFor($owner, $accountId)) + ->assertForbidden() + ->assertJsonPath('error_code', 'FEATURE_UNAVAILABLE'); + + $this->postJson("/accounts/$accountId/subscription/portal-session", [], $this->headersFor($owner, $accountId)) + ->assertForbidden() + ->assertJsonPath('error_code', 'FEATURE_UNAVAILABLE'); + } + + public function test_owner_sees_the_subscription(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + DB::table('accounts')->where('id', $accountId)->update(['currency_code' => 'USD']); + $this->givenSubscription($accountId, AccountSubscriptionStatus::PAST_DUE, cancelAtPeriodEnd: true); + $this->stripeSubscriptions->shouldReceive('all')->andThrow(new ApiConnectionException('Stripe is unreachable')); + + $this->getJson("/accounts/$accountId/subscription?refresh=1", $this->headersFor($owner, $accountId)) + ->assertOk() + ->assertJsonPath('data.available', true) + ->assertJsonPath('data.active', true) + ->assertJsonPath('data.status', 'PAST_DUE') + ->assertJsonPath('data.cancel_at_period_end', true) + ->assertJsonPath('data.prices', [ + ['interval' => 'month', 'unit_amount' => 3399, 'currency' => 'USD'], + ['interval' => 'year', 'unit_amount' => 33599, 'currency' => 'USD'], + ]); + } + + public function test_billing_page_picks_up_a_subscription_the_webhook_never_delivered(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + DB::table('account_subscriptions')->insert([ + 'account_id' => $accountId, + 'stripe_customer_id' => 'cus_waiting', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->stripeSubscriptions->shouldReceive('all') + ->with(['customer' => 'cus_waiting', 'status' => 'all', 'limit' => 10]) + ->andReturn(Collection::constructFrom(['object' => 'list', 'data' => [ + Subscription::constructFrom([ + 'id' => 'sub_paid', + 'customer' => 'cus_waiting', + 'status' => 'active', + 'cancel_at_period_end' => false, + 'items' => ['object' => 'list', 'data' => [[ + 'id' => 'si_1', + 'current_period_end' => now()->addYear()->getTimestamp(), + ]]], + ]), + ]])); + + $this->getJson("/accounts/$accountId/subscription?refresh=1", $this->headersFor($owner, $accountId)) + ->assertOk() + ->assertJsonPath('data.active', true) + ->assertJsonPath('data.status', 'ACTIVE'); + + $this->assertDatabaseHas('account_subscriptions', [ + 'account_id' => $accountId, + 'stripe_subscription_id' => 'sub_paid', + 'status' => 'ACTIVE', + ]); + } + + public function test_reading_the_subscription_does_not_call_stripe_unless_asked(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + $this->givenSubscription($accountId, AccountSubscriptionStatus::ACTIVE); + + $this->stripeSubscriptions->shouldNotReceive('all'); + + $this->getJson("/accounts/$accountId/subscription", $this->headersFor($owner, $accountId)) + ->assertOk() + ->assertJsonPath('data.active', true); + } + + public function test_subscriptions_are_unavailable_until_stripe_has_prices(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + $this->stripePrices->shouldReceive('all')->andReturn(Collection::constructFrom(['object' => 'list', 'data' => []])); + + $this->getJson("/accounts/$accountId/subscription", $this->headersFor($owner, $accountId)) + ->assertOk() + ->assertJsonPath('data.available', false); + } + + public function test_a_subscriber_still_sees_their_subscription_when_stripe_prices_are_missing(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + $this->givenSubscription($accountId, AccountSubscriptionStatus::ACTIVE); + $this->stripePrices->shouldReceive('all')->andReturn(Collection::constructFrom(['object' => 'list', 'data' => []])); + + $this->getJson("/accounts/$accountId/subscription", $this->headersFor($owner, $accountId)) + ->assertOk() + ->assertJsonPath('data.available', true) + ->assertJsonPath('data.active', true) + ->assertJsonPath('data.prices', []); + } + + public function test_members_without_manage_account_can_see_but_not_change_billing(): void + { + config(['app.is_hi_events' => true]); + [, $accountId] = $this->createAccountOwner(); + $organizer = $this->addAccountUser($accountId, Role::ORGANIZER); + + $this->getJson("/accounts/$accountId/subscription", $this->headersFor($organizer, $accountId)) + ->assertOk() + ->assertJsonPath('data.available', true); + + $this->postJson("/accounts/$accountId/subscription/checkout-session", ['interval' => 'month'], $this->headersFor($organizer, $accountId)) + ->assertForbidden(); + + $this->postJson("/accounts/$accountId/subscription/portal-session", [], $this->headersFor($organizer, $accountId)) + ->assertForbidden(); + } + + public function test_cannot_read_another_accounts_subscription(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + [, $otherAccountId] = $this->createAccountOwner(); + + $this->getJson("/accounts/$otherAccountId/subscription", $this->headersFor($owner, $accountId)) + ->assertForbidden(); + } + + public function test_checkout_rejects_an_unknown_interval(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + + $this->postJson("/accounts/$accountId/subscription/checkout-session", ['interval' => 'week'], $this->headersFor($owner, $accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors('interval'); + } + + public function test_checkout_refuses_a_second_subscription(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + $this->givenSubscription($accountId, AccountSubscriptionStatus::ACTIVE); + $this->stripeSubscriptions->shouldReceive('all')->andReturn(Collection::constructFrom(['object' => 'list', 'data' => [ + Subscription::constructFrom(['id' => 'sub_live', 'status' => 'active', 'cancel_at_period_end' => false, 'items' => ['object' => 'list', 'data' => []]]), + ]])); + + $this->postJson("/accounts/$accountId/subscription/checkout-session", ['interval' => 'year'], $this->headersFor($owner, $accountId)) + ->assertStatus(409); + } + + public function test_portal_needs_a_subscription(): void + { + config(['app.is_hi_events' => true]); + [$owner, $accountId] = $this->createAccountOwner(); + + $this->postJson("/accounts/$accountId/subscription/portal-session", [], $this->headersFor($owner, $accountId)) + ->assertNotFound(); + } + + public function test_public_organizer_reports_whether_branding_is_removed(): void + { + config(['app.is_hi_events' => true]); + [, $accountId] = $this->createAccountOwner(); + $organizerId = $this->createOrganizer($accountId); + DB::table('organizers')->where('id', $organizerId)->update(['status' => OrganizerStatus::LIVE->name]); + + $this->getJson("/public/organizers/$organizerId") + ->assertOk() + ->assertJsonPath('data.branding_removed', false); + + $this->givenSubscription($accountId, AccountSubscriptionStatus::ACTIVE); + + $this->getJson("/public/organizers/$organizerId") + ->assertOk() + ->assertJsonPath('data.branding_removed', true); + } + + public function test_public_organizer_branding_is_never_removed_outside_hi_events(): void + { + config(['app.is_hi_events' => false]); + [, $accountId] = $this->createAccountOwner(); + $organizerId = $this->createOrganizer($accountId); + DB::table('organizers')->where('id', $organizerId)->update(['status' => OrganizerStatus::LIVE->name]); + $this->givenSubscription($accountId, AccountSubscriptionStatus::ACTIVE); + + $this->getJson("/public/organizers/$organizerId") + ->assertOk() + ->assertJsonPath('data.branding_removed', false); + } + + public function test_public_order_reports_whether_the_organizers_branding_is_removed(): void + { + config(['app.is_hi_events' => true]); + [, $accountId] = $this->createAccountOwner(); + $eventId = $this->createEvent($accountId, $this->createOrganizer($accountId)); + $shortId = 'o_'.uniqid(); + DB::table('orders')->insert([ + 'short_id' => $shortId, + 'event_id' => $eventId, + 'currency' => 'USD', + 'status' => 'COMPLETED', + 'public_id' => 'PUB_'.uniqid(), + 'first_name' => 'Ada', + 'last_name' => 'Buyer', + 'email' => 'ada@example.test', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->getJson("/public/events/$eventId/order/$shortId?include=event") + ->assertOk() + ->assertJsonPath('data.event.organizer.branding_removed', false); + + $this->givenSubscription($accountId, AccountSubscriptionStatus::ACTIVE); + + $this->getJson("/public/events/$eventId/order/$shortId?include=event") + ->assertOk() + ->assertJsonPath('data.event.organizer.branding_removed', true); + } + + private function givenSubscription(int $accountId, AccountSubscriptionStatus $status, bool $cancelAtPeriodEnd = false): void + { + DB::table('account_subscriptions')->insert([ + 'account_id' => $accountId, + 'stripe_customer_id' => 'cus_'.uniqid(), + 'stripe_subscription_id' => 'sub_'.uniqid(), + 'status' => $status->name, + 'current_period_end' => now()->addMonth(), + 'cancel_at_period_end' => $cancelAtPeriodEnd, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Feature/Http/Actions/Admin/AccountSuspensionTest.php b/backend/tests/Feature/Http/Actions/Admin/AccountSuspensionTest.php index 0af4f3d83b..eace14cbb9 100644 --- a/backend/tests/Feature/Http/Actions/Admin/AccountSuspensionTest.php +++ b/backend/tests/Feature/Http/Actions/Admin/AccountSuspensionTest.php @@ -5,12 +5,18 @@ use HiEvents\DomainObjects\Enums\Role; use HiEvents\Mail\Account\AccountSuspendedEmail; use HiEvents\Models\User; +use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use Illuminate\Testing\TestResponse; +use Mockery; use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth; +use Stripe\Collection; +use Stripe\Service\SubscriptionService; +use Stripe\StripeClient; +use Stripe\Subscription; use Tests\Concerns\ActsAsAccountUsers; use Tests\Concerns\UsesApiKeys; use Tests\TestCase; @@ -201,6 +207,34 @@ public function test_unsuspending_an_active_account_conflicts(): void ->assertStatus(409); } + public function test_suspending_stops_the_accounts_subscription_from_renewing(): void + { + DB::table('account_subscriptions')->insert([ + 'account_id' => $this->accountId, + 'stripe_customer_id' => 'cus_suspended', + 'stripe_subscription_id' => 'sub_live', + 'status' => 'ACTIVE', + 'cancel_at_period_end' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $stripeSubscriptions = Mockery::mock(SubscriptionService::class); + $stripeSubscriptions->shouldReceive('all')->andReturn(Collection::constructFrom(['object' => 'list', 'data' => [ + Subscription::constructFrom(['id' => 'sub_live', 'status' => 'active', 'cancel_at_period_end' => false]), + ]])); + $stripeSubscriptions->shouldReceive('update')->with('sub_live', ['cancel_at_period_end' => true])->once(); + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('subscriptions')->andReturn($stripeSubscriptions); + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForBilling')->andReturn($stripeClient); + $this->app->instance(StripeClientFactory::class, $stripeClientFactory); + + $this->suspend(['reason' => 'Chargebacks'])->assertOk(); + + $this->assertTrue((bool) DB::table('account_subscriptions')->where('account_id', $this->accountId)->value('cancel_at_period_end')); + } + private function suspend(array $payload = []): TestResponse { return $this->postJson("/admin/accounts/{$this->accountId}/suspension", $payload, $this->superadminHeaders()); diff --git a/backend/tests/Feature/Http/ApiKeys/ApiKeyRouteExposureTest.php b/backend/tests/Feature/Http/ApiKeys/ApiKeyRouteExposureTest.php index 5782a8084a..1bcf276ea8 100644 --- a/backend/tests/Feature/Http/ApiKeys/ApiKeyRouteExposureTest.php +++ b/backend/tests/Feature/Http/ApiKeys/ApiKeyRouteExposureTest.php @@ -30,6 +30,7 @@ class ApiKeyRouteExposureTest extends TestCase 'GET accounts/deletion-request', 'GET accounts/{account_id}/sso', 'GET accounts/{account_id}/sso/enforcement', + 'GET accounts/{account_id}/subscription', 'GET announcements/active', 'GET api-keys', 'GET api-reference', @@ -48,6 +49,8 @@ class ApiKeyRouteExposureTest extends TestCase 'POST accounts/{account_id}/sso/connection/test', 'POST accounts/{account_id}/sso/domains', 'POST accounts/{account_id}/sso/domains/{domain_id}/verify', + 'POST accounts/{account_id}/subscription/checkout-session', + 'POST accounts/{account_id}/subscription/portal-session', 'POST announcements/{announcement_id}/dismiss', 'POST api-keys', 'POST auth/invitation/{invite_token}/join', diff --git a/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php b/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php index afe3534f59..6a41593c0c 100644 --- a/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php +++ b/backend/tests/Unit/Mail/Attendee/AttendeeTicketMailOccurrenceDatesTest.php @@ -16,6 +16,13 @@ class AttendeeTicketMailOccurrenceDatesTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + config(['app.is_hi_events' => false]); + } + public function test_an_occurrence_without_an_end_shows_only_its_own_date(): void { $firstDate = $this->occurrence(1, '2026-11-02 19:00:00', null); diff --git a/backend/tests/Unit/Mail/Attendee/GroupedAttendeeTicketMailTest.php b/backend/tests/Unit/Mail/Attendee/GroupedAttendeeTicketMailTest.php index 55c1383ca1..3144b35782 100644 --- a/backend/tests/Unit/Mail/Attendee/GroupedAttendeeTicketMailTest.php +++ b/backend/tests/Unit/Mail/Attendee/GroupedAttendeeTicketMailTest.php @@ -32,6 +32,8 @@ protected function setUp(): void { parent::setUp(); + config(['app.is_hi_events' => false]); + $this->firstDate = (new EventOccurrenceDomainObject) ->setId(1) ->setStartDate('2026-11-05 18:00:00') diff --git a/backend/tests/Unit/Mail/MailBrandingLayoutTest.php b/backend/tests/Unit/Mail/MailBrandingLayoutTest.php new file mode 100644 index 0000000000..a41a36610a --- /dev/null +++ b/backend/tests/Unit/Mail/MailBrandingLayoutTest.php @@ -0,0 +1,200 @@ +useLicenceKey(null); + + $this->app->instance(EmailDeliveryRecorder::class, Mockery::mock(EmailDeliveryRecorder::class)->shouldIgnoreMissing()); + } + + public function test_the_promo_sits_above_the_powered_by_footer(): void + { + $html = $this->render(new MailBrandingDTO( + hideHiEventsBranding: false, + showHiEventsPromo: true, + promoUrl: self::PROMO_URL, + )); + + $this->assertStringContainsString('Planning an event of your own?', $html); + $this->assertStringContainsString('Sell tickets and manage attendees with '.config('app.name').'.', $html); + $this->assertStringContainsString('href="'.self::PROMO_URL.'"', $html); + $this->assertStringContainsString('Powered by', $html); + $this->assertStringContainsString('hi-events-stacked-light.png', $html); + } + + public function test_without_the_promo_only_the_powered_by_footer_shows(): void + { + $html = $this->render(new MailBrandingDTO( + hideHiEventsBranding: false, + showHiEventsPromo: false, + promoUrl: self::PROMO_URL, + )); + + $this->assertStringNotContainsString('Planning an event of your own?', $html); + $this->assertStringContainsString('Powered by', $html); + } + + public function test_subscribed_organizers_replace_hi_events_branding_with_their_own(): void + { + $html = $this->render(new MailBrandingDTO( + hideHiEventsBranding: true, + showHiEventsPromo: false, + promoUrl: self::PROMO_URL, + organizerName: 'Acme Events', + organizerLogoUrl: 'https://cdn.test/acme-logo.png', + organizerUrl: 'https://acme.test', + )); + + $this->assertStringContainsString('src="https://cdn.test/acme-logo.png"', $html); + $this->assertStringContainsString('href="https://acme.test"', $html); + $this->assertStringContainsString('© '.date('Y').' Acme Events', $html); + $this->assertStringNotContainsString('hi-events-stacked-light.png', $html); + $this->assertStringNotContainsString('Powered by', $html); + $this->assertStringNotContainsString('Planning an event of your own?', $html); + } + + public function test_subscribed_organizers_without_a_logo_or_website_show_their_name_unlinked(): void + { + $html = $this->render(new MailBrandingDTO( + hideHiEventsBranding: true, + showHiEventsPromo: false, + promoUrl: self::PROMO_URL, + organizerName: 'Acme Events', + )); + + $this->assertMatchesRegularExpression('/]*>\s*]*>Acme Events<\/span>/', $html); + } + + public function test_subscribers_send_under_the_organizer_name_in_their_brand_colour(): void + { + $message = $this->send(new MailBrandingDTO( + hideHiEventsBranding: true, + showHiEventsPromo: false, + promoUrl: self::PROMO_URL, + organizerName: 'Acme Events', + senderName: 'Acme Events', + buttonColor: '#1b36f5', + buttonTextColor: '#ffffff', + linkColor: '#1b36f5', + )); + + $from = $message->getOriginalMessage()->getFrom()[0]; + $html = $message->getOriginalMessage()->getHtmlBody(); + + $this->assertSame('Acme Events', $from->getName()); + $this->assertSame(config('mail.from.address'), $from->getAddress()); + $this->assertStringContainsString('background-color: #1b36f5; color: #ffffff; box-shadow: none;', $html); + $this->assertMatchesRegularExpression('/]*style="[^"]*color: #1b36f5/', $html); + } + + public function test_a_sender_set_by_the_mailable_is_never_replaced(): void + { + $message = $this->send(new MailBrandingDTO( + hideHiEventsBranding: true, + showHiEventsPromo: false, + promoUrl: self::PROMO_URL, + organizerName: 'Acme Events', + senderName: 'Acme Events', + ), fn ($mail) => $mail->from('team@example.test', 'Example Team')); + + $from = $message->getOriginalMessage()->getFrom()[0]; + + $this->assertSame('Example Team', $from->getName()); + $this->assertSame('team@example.test', $from->getAddress()); + } + + public function test_without_a_sender_name_the_default_sender_is_kept(): void + { + $message = $this->send(new MailBrandingDTO( + hideHiEventsBranding: false, + showHiEventsPromo: true, + promoUrl: self::PROMO_URL, + )); + + $this->assertSame(config('mail.from.name'), $message->getOriginalMessage()->getFrom()[0]->getName()); + $this->assertStringNotContainsString('.content-cell a:not(.button)', $message->getOriginalMessage()->getHtmlBody()); + } + + public function test_branding_does_not_leak_into_the_next_mail(): void + { + $this->render(new MailBrandingDTO( + hideHiEventsBranding: true, + showHiEventsPromo: false, + promoUrl: self::PROMO_URL, + organizerName: 'Acme Events', + )); + + $this->assertNull(view()->shared('mailBranding')); + } + + private function render(MailBrandingDTO $branding): string + { + return $this->send($branding)->getOriginalMessage()->getHtmlBody(); + } + + private function send(MailBrandingDTO $branding, ?Closure $prepare = null): SentMessage + { + $brandingService = Mockery::mock(MailBrandingService::class); + $brandingService->shouldReceive('forMail')->andReturn($branding); + $this->app->instance(MailBrandingService::class, $brandingService); + + $policy = Mockery::mock(UnpublishedEventMailPolicy::class); + $policy->shouldReceive('shouldWithholdEventContent')->andReturnFalse(); + $this->app->instance(UnpublishedEventMailPolicy::class, $policy); + + $mail = new OrderSummary( + order: (new OrderDomainObject) + ->setId(30) + ->setShortId('o_short') + ->setPublicId('ORD-1') + ->setTotalGross(0) + ->setStatus(OrderStatus::COMPLETED->name) + ->setOrderItems(collect()), + event: (new EventDomainObject) + ->setId(10) + ->setTitle('Jazz Nights') + ->setTimezone('UTC') + ->setCurrency('USD') + ->setEventOccurrences(collect([ + (new EventOccurrenceDomainObject)->setId(2)->setStartDate('2026-11-12 18:00:00'), + ])), + organizer: (new OrganizerDomainObject)->setName('Acme Events')->setEmail('organizer@acme.test'), + eventSettings: (new EventSettingDomainObject)->setSupportEmail('support@acme.test'), + invoice: null, + ); + + if ($prepare !== null) { + $prepare($mail); + } + + return Mail::mailer('array')->to('buyer@example.test')->sendNow($mail); + } +} diff --git a/backend/tests/Unit/Mail/Order/OrderSummaryTest.php b/backend/tests/Unit/Mail/Order/OrderSummaryTest.php index 7f1c578cd5..fb68c52d01 100644 --- a/backend/tests/Unit/Mail/Order/OrderSummaryTest.php +++ b/backend/tests/Unit/Mail/Order/OrderSummaryTest.php @@ -14,6 +14,13 @@ class OrderSummaryTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + config(['app.is_hi_events' => false]); + } + public function test_shows_the_orders_date_rather_than_the_events_first_date(): void { $html = $this->mailFor([ diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandlerTest.php new file mode 100644 index 0000000000..f331173894 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionCheckoutSessionHandlerTest.php @@ -0,0 +1,270 @@ +accessService = Mockery::mock(AccountSubscriptionAccessService::class); + $this->accessService->shouldReceive('assertSubscriptionsAvailable')->byDefault(); + $this->syncService = Mockery::mock(AccountSubscriptionSyncService::class); + $this->subscriptionRepository = Mockery::mock(AccountSubscriptionRepositoryInterface::class); + $this->deletionService = Mockery::mock(AccountDeletionService::class); + $this->deletionService->shouldReceive('isAccountPendingDeletion')->andReturnFalse()->byDefault(); + $this->priceService = Mockery::mock(SubscriptionPriceService::class); + $this->customers = Mockery::mock(CustomerService::class); + $this->sessions = Mockery::mock(SessionService::class); + + $accountRepository = Mockery::mock(AccountRepositoryInterface::class); + $accountRepository->shouldReceive('findById')->with(1)->andReturn( + (new AccountDomainObject)->setId(1)->setName('Acme Events')->setEmail('owner@acme.test')->setCurrencyCode('USD') + ); + + $checkout = new CheckoutServiceFactory(null); + (fn () => $this->services['sessions'] = func_get_arg(0))->call($checkout, $this->sessions); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('customers')->andReturn($this->customers); + $stripeClient->shouldReceive('getService')->with('checkout')->andReturn($checkout); + + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForBilling')->andReturn($stripeClient); + + $this->handler = new CreateSubscriptionCheckoutSessionHandler( + $this->accessService, + $this->syncService, + $this->subscriptionRepository, + $accountRepository, + $this->deletionService, + $stripeClientFactory, + $this->priceService, + ); + } + + public function test_creates_a_customer_and_a_checkout_session_in_the_accounts_currency(): void + { + $this->syncService->shouldReceive('refreshForAccount')->with(1)->andReturnNull(); + $this->subscriptionRepository->shouldReceive('findFirstWhere')->with(['account_id' => 1])->andReturnNull(); + $this->customers->shouldReceive('create') + ->once() + ->with( + ['email' => 'owner@acme.test', 'name' => 'Acme Events', 'metadata' => ['account_id' => '1']], + ['idempotency_key' => 'account-subscription-customer-1'], + ) + ->andReturn(Customer::constructFrom(['id' => 'cus_new'])); + $this->subscriptionRepository->shouldReceive('create') + ->once() + ->with(['account_id' => 1, 'stripe_customer_id' => 'cus_new']); + $this->givenCustomerCurrency('cus_new', null); + $this->givenPrices('USD'); + + $this->sessions->shouldReceive('create') + ->once() + ->withArgs(fn (array $params) => $params['mode'] === 'subscription' + && $params['customer'] === 'cus_new' + && $params['currency'] === 'usd' + && $params['line_items'] === [['price' => 'price_yearly', 'quantity' => 1]] + && $params['automatic_tax'] === ['enabled' => true] + && str_ends_with($params['success_url'], '/settings/billing?checkout=success')) + ->andReturn(Session::constructFrom(['url' => 'https://checkout.stripe.com/c/cs_1'])); + + $this->assertSame( + 'https://checkout.stripe.com/c/cs_1', + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::YEAR)), + ); + } + + public function test_a_returning_customer_is_charged_in_the_currency_stripe_locked_them_to(): void + { + $this->syncService->shouldReceive('refreshForAccount')->andReturn($this->accountSubscription(AccountSubscriptionStatus::CANCELED)); + $this->subscriptionRepository->shouldReceive('findFirstWhere')->andReturn($this->accountSubscription(AccountSubscriptionStatus::CANCELED)); + $this->customers->shouldNotReceive('create'); + $this->givenCustomerCurrency('cus_existing', 'eur'); + $this->givenPrices('EUR'); + + $this->sessions->shouldReceive('create') + ->once() + ->withArgs(fn (array $params) => $params['customer'] === 'cus_existing' && $params['currency'] === 'eur') + ->andReturn(Session::constructFrom(['url' => 'https://checkout.stripe.com/c/cs_1'])); + + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::YEAR)); + } + + public function test_a_concurrent_first_checkout_reuses_the_customer_the_other_request_stored(): void + { + $this->syncService->shouldReceive('refreshForAccount')->andReturnNull(); + $this->subscriptionRepository->shouldReceive('findFirstWhere') + ->andReturn(null, (new AccountSubscriptionDomainObject)->setStripeCustomerId('cus_winner')); + $this->customers->shouldReceive('create')->andReturn(Customer::constructFrom(['id' => 'cus_winner'])); + $this->subscriptionRepository->shouldReceive('create') + ->andThrow(new UniqueConstraintViolationException('pgsql', 'insert', [], new Exception('duplicate key'))); + $this->givenCustomerCurrency('cus_winner', null); + $this->givenPrices('USD'); + + $this->sessions->shouldReceive('create') + ->once() + ->withArgs(fn (array $params) => $params['customer'] === 'cus_winner') + ->andReturn(Session::constructFrom(['url' => 'https://checkout.stripe.com/c/cs_1'])); + + $this->assertSame( + 'https://checkout.stripe.com/c/cs_1', + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::YEAR)), + ); + } + + public function test_a_customer_deleted_in_stripe_is_replaced_instead_of_blocking_checkout(): void + { + $this->syncService->shouldReceive('refreshForAccount') + ->andThrow(InvalidRequestException::factory('No such customer', 404, null, null, null, 'resource_missing')); + $this->subscriptionRepository->shouldReceive('deleteWhere')->with(['account_id' => 1])->once(); + $this->subscriptionRepository->shouldReceive('findFirstWhere')->andReturnNull(); + $this->customers->shouldReceive('create')->once()->andReturn(Customer::constructFrom(['id' => 'cus_fresh'])); + $this->subscriptionRepository->shouldReceive('create')->once(); + $this->givenCustomerCurrency('cus_fresh', null); + $this->givenPrices('USD'); + + $this->sessions->shouldReceive('create') + ->once() + ->withArgs(fn (array $params) => $params['customer'] === 'cus_fresh') + ->andReturn(Session::constructFrom(['url' => 'https://checkout.stripe.com/c/cs_1'])); + + $this->assertSame( + 'https://checkout.stripe.com/c/cs_1', + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::YEAR)), + ); + } + + #[DataProvider('openStatuses')] + public function test_refuses_while_a_subscription_is_still_open(AccountSubscriptionStatus $status): void + { + $this->syncService->shouldReceive('refreshForAccount')->with(1)->andReturn($this->accountSubscription($status)); + + $this->sessions->shouldNotReceive('create'); + + $this->expectException(ResourceConflictException::class); + + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::MONTH)); + } + + public static function openStatuses(): array + { + return [ + 'active' => [AccountSubscriptionStatus::ACTIVE], + 'payment still processing' => [AccountSubscriptionStatus::INCOMPLETE], + 'unpaid' => [AccountSubscriptionStatus::UNPAID], + 'past due' => [AccountSubscriptionStatus::PAST_DUE], + ]; + } + + public function test_refuses_while_the_account_is_pending_deletion(): void + { + $this->deletionService->shouldReceive('isAccountPendingDeletion')->with(1)->andReturnTrue(); + + $this->syncService->shouldNotReceive('refreshForAccount'); + $this->sessions->shouldNotReceive('create'); + + $this->expectException(ResourceConflictException::class); + + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::MONTH)); + } + + public function test_refuses_off_cloud(): void + { + $this->accessService->shouldReceive('assertSubscriptionsAvailable')->andThrow(new FeatureNotEnabledException); + + $this->sessions->shouldNotReceive('create'); + + $this->expectException(FeatureNotEnabledException::class); + + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::MONTH)); + } + + public function test_fails_clearly_when_the_interval_has_no_price(): void + { + $this->syncService->shouldReceive('refreshForAccount')->andReturnNull(); + $this->subscriptionRepository->shouldReceive('findFirstWhere') + ->andReturn((new AccountSubscriptionDomainObject)->setStripeCustomerId('cus_existing')); + $this->givenCustomerCurrency('cus_existing', null); + $this->priceService->shouldReceive('getPrices')->andReturn([]); + + $this->sessions->shouldNotReceive('create'); + + $this->expectException(StripeClientConfigurationException::class); + + $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(1, SubscriptionBillingInterval::MONTH)); + } + + private function givenCustomerCurrency(string $customerId, ?string $currency): void + { + $this->customers->shouldReceive('retrieve') + ->with($customerId) + ->andReturn(Customer::constructFrom(['id' => $customerId, 'currency' => $currency])); + } + + private function givenPrices(string $currency): void + { + $this->priceService->shouldReceive('getPrices')->with($currency)->andReturn([ + new SubscriptionPriceDTO(SubscriptionBillingInterval::MONTH, 'price_monthly', 2999, $currency), + new SubscriptionPriceDTO(SubscriptionBillingInterval::YEAR, 'price_yearly', 29999, $currency), + ]); + } + + private function accountSubscription(AccountSubscriptionStatus $status): AccountSubscriptionDomainObject + { + return (new AccountSubscriptionDomainObject) + ->setAccountId(1) + ->setStripeCustomerId('cus_existing') + ->setStripeSubscriptionId('sub_1') + ->setStatus($status->name); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandlerTest.php new file mode 100644 index 0000000000..9dc31ca87e --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Account/Subscription/CreateSubscriptionPortalSessionHandlerTest.php @@ -0,0 +1,76 @@ +repository = Mockery::mock(AccountSubscriptionRepositoryInterface::class); + $this->sessions = Mockery::mock(SessionService::class); + + $billingPortal = new BillingPortalServiceFactory(null); + (fn () => $this->services['sessions'] = func_get_arg(0))->call($billingPortal, $this->sessions); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('billingPortal')->andReturn($billingPortal); + + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForBilling')->andReturn($stripeClient); + + $accessService = Mockery::mock(AccountSubscriptionAccessService::class); + $accessService->shouldReceive('assertSubscriptionsAvailable'); + + $this->handler = new CreateSubscriptionPortalSessionHandler($accessService, $this->repository, $stripeClientFactory); + } + + public function test_opens_the_portal_for_the_accounts_customer(): void + { + $this->repository->shouldReceive('findFirstWhere')->with(['account_id' => 1])->andReturn( + (new AccountSubscriptionDomainObject)->setStripeCustomerId('cus_1')->setStripeSubscriptionId('sub_1') + ); + + $this->sessions->shouldReceive('create') + ->once() + ->withArgs(fn (array $params) => $params['customer'] === 'cus_1' + && str_ends_with($params['return_url'], '/settings/billing')) + ->andReturn(Session::constructFrom(['url' => 'https://billing.stripe.com/p/session_1'])); + + $this->assertSame('https://billing.stripe.com/p/session_1', $this->handler->handle(1)); + } + + public function test_accounts_that_never_subscribed_have_no_portal(): void + { + $this->repository->shouldReceive('findFirstWhere')->andReturn( + (new AccountSubscriptionDomainObject)->setStripeCustomerId('cus_1')->setStripeSubscriptionId(null) + ); + + $this->sessions->shouldNotReceive('create'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle(1); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php index 0ec98a179d..ecf7246879 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php @@ -8,6 +8,7 @@ use HiEvents\DomainObjects\EventOccurrenceDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; +use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; @@ -24,6 +25,7 @@ use HiEvents\Services\Domain\EventOccurrence\PublicOccurrenceVisibilityService; use HiEvents\Services\Domain\Product\ProductFilterService; use HiEvents\Services\Domain\Product\PublicProductSelectionFilter; +use HiEvents\Services\Domain\Subscription\AccountSubscriptionAccessService; use Illuminate\Support\Collection; use Mockery as m; use Tests\TestCase; @@ -42,6 +44,8 @@ class GetPublicEventHandlerTest extends TestCase private GetPublicEventHandler $handler; + private AccountSubscriptionAccessService $accountSubscriptionAccessService; + protected function setUp(): void { parent::setUp(); @@ -51,6 +55,7 @@ protected function setUp(): void $this->promoCodeRepository = m::mock(PromoCodeRepositoryInterface::class); $this->ticketFilterService = m::mock(ProductFilterService::class); $this->eventPageViewIncrementService = m::mock(EventPageViewIncrementService::class); + $this->accountSubscriptionAccessService = m::mock(AccountSubscriptionAccessService::class); $this->handler = new GetPublicEventHandler( $this->eventRepository, @@ -61,9 +66,29 @@ protected function setUp(): void new PublicOccurrenceVisibilityService, m::mock(EventSeatMapLookupService::class, ['existsForEvent' => false]), new PublicProductSelectionFilter, + $this->accountSubscriptionAccessService, ); } + public function test_marks_the_organizer_branding_as_removed_when_the_account_has_an_active_subscription(): void + { + $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null); + $organizer = (new OrganizerDomainObject)->setId(3)->setAccountId(7); + $event = (new EventDomainObject) + ->setAccountId(7) + ->setOrganizer($organizer) + ->setProductCategories(collect()); + + $this->setupEventRepositoryMock($event, $data->eventId); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); + $this->accountSubscriptionAccessService->shouldReceive('hasActiveSubscription')->with(7)->once()->andReturnTrue(); + + $result = $this->handler->handle($data); + + $this->assertTrue($result->getOrganizer()->getBrandingRemoved()); + } + public function test_handle_without_promo_code_and_unauthenticated_user(): void { $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: null); diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookSubscriptionEventsTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookSubscriptionEventsTest.php new file mode 100644 index 0000000000..6737626f7b --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookSubscriptionEventsTest.php @@ -0,0 +1,113 @@ + 'whsec_connect_test', + 'services.stripe.ca_webhook_secret' => null, + 'services.stripe.ie_webhook_secret' => null, + 'services.stripe.billing_webhook_secret' => self::BILLING_SECRET, + 'services.stripe.primary_platform' => null, + ]); + + $this->syncService = Mockery::mock(AccountSubscriptionSyncService::class); + + $this->handler = new IncomingWebhookHandler( + $this->unused(ChargeRefundUpdatedHandler::class), + $this->unused(ChargeSucceededHandler::class), + $this->unused(PaymentIntentSucceededHandler::class), + $this->unused(PaymentIntentFailedHandler::class), + $this->unused(AccountUpdateHandler::class), + $this->unused(PayoutPaidHandler::class), + $this->unused(TerminalReaderActionFailedHandler::class), + new CustomerSubscriptionUpdatedHandler($this->syncService), + Mockery::mock(Logger::class)->shouldIgnoreMissing(), + new Repository(new ArrayStore), + new StripeConfigurationService, + ); + } + + #[DataProvider('subscriptionEvents')] + public function test_subscription_events_signed_by_the_billing_endpoint_sync_the_customer(string $type): void + { + $this->syncService->shouldReceive('syncCustomer')->with('cus_1')->once(); + + $this->handler->handle($this->signedEvent($type)); + } + + public static function subscriptionEvents(): array + { + return [ + 'created' => ['customer.subscription.created'], + 'updated' => ['customer.subscription.updated'], + 'deleted' => ['customer.subscription.deleted'], + ]; + } + + public function test_subscription_events_from_connected_accounts_are_ignored(): void + { + $this->syncService->shouldNotReceive('syncCustomer'); + + $this->handler->handle($this->signedEvent('customer.subscription.updated', connectedAccount: 'acct_organizer')); + } + + /** + * @template T of object + * + * @param class-string $class + * @return T + */ + private function unused(string $class): object + { + return (new ReflectionClass($class))->newInstanceWithoutConstructor(); + } + + private function signedEvent(string $type, ?string $connectedAccount = null): StripeWebhookDTO + { + $payload = json_encode([ + 'id' => 'evt_'.uniqid(), + 'object' => 'event', + 'type' => $type, + 'account' => $connectedAccount, + 'created' => time(), + 'data' => ['object' => ['id' => 'sub_1', 'object' => 'subscription', 'customer' => 'cus_1', 'status' => 'active']], + ]); + $timestamp = time(); + $signature = hash_hmac('sha256', $timestamp.'.'.$payload, self::BILLING_SECRET); + + return new StripeWebhookDTO(headerSignature: "t=$timestamp,v1=$signature", payload: $payload); + } +} diff --git a/backend/tests/Unit/Services/Domain/Account/AccountDeletionServiceTest.php b/backend/tests/Unit/Services/Domain/Account/AccountDeletionServiceTest.php index 474321ec9e..1e0b07b7bc 100644 --- a/backend/tests/Unit/Services/Domain/Account/AccountDeletionServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Account/AccountDeletionServiceTest.php @@ -24,12 +24,14 @@ use HiEvents\Services\Domain\Account\AccountAnonymizationService; use HiEvents\Services\Domain\Account\AccountDeletionService; use HiEvents\Services\Domain\Account\AccountHardDeletionService; +use HiEvents\Services\Domain\Subscription\AccountSubscriptionCancellationService; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Mail; use Mockery; use Mockery\MockInterface; use Psr\Log\NullLogger; +use RuntimeException; use Tests\TestCase; class AccountDeletionServiceTest extends TestCase @@ -50,6 +52,8 @@ class AccountDeletionServiceTest extends TestCase private AccountUserRepositoryInterface|MockInterface $accountUserRepository; + private AccountSubscriptionCancellationService|MockInterface $subscriptionCancellationService; + protected function setUp(): void { parent::setUp(); @@ -63,6 +67,7 @@ protected function setUp(): void $this->anonymizationService = Mockery::mock(AccountAnonymizationService::class); $this->hardDeletionService = Mockery::mock(AccountHardDeletionService::class); $this->accountUserRepository = Mockery::mock(AccountUserRepositoryInterface::class); + $this->subscriptionCancellationService = Mockery::mock(AccountSubscriptionCancellationService::class); $databaseManager = Mockery::mock(DatabaseManager::class); $databaseManager->shouldReceive('transaction') @@ -78,6 +83,7 @@ protected function setUp(): void $this->accountUserRepository, $databaseManager, new NullLogger, + $this->subscriptionCancellationService, ); } @@ -171,12 +177,33 @@ public function test_request_deletion_creates_request_and_unpublishes_events(): ) ->andReturn(1); + $this->subscriptionCancellationService->shouldReceive('tryCancelAtPeriodEnd')->with(1)->once(); + $result = $this->service->requestDeletion(1, 5, AccountDeletionInitiator::ACCOUNT_OWNER); $this->assertInstanceOf(AccountDeletionRequestDomainObject::class, $result); Mail::assertQueued(AccountDeletionRequestedEmail::class); } + public function test_execute_deletion_stops_when_subscription_cannot_be_cancelled(): void + { + $this->deletionRequestRepository->shouldReceive('findById') + ->with(10) + ->andReturn($this->makeDeletionRequest()); + $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($this->makeAccount()); + $this->orderRepository->shouldReceive('accountHasCompletedOrders')->with(1)->andReturnFalse(); + + $this->subscriptionCancellationService->shouldReceive('cancelImmediately') + ->with(1) + ->andThrow(new RuntimeException('Stripe is down')); + $this->hardDeletionService->shouldNotReceive('deleteAccount'); + $this->deletionRequestRepository->shouldNotReceive('updateFromArray'); + + $this->expectException(RuntimeException::class); + + $this->service->executeDeletion(10); + } + public function test_immediate_deletion_queues_execution_without_grace_period_email(): void { Bus::fake(); @@ -192,6 +219,8 @@ public function test_immediate_deletion_queues_execution_without_grace_period_em ->withArgs(fn (array $attributes) => $attributes['scheduled_deletion_at']->diffInMinutes(now(), true) < 1) ->andReturn($this->makeDeletionRequest()); + $this->subscriptionCancellationService->shouldNotReceive('tryCancelAtPeriodEnd'); + $result = $this->service->requestDeletion(1, 5, AccountDeletionInitiator::ADMIN, executeImmediately: true); Mail::assertNotQueued(AccountDeletionRequestedEmail::class); @@ -258,6 +287,7 @@ public function test_execute_deletion_skips_inactive_requests(): void $this->hardDeletionService->shouldNotReceive('deleteAccount'); $this->anonymizationService->shouldNotReceive('anonymizeAccount'); + $this->subscriptionCancellationService->shouldNotReceive('cancelImmediately'); $this->service->executeDeletion(10); @@ -272,7 +302,8 @@ public function test_execute_deletion_hard_deletes_when_no_completed_orders(): v $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($this->makeAccount()); $this->orderRepository->shouldReceive('accountHasCompletedOrders')->with(1)->andReturnFalse(); - $this->hardDeletionService->shouldReceive('deleteAccount')->with(1)->once()->andReturn(['accounts' => 1]); + $this->subscriptionCancellationService->shouldReceive('cancelImmediately')->with(1)->once()->globally()->ordered(); + $this->hardDeletionService->shouldReceive('deleteAccount')->with(1)->once()->globally()->ordered()->andReturn(['accounts' => 1]); $this->anonymizationService->shouldNotReceive('anonymizeAccount'); $this->deletionRequestRepository->shouldReceive('updateFromArray') @@ -298,7 +329,8 @@ public function test_execute_deletion_anonymizes_when_completed_orders_exist(): $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($this->makeAccount()); $this->orderRepository->shouldReceive('accountHasCompletedOrders')->with(1)->andReturnTrue(); - $this->anonymizationService->shouldReceive('anonymizeAccount')->with(1)->once()->andReturn([]); + $this->subscriptionCancellationService->shouldReceive('cancelImmediately')->with(1)->once()->globally()->ordered(); + $this->anonymizationService->shouldReceive('anonymizeAccount')->with(1)->once()->globally()->ordered()->andReturn([]); $this->hardDeletionService->shouldNotReceive('deleteAccount'); $this->deletionRequestRepository->shouldReceive('updateFromArray') diff --git a/backend/tests/Unit/Services/Domain/Mail/EmailAccentColorServiceTest.php b/backend/tests/Unit/Services/Domain/Mail/EmailAccentColorServiceTest.php new file mode 100644 index 0000000000..35091af57e --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Mail/EmailAccentColorServiceTest.php @@ -0,0 +1,80 @@ +service = new EmailAccentColorService; + } + + public function test_a_dark_accent_gets_white_button_text_and_is_used_for_links(): void + { + $accent = $this->service->fromAccent('#1B36F5'); + + $this->assertSame('#1b36f5', $accent->buttonColor); + $this->assertSame('#ffffff', $accent->buttonTextColor); + $this->assertSame('#1b36f5', $accent->linkColor); + } + + public function test_a_mid_tone_accent_styles_the_button_but_not_body_links(): void + { + $accent = $this->service->fromAccent('#ff5a36'); + + $this->assertSame('#ff5a36', $accent->buttonColor); + $this->assertSame('#111111', $accent->buttonTextColor); + $this->assertNull($accent->linkColor); + } + + #[DataProvider('nearWhiteAccents')] + public function test_accents_too_close_to_white_are_ignored(string $accent): void + { + $this->assertNull($this->service->fromAccent($accent)); + } + + public static function nearWhiteAccents(): array + { + return [ + 'white' => ['#ffffff'], + 'lavender wash' => ['#f5f3ff'], + 'neon yellow' => ['#D0FF14'], + ]; + } + + public function test_short_hex_is_expanded(): void + { + $this->assertSame('#ff6600', $this->service->fromAccent('#f60')->buttonColor); + $this->assertSame('#ff6600', $this->service->fromAccent('#f60a')->buttonColor); + } + + public function test_the_alpha_channel_is_dropped(): void + { + $this->assertSame('#7c3aed', $this->service->fromAccent('#7c3aedff')->buttonColor); + } + + #[DataProvider('invalidAccents')] + public function test_invalid_accents_are_ignored(?string $accent): void + { + $this->assertNull($this->service->fromAccent($accent)); + } + + public static function invalidAccents(): array + { + return [ + 'missing' => [null], + 'five digits' => ['#12345'], + 'css injection' => ['#000000; background: url(x)'], + 'trailing newline' => ["#000000\n"], + 'named colour' => ['red'], + ]; + } +} diff --git a/backend/tests/Unit/Services/Domain/Mail/MailBrandingServiceTest.php b/backend/tests/Unit/Services/Domain/Mail/MailBrandingServiceTest.php new file mode 100644 index 0000000000..620a7139cb --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Mail/MailBrandingServiceTest.php @@ -0,0 +1,253 @@ +accessService = Mockery::mock(AccountSubscriptionAccessService::class); + $this->organizerRepository = Mockery::mock(OrganizerRepositoryInterface::class); + config(['app.cdn_url' => 'https://cdn.test', 'app.frontend_url' => 'https://tickets.example.test', 'app.name' => 'Hi.Events']); + + $this->accessService->shouldReceive('areSubscriptionsAvailable')->andReturnTrue()->byDefault(); + + $this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class); + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturnNull()->byDefault(); + + $this->service = new MailBrandingService( + $this->accessService, + $this->organizerRepository, + $this->eventSettingsRepository, + new EmailAccentColorService, + ); + } + + public function test_order_and_ticket_mail_carry_the_promo_for_unsubscribed_accounts(): void + { + $this->givenSubscription(false); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertTrue($branding->showHiEventsPromo); + $this->assertFalse($branding->hideHiEventsBranding); + $this->assertNull($branding->organizerName); + $this->assertSame( + 'https://tickets.example.test/auth/register?utm_source=email-promo&utm_medium=email&utm_campaign=create-your-event', + $branding->promoUrl, + ); + } + + public function test_other_event_mail_has_no_promo(): void + { + $this->givenSubscription(false); + + $branding = $this->service->forMail(new OrderSummaryForOrganizer($this->order(), $this->event())); + + $this->assertFalse($branding->showHiEventsPromo); + } + + public function test_account_mail_has_no_promo_and_never_checks_a_subscription(): void + { + $this->accessService->shouldNotReceive('hasActiveSubscription'); + + $branding = $this->service->forMail(new UnpublishedEventUpdateMail); + + $this->assertFalse($branding->showHiEventsPromo); + $this->assertFalse($branding->hideHiEventsBranding); + } + + public function test_a_subscriber_whose_organizer_is_gone_keeps_the_default_header_without_the_promo(): void + { + $this->givenSubscription(true); + $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->organizerRepository->shouldReceive('findFirstWhere')->andReturnNull(); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertFalse($branding->hideHiEventsBranding); + $this->assertFalse($branding->showHiEventsPromo); + $this->assertNull($branding->organizerName); + } + + public function test_installs_other_than_hi_events_never_show_the_promo_or_check_subscriptions(): void + { + $this->accessService->shouldReceive('areSubscriptionsAvailable')->andReturnFalse(); + $this->accessService->shouldNotReceive('hasActiveSubscription'); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertFalse($branding->showHiEventsPromo); + $this->assertFalse($branding->hideHiEventsBranding); + } + + public function test_subscribed_accounts_get_the_organizer_branding_and_no_promo(): void + { + $this->givenSubscription(true); + + $this->organizerRepository->shouldReceive('loadRelation')->with(ImageDomainObject::class)->andReturnSelf(); + $this->organizerRepository->shouldReceive('findFirstWhere') + ->with(['id' => 4, 'account_id' => 7]) + ->andReturn((new OrganizerDomainObject) + ->setId(4) + ->setName('Acme Events') + ->setWebsite('https://acme.test') + ->setImages(collect([ + (new ImageDomainObject)->setType(ImageType::ORGANIZER_COVER->name)->setPath('cover.jpg'), + (new ImageDomainObject)->setType(ImageType::ORGANIZER_LOGO->name)->setPath('logo.png'), + ]))); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertTrue($branding->hideHiEventsBranding); + $this->assertFalse($branding->showHiEventsPromo); + $this->assertSame('Acme Events', $branding->organizerName); + $this->assertSame('https://cdn.test/logo.png', $branding->organizerLogoUrl); + $this->assertSame('https://acme.test', $branding->organizerUrl); + } + + public function test_buyer_emails_from_subscribers_use_the_organizer_name_and_event_accent(): void + { + $this->givenSubscription(true); + $this->givenOrganizer("Acme\r\nEvents"); + $this->eventSettingsRepository->shouldReceive('findFirstWhere') + ->with(['event_id' => 10]) + ->andReturn((new EventSettingDomainObject)->setHomepageThemeSettings(['accent' => '#1B36F5'])); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertSame('Acme Events', $branding->senderName); + $this->assertSame('#1b36f5', $branding->buttonColor); + $this->assertSame('#ffffff', $branding->buttonTextColor); + $this->assertSame('#1b36f5', $branding->linkColor); + } + + public function test_the_organizers_own_order_notification_keeps_the_default_sender(): void + { + $this->givenSubscription(true); + $this->givenOrganizer('Acme Events'); + + $branding = $this->service->forMail(new OrderSummaryForOrganizer($this->order(), $this->event())); + + $this->assertTrue($branding->hideHiEventsBranding); + $this->assertNull($branding->senderName); + } + + public function test_events_without_an_accent_keep_the_default_colours(): void + { + $this->givenSubscription(true); + $this->givenOrganizer('Acme Events'); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertNull($branding->buttonColor); + $this->assertNull($branding->linkColor); + } + + #[DataProvider('impersonatingNames')] + public function test_names_that_could_impersonate_a_sender_fall_back_to_the_default(string $name): void + { + $this->givenSubscription(true); + $this->givenOrganizer($name); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertTrue($branding->hideHiEventsBranding); + $this->assertNull($branding->senderName); + } + + public static function impersonatingNames(): array + { + return [ + 'email address' => ['support@paypal.com'], + 'platform name' => ['Hi.Events Support'], + 'platform name respelled' => ['hi events tickets'], + 'bidi override' => ["Acme\u{202E}stnevE"], + 'nothing left after cleaning' => ["\r\n\t"], + ]; + } + + public function test_long_sender_names_are_truncated(): void + { + $this->givenSubscription(true); + $this->givenOrganizer(str_repeat('a', 100)); + + $this->assertSame(70, mb_strlen($this->service->forMail($this->orderSummary())->senderName)); + } + + private function givenOrganizer(string $name): void + { + $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->organizerRepository->shouldReceive('findFirstWhere') + ->andReturn((new OrganizerDomainObject)->setId(4)->setName($name)->setImages(collect())); + } + + public function test_subscribed_organizers_without_a_logo_fall_back_to_their_name(): void + { + $this->givenSubscription(true); + + $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->organizerRepository->shouldReceive('findFirstWhere') + ->andReturn((new OrganizerDomainObject)->setId(4)->setName('Acme Events')->setImages(collect())); + + $branding = $this->service->forMail($this->orderSummary()); + + $this->assertSame('Acme Events', $branding->organizerName); + $this->assertNull($branding->organizerLogoUrl); + } + + private function givenSubscription(bool $active): void + { + $this->accessService->shouldReceive('hasActiveSubscription')->with(7)->andReturn($active); + } + + private function orderSummary(): OrderSummary + { + return new OrderSummary( + order: $this->order(), + event: $this->event(), + organizer: (new OrganizerDomainObject)->setId(4)->setName('Acme Events'), + eventSettings: new EventSettingDomainObject, + invoice: null, + ); + } + + private function event(): EventDomainObject + { + return (new EventDomainObject)->setId(10)->setAccountId(7)->setOrganizerId(4)->setTitle('Jazz Nights'); + } + + private function order(): OrderDomainObject + { + return (new OrderDomainObject)->setId(30)->setTotalGross(0); + } +} diff --git a/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionAccessServiceTest.php b/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionAccessServiceTest.php new file mode 100644 index 0000000000..196bd142d2 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionAccessServiceTest.php @@ -0,0 +1,79 @@ +repository = Mockery::mock(AccountSubscriptionRepositoryInterface::class); + $this->config = new Repository(['app' => ['is_hi_events' => true]]); + $this->service = new AccountSubscriptionAccessService($this->repository, $this->config); + } + + public function test_subscriptions_are_never_active_outside_hi_events(): void + { + $this->config->set('app.is_hi_events', false); + $this->repository->shouldNotReceive('findFirstWhere'); + + $this->assertFalse($this->service->hasActiveSubscription(1)); + } + + public function test_asserting_availability_outside_hi_events_throws(): void + { + $this->config->set('app.is_hi_events', false); + + $this->expectException(FeatureNotEnabledException::class); + + $this->service->assertSubscriptionsAvailable(); + } + + public function test_an_account_without_a_subscription_has_no_access(): void + { + $this->repository->shouldReceive('findFirstWhere')->with(['account_id' => 1])->andReturnNull(); + + $this->assertFalse($this->service->hasActiveSubscription(1)); + } + + #[DataProvider('statuses')] + public function test_access_follows_the_subscription_status(AccountSubscriptionStatus $status, bool $expected): void + { + $this->repository->shouldReceive('findFirstWhere') + ->with(['account_id' => 1]) + ->andReturn((new AccountSubscriptionDomainObject)->setStatus($status->name)); + + $this->assertSame($expected, $this->service->hasActiveSubscription(1)); + } + + public static function statuses(): array + { + return [ + 'active' => [AccountSubscriptionStatus::ACTIVE, true], + 'trialing' => [AccountSubscriptionStatus::TRIALING, true], + 'past due during retries' => [AccountSubscriptionStatus::PAST_DUE, true], + 'incomplete first payment' => [AccountSubscriptionStatus::INCOMPLETE, false], + 'canceled' => [AccountSubscriptionStatus::CANCELED, false], + 'unpaid' => [AccountSubscriptionStatus::UNPAID, false], + 'paused' => [AccountSubscriptionStatus::PAUSED, false], + ]; + } +} diff --git a/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionCancellationServiceTest.php b/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionCancellationServiceTest.php new file mode 100644 index 0000000000..20ec46951d --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionCancellationServiceTest.php @@ -0,0 +1,168 @@ +repository = Mockery::mock(AccountSubscriptionRepositoryInterface::class); + $this->stripeSubscriptions = Mockery::mock(SubscriptionService::class); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('subscriptions')->andReturn($this->stripeSubscriptions); + + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForBilling')->andReturn($stripeClient); + + $this->service = new AccountSubscriptionCancellationService($this->repository, $stripeClientFactory, new NullLogger); + } + + public function test_cancel_at_period_end_stops_every_renewing_subscription(): void + { + $this->givenAccountSubscription(AccountSubscriptionStatus::ACTIVE); + $this->givenStripeSubscriptions([ + $this->subscription('sub_1', 'active'), + $this->subscription('sub_2', 'past_due'), + $this->subscription('sub_ending', 'active', cancelAtPeriodEnd: true), + $this->subscription('sub_old', 'canceled'), + ]); + + $this->stripeSubscriptions->shouldReceive('update')->with('sub_1', ['cancel_at_period_end' => true])->once(); + $this->stripeSubscriptions->shouldReceive('update')->with('sub_2', ['cancel_at_period_end' => true])->once(); + $this->repository->shouldReceive('updateFromArray')->with(10, ['cancel_at_period_end' => true])->once(); + + $this->service->cancelAtPeriodEnd(1); + } + + public function test_trying_to_stop_renewal_swallows_stripe_errors(): void + { + $this->givenAccountSubscription(AccountSubscriptionStatus::ACTIVE); + $this->stripeSubscriptions->shouldReceive('all')->andThrow(new ApiConnectionException('Stripe is unreachable')); + + $this->repository->shouldNotReceive('updateFromArray'); + + $this->service->tryCancelAtPeriodEnd(1); + } + + public function test_cancel_immediately_cancels_every_open_subscription(): void + { + $this->givenAccountSubscription(AccountSubscriptionStatus::ACTIVE); + $this->givenStripeSubscriptions([ + $this->subscription('sub_1', 'active'), + $this->subscription('sub_2', 'incomplete'), + $this->subscription('sub_old', 'canceled'), + ]); + + $this->stripeSubscriptions->shouldReceive('cancel')->with('sub_1')->once(); + $this->stripeSubscriptions->shouldReceive('cancel')->with('sub_2')->once(); + $this->repository->shouldReceive('updateFromArray') + ->with(10, ['status' => AccountSubscriptionStatus::CANCELED->name]) + ->once(); + + $this->service->cancelImmediately(1); + } + + public function test_a_customer_deleted_in_stripe_does_not_block_cancellation(): void + { + $this->givenAccountSubscription(AccountSubscriptionStatus::ACTIVE); + $this->stripeSubscriptions->shouldReceive('all') + ->andThrow(InvalidRequestException::factory('No such customer', 404, null, null, null, 'resource_missing')); + + $this->stripeSubscriptions->shouldNotReceive('cancel'); + $this->repository->shouldReceive('updateFromArray') + ->with(10, ['status' => AccountSubscriptionStatus::CANCELED->name]) + ->once(); + + $this->service->cancelImmediately(1); + } + + public function test_other_stripe_errors_still_fail_the_cancellation(): void + { + $this->givenAccountSubscription(AccountSubscriptionStatus::ACTIVE); + $this->stripeSubscriptions->shouldReceive('all') + ->andThrow(InvalidRequestException::factory('Invalid API key', 400)); + + $this->repository->shouldNotReceive('updateFromArray'); + + $this->expectException(InvalidRequestException::class); + + $this->service->cancelImmediately(1); + } + + public function test_nothing_happens_for_accounts_that_never_subscribed(): void + { + $this->repository->shouldReceive('findFirstWhere')->andReturnNull(); + + $this->stripeSubscriptions->shouldNotReceive('all'); + + $this->service->cancelImmediately(1); + $this->service->cancelAtPeriodEnd(1); + } + + public function test_ended_subscriptions_are_left_alone(): void + { + $this->givenAccountSubscription(AccountSubscriptionStatus::CANCELED); + $this->givenStripeSubscriptions([$this->subscription('sub_old', 'canceled')]); + + $this->stripeSubscriptions->shouldNotReceive('cancel'); + $this->stripeSubscriptions->shouldNotReceive('update'); + $this->repository->shouldNotReceive('updateFromArray'); + + $this->service->cancelImmediately(1); + $this->service->cancelAtPeriodEnd(1); + } + + private function givenAccountSubscription(AccountSubscriptionStatus $status): void + { + $this->repository->shouldReceive('findFirstWhere') + ->with(['account_id' => 1]) + ->andReturn((new AccountSubscriptionDomainObject) + ->setId(10) + ->setAccountId(1) + ->setStripeCustomerId('cus_1') + ->setStripeSubscriptionId('sub_1') + ->setStatus($status->name) + ->setCancelAtPeriodEnd(false)); + } + + private function givenStripeSubscriptions(array $subscriptions): void + { + $this->stripeSubscriptions->shouldReceive('all') + ->with(['customer' => 'cus_1', 'status' => 'all', 'limit' => 100]) + ->andReturn(Collection::constructFrom(['object' => 'list', 'data' => $subscriptions])); + } + + private function subscription(string $id, string $status, bool $cancelAtPeriodEnd = false): Subscription + { + return Subscription::constructFrom([ + 'id' => $id, + 'status' => $status, + 'cancel_at_period_end' => $cancelAtPeriodEnd, + ]); + } +} diff --git a/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionSyncServiceTest.php b/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionSyncServiceTest.php new file mode 100644 index 0000000000..fed447dbd7 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Subscription/AccountSubscriptionSyncServiceTest.php @@ -0,0 +1,178 @@ +repository = Mockery::mock(AccountSubscriptionRepositoryInterface::class); + $this->stripeSubscriptions = Mockery::mock(SubscriptionService::class); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('subscriptions')->andReturn($this->stripeSubscriptions); + + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForBilling')->andReturn($stripeClient); + + $this->service = new AccountSubscriptionSyncService($this->repository, $stripeClientFactory, new NullLogger); + } + + public function test_webhooks_for_unknown_customers_are_ignored(): void + { + $this->repository->shouldReceive('findFirstWhere')->with(['stripe_customer_id' => 'cus_unknown'])->andReturnNull(); + + $this->stripeSubscriptions->shouldNotReceive('all'); + $this->repository->shouldNotReceive('updateFromArray'); + + $this->service->syncCustomer('cus_unknown'); + } + + public function test_webhooks_store_the_customers_current_state_from_stripe(): void + { + $this->repository->shouldReceive('findFirstWhere') + ->with(['stripe_customer_id' => 'cus_1']) + ->andReturn($this->accountSubscription(subscriptionId: null, status: null)); + $this->givenCustomerSubscriptions([$this->stripeSubscription('sub_1', 'active', cancelAtPeriodEnd: true)]); + + $this->repository->shouldReceive('updateFromArray') + ->once() + ->withArgs(fn (int $id, array $attributes) => $id === 10 + && $attributes['stripe_subscription_id'] === 'sub_1' + && $attributes['status'] === AccountSubscriptionStatus::ACTIVE->name + && $attributes['current_period_end']->getTimestamp() === 1_800_000_000 + && $attributes['cancel_at_period_end'] === true) + ->andReturn($this->accountSubscription(subscriptionId: 'sub_1', status: AccountSubscriptionStatus::ACTIVE)); + + $this->service->syncCustomer('cus_1'); + } + + public function test_refresh_does_nothing_for_accounts_that_never_started_checkout(): void + { + $this->repository->shouldReceive('findFirstWhere')->with(['account_id' => 1])->andReturnNull(); + + $this->stripeSubscriptions->shouldNotReceive('all'); + + $this->assertNull($this->service->refreshForAccount(1)); + } + + public function test_refresh_keeps_the_row_when_checkout_was_abandoned(): void + { + $accountSubscription = $this->accountSubscription(subscriptionId: null, status: null); + $this->repository->shouldReceive('findFirstWhere')->with(['account_id' => 1])->andReturn($accountSubscription); + $this->givenCustomerSubscriptions([]); + + $this->repository->shouldNotReceive('updateFromArray'); + + $this->assertSame($accountSubscription, $this->service->refreshForAccount(1)); + } + + public function test_a_subscription_that_grants_access_wins_over_newer_ended_ones(): void + { + $this->repository->shouldReceive('findFirstWhere') + ->andReturn($this->accountSubscription(subscriptionId: 'sub_live', status: AccountSubscriptionStatus::ACTIVE)); + $this->givenCustomerSubscriptions([ + $this->stripeSubscription('sub_abandoned', 'incomplete_expired'), + $this->stripeSubscription('sub_live', 'active'), + $this->stripeSubscription('sub_old', 'canceled'), + ]); + + $this->repository->shouldReceive('updateFromArray') + ->once() + ->withArgs(fn (int $id, array $attributes) => $attributes['stripe_subscription_id'] === 'sub_live') + ->andReturn($this->accountSubscription(subscriptionId: 'sub_live', status: AccountSubscriptionStatus::ACTIVE)); + + $this->service->refreshForAccount(1); + } + + public function test_an_older_unpaid_subscription_is_tracked_over_a_newer_ended_one(): void + { + $this->repository->shouldReceive('findFirstWhere') + ->andReturn($this->accountSubscription(subscriptionId: null, status: null)); + $this->givenCustomerSubscriptions([ + $this->stripeSubscription('sub_new', 'canceled'), + $this->stripeSubscription('sub_unpaid', 'unpaid'), + ]); + + $this->repository->shouldReceive('updateFromArray') + ->once() + ->withArgs(fn (int $id, array $attributes) => $attributes['stripe_subscription_id'] === 'sub_unpaid' + && $attributes['status'] === AccountSubscriptionStatus::UNPAID->name) + ->andReturn($this->accountSubscription(subscriptionId: 'sub_unpaid', status: AccountSubscriptionStatus::UNPAID)); + + $this->assertTrue($this->service->refreshForAccount(1)->hasOpenSubscription()); + } + + public function test_a_cancellation_revokes_access(): void + { + $this->repository->shouldReceive('findFirstWhere') + ->andReturn($this->accountSubscription(subscriptionId: 'sub_1', status: AccountSubscriptionStatus::ACTIVE)); + $this->givenCustomerSubscriptions([$this->stripeSubscription('sub_1', 'canceled')]); + + $this->repository->shouldReceive('updateFromArray') + ->once() + ->withArgs(fn (int $id, array $attributes) => $attributes['status'] === AccountSubscriptionStatus::CANCELED->name) + ->andReturn($this->accountSubscription(subscriptionId: 'sub_1', status: AccountSubscriptionStatus::CANCELED)); + + $this->assertFalse($this->service->refreshForAccount(1)->grantsAccess()); + } + + private function givenCustomerSubscriptions(array $subscriptions): void + { + $this->stripeSubscriptions->shouldReceive('all') + ->with(['customer' => 'cus_1', 'status' => 'all', 'limit' => 10]) + ->andReturn(Collection::constructFrom(['object' => 'list', 'data' => $subscriptions])); + } + + private function accountSubscription(?string $subscriptionId, ?AccountSubscriptionStatus $status): AccountSubscriptionDomainObject + { + return (new AccountSubscriptionDomainObject) + ->setId(10) + ->setAccountId(1) + ->setStripeCustomerId('cus_1') + ->setStripeSubscriptionId($subscriptionId) + ->setStatus($status?->name) + ->setCancelAtPeriodEnd(false); + } + + private function stripeSubscription(string $id, string $status, bool $cancelAtPeriodEnd = false): Subscription + { + return Subscription::constructFrom([ + 'id' => $id, + 'object' => 'subscription', + 'customer' => 'cus_1', + 'status' => $status, + 'cancel_at_period_end' => $cancelAtPeriodEnd, + 'items' => [ + 'object' => 'list', + 'data' => [[ + 'id' => 'si_1', + 'object' => 'subscription_item', + 'current_period_end' => 1_800_000_000, + ]], + ], + ]); + } +} diff --git a/backend/tests/Unit/Services/Domain/Subscription/SubscriptionPriceServiceTest.php b/backend/tests/Unit/Services/Domain/Subscription/SubscriptionPriceServiceTest.php new file mode 100644 index 0000000000..315af50016 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Subscription/SubscriptionPriceServiceTest.php @@ -0,0 +1,150 @@ +stripePrices = Mockery::mock(PriceService::class); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->shouldReceive('getService')->with('prices')->andReturn($this->stripePrices); + + $stripeClientFactory = Mockery::mock(StripeClientFactory::class); + $stripeClientFactory->shouldReceive('createForBilling')->andReturn($stripeClient); + + $this->service = new SubscriptionPriceService($stripeClientFactory, new Repository(new ArrayStore), new NullLogger); + } + + public function test_prices_are_shown_in_the_accounts_currency_when_stripe_offers_it(): void + { + $this->givenCatalog(); + + $this->assertSame( + [[SubscriptionBillingInterval::MONTH, 3399, 'USD'], [SubscriptionBillingInterval::YEAR, 33599, 'USD']], + $this->summarise($this->service->getPrices('usd')), + ); + } + + public function test_prices_fall_back_to_the_default_currency(): void + { + $this->givenCatalog(); + + $this->assertSame( + [[SubscriptionBillingInterval::MONTH, 2999, 'EUR'], [SubscriptionBillingInterval::YEAR, 29999, 'EUR']], + $this->summarise($this->service->getPrices('CAD')), + ); + } + + public function test_a_currency_only_some_prices_offer_falls_back_to_the_default(): void + { + $this->stripePrices->shouldReceive('all')->andReturn($this->prices([ + ['hi_events_pro_monthly', 2999, ['gbp' => 2499]], + ['hi_events_pro_yearly', 29999, []], + ])); + + $this->assertSame('EUR', $this->service->getPrices('GBP')[0]->currency); + } + + public function test_prices_carry_the_stripe_price_ids(): void + { + $this->givenCatalog(); + + $this->assertSame( + ['price_hi_events_pro_monthly', 'price_hi_events_pro_yearly'], + array_map(fn ($price) => $price->priceId, $this->service->getPrices('GBP')), + ); + } + + public function test_prices_with_mismatched_default_currencies_skip_the_one_that_cannot_be_shown(): void + { + $this->stripePrices->shouldReceive('all')->andReturn(Collection::constructFrom([ + 'object' => 'list', + 'data' => [ + Price::constructFrom(['id' => 'price_m', 'lookup_key' => 'hi_events_pro_monthly', 'unit_amount' => 2999, 'currency' => 'eur']), + Price::constructFrom(['id' => 'price_y', 'lookup_key' => 'hi_events_pro_yearly', 'unit_amount' => 33599, 'currency' => 'usd']), + ], + ])); + + $this->assertSame( + [[SubscriptionBillingInterval::MONTH, 2999, 'EUR']], + $this->summarise($this->service->getPrices('GBP')), + ); + } + + public function test_the_catalog_is_cached_between_calls(): void + { + $this->givenCatalog(); + + $this->service->getPrices('EUR'); + $this->service->getPrices('USD'); + + $this->stripePrices->shouldHaveReceived('all')->once(); + } + + public function test_returns_no_prices_when_stripe_is_unavailable_without_retrying_on_every_request(): void + { + $this->stripePrices->shouldReceive('all') + ->once() + ->andThrow(new ApiConnectionException('Stripe is unreachable')); + + $this->assertSame([], $this->service->getPrices('EUR')); + $this->assertSame([], $this->service->getPrices('EUR')); + } + + private function givenCatalog(): void + { + $this->stripePrices->shouldReceive('all') + ->with([ + 'lookup_keys' => ['hi_events_pro_monthly', 'hi_events_pro_yearly'], + 'active' => true, + 'expand' => ['data.currency_options'], + ]) + ->andReturn($this->prices([ + ['hi_events_pro_yearly', 29999, ['usd' => 33599, 'gbp' => 25099]], + ['hi_events_pro_monthly', 2999, ['usd' => 3399, 'gbp' => 2499]], + ])); + } + + private function prices(array $prices): Collection + { + return Collection::constructFrom([ + 'object' => 'list', + 'data' => array_map(fn (array $price) => Price::constructFrom([ + 'id' => 'price_'.$price[0], + 'lookup_key' => $price[0], + 'unit_amount' => $price[1], + 'currency' => 'eur', + 'currency_options' => array_map(fn (int $amount) => ['unit_amount' => $amount], $price[2]) + + ['eur' => ['unit_amount' => $price[1]]], + ]), $prices), + ]); + } + + private function summarise(array $prices): array + { + return array_map(fn ($price) => [$price->interval, $price->unitAmount, $price->currency], $prices); + } +} diff --git a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php index 7e083ae3e1..60287aa19a 100644 --- a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php +++ b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php @@ -91,6 +91,16 @@ public function test_get_all_webhook_secrets_includes_all_platforms(): void $this->assertEquals('whsec_ireland', $result['ie']); } + public function test_get_all_webhook_secrets_includes_the_billing_endpoint(): void + { + config([ + 'services.stripe.webhook_secret' => 'whsec_default', + 'services.stripe.billing_webhook_secret' => 'whsec_billing', + ]); + + $this->assertEquals('whsec_billing', $this->service->getAllWebhookSecrets()['billing']); + } + public function test_get_primary_platform_returns_correct_enum(): void { config(['services.stripe.primary_platform' => 'ie']); @@ -124,6 +134,7 @@ public function test_get_all_webhook_secrets_returns_filtered_secrets(): void 'services.stripe.webhook_secret' => 'whsec_default', 'services.stripe.ca_webhook_secret' => 'whsec_canada', 'services.stripe.ie_webhook_secret' => null, + 'services.stripe.billing_webhook_secret' => null, ]); $result = $this->service->getAllWebhookSecrets(); diff --git a/frontend/src/api/account.client.ts b/frontend/src/api/account.client.ts index e7b32a0cae..17e90cc6e5 100644 --- a/frontend/src/api/account.client.ts +++ b/frontend/src/api/account.client.ts @@ -1,5 +1,14 @@ import {api} from "./client.ts"; -import {Account, AccountDeletionRequest, AccountDeletionStatus, GenericDataResponse, IdParam, User} from "../types.ts"; +import { + Account, + AccountDeletionRequest, + AccountDeletionStatus, + AccountSubscription, + GenericDataResponse, + IdParam, + SubscriptionBillingInterval, + User, +} from "../types.ts"; interface CreateAccountRequest { first_name: string; @@ -39,4 +48,18 @@ export const accountClient = { const response = await api.delete>('accounts/deletion-request'); return response.data; }, + getSubscription: async (accountId: IdParam, refreshFromStripe: boolean) => { + const response = await api.get>(`accounts/${accountId}/subscription`, { + params: refreshFromStripe ? {refresh: 1} : undefined, + }); + return response.data; + }, + createSubscriptionCheckoutSession: async (accountId: IdParam, interval: SubscriptionBillingInterval) => { + const response = await api.post<{ url: string }>(`accounts/${accountId}/subscription/checkout-session`, {interval}); + return response.data; + }, + createSubscriptionPortalSession: async (accountId: IdParam) => { + const response = await api.post<{ url: string }>(`accounts/${accountId}/subscription/portal-session`); + return response.data; + }, } diff --git a/frontend/src/components/common/AttendeeTicket/index.tsx b/frontend/src/components/common/AttendeeTicket/index.tsx index a98305d95c..7df7d17ac5 100644 --- a/frontend/src/components/common/AttendeeTicket/index.tsx +++ b/frontend/src/components/common/AttendeeTicket/index.tsx @@ -198,7 +198,7 @@ export const AttendeeTicket = ({ {showPoweredBy && (
- +
)} diff --git a/frontend/src/components/common/BrandingUpsell/BrandingAnimation.module.scss b/frontend/src/components/common/BrandingUpsell/BrandingAnimation.module.scss new file mode 100644 index 0000000000..c0adbbd62c --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/BrandingAnimation.module.scss @@ -0,0 +1,135 @@ +$cycle: 7s; + +.stage { + padding: 14px 14px 8px; + border-radius: 12px; + background: color-mix(in srgb, var(--hi-primary) 5%, var(--hi-color-white)); +} + +.animation { + display: block; + width: 100%; + max-width: 400px; + margin: 0 auto; + height: auto; + font-family: inherit; + + g { + transform-box: fill-box; + transform-origin: center; + } +} + +.surface { + fill: var(--hi-color-white); + stroke: var(--hi-color-border); +} + +.shadow { + fill: rgba(16, 24, 40, 0.06); +} + +.cover { + fill: color-mix(in srgb, var(--hi-primary) 14%, white); +} + +.inner { + fill: color-mix(in srgb, var(--hi-primary) 4%, white); +} + +.lineStrong { + fill: color-mix(in srgb, var(--hi-text) 70%, white); +} + +.line { + fill: color-mix(in srgb, var(--hi-text) 18%, white); +} + +.link { + fill: color-mix(in srgb, var(--hi-primary) 60%, white); +} + +.button { + fill: var(--hi-primary); +} + +.hairline { + stroke: var(--hi-color-border); +} + +.footerText { + font-size: 9px; + font-weight: 500; + fill: var(--hi-color-text-muted); +} + +.wordmark { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + fill: var(--hi-primary); +} + +.logoMark { + fill: var(--hi-text); +} + +.logoLetter { + font-size: 13px; + font-weight: 700; + fill: var(--hi-color-white); +} + +.pageFooter { + animation: pageFooter $cycle ease-in-out infinite; +} + +.hiLogo { + animation: hiLogo $cycle ease-in-out infinite; +} + +.organizerLogo { + animation: organizerLogo $cycle ease-in-out infinite; +} + +.promo { + animation: promo $cycle ease-in-out infinite; +} + +@keyframes pageFooter { + 0%, 26% { opacity: 1; transform: translateY(0); } + 34%, 90% { opacity: 0; transform: translateY(5px); } + 100% { opacity: 1; transform: translateY(0); } +} + +@keyframes hiLogo { + 0%, 38% { opacity: 1; transform: scale(1); } + 46%, 90% { opacity: 0; transform: scale(0.85); } + 100% { opacity: 1; transform: scale(1); } +} + +@keyframes organizerLogo { + 0%, 42% { opacity: 0; transform: scale(0.7); } + 52%, 90% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(0.7); } +} + +@keyframes promo { + 0%, 50% { opacity: 1; transform: translateY(0); } + 60%, 90% { opacity: 0; transform: translateY(5px); } + 100% { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + .pageFooter, + .hiLogo, + .promo { + animation: none; + opacity: 0; + } + + .organizerLogo { + animation: none; + opacity: 1; + } +} diff --git a/frontend/src/components/common/BrandingUpsell/BrandingAnimation.tsx b/frontend/src/components/common/BrandingUpsell/BrandingAnimation.tsx new file mode 100644 index 0000000000..e18ca5b765 --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/BrandingAnimation.tsx @@ -0,0 +1,55 @@ +import {t} from "@lingui/macro"; +import classNames from "classnames"; +import classes from "./BrandingAnimation.module.scss"; + +interface BrandingAnimationProps { + className?: string; +} + +export const BrandingAnimation = ({className}: BrandingAnimationProps) => ( +
+ + + + + + + + + + + + + {t`Powered by`} Hi.Events + + + + + + + + HI.EVENTS + + + + A + + + + + + + + + + + + + +
+); diff --git a/frontend/src/components/common/BrandingUpsell/BrandingDesignerSection.tsx b/frontend/src/components/common/BrandingUpsell/BrandingDesignerSection.tsx new file mode 100644 index 0000000000..09981420d2 --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/BrandingDesignerSection.tsx @@ -0,0 +1,46 @@ +import {useState} from "react"; +import {Anchor, Switch} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {IconEyeOff} from "@tabler/icons-react"; +import {NavLink, useLocation} from "react-router"; +import {DesignerSection} from "../DesignerShell"; +import {useBrandingSubscription} from "../../../hooks/useBrandingSubscription.ts"; +import {BrandingUpgradeModal} from "./BrandingUpgradeModal.tsx"; + +export const BrandingDesignerSection = () => { + const location = useLocation(); + const {isAvailable, isActive, canManageBilling} = useBrandingSubscription(); + const [modalOpen, setModalOpen] = useState(false); + + if (!isAvailable) { + return null; + } + + const description = isActive ? ( + <> + {t`Included in your plan. Hi.Events branding is hidden on your event pages, widgets, tickets and emails.`} + {canManageBilling && ( + <> + {' '} + + {t`Manage billing`} + + + )} + + ) : t`Remove "Powered by Hi.Events" from your pages, widgets, tickets and emails, and show your own logo instead.`; + + return ( + } title={t`Branding`}> + setModalOpen(true)} + /> + setModalOpen(false)}/> + + ); +}; diff --git a/frontend/src/components/common/BrandingUpsell/BrandingUpgradeModal.tsx b/frontend/src/components/common/BrandingUpsell/BrandingUpgradeModal.tsx new file mode 100644 index 0000000000..c09196fd6e --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/BrandingUpgradeModal.tsx @@ -0,0 +1,51 @@ +import {useState} from "react"; +import {Button} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {Modal, ModalFooter} from "../Modal"; +import {SubscriptionBillingInterval} from "../../../types.ts"; +import {useBrandingSubscription} from "../../../hooks/useBrandingSubscription.ts"; +import {useSubscriptionRedirect} from "../../../hooks/useSubscriptionRedirect.ts"; +import {BrandingAnimation} from "./BrandingAnimation.tsx"; +import {IncludedFeatures} from "./IncludedFeatures.tsx"; +import {defaultBillingInterval, PlanIntervalPicker} from "./PlanIntervalPicker.tsx"; +import classes from "./BrandingUpsell.module.scss"; + +interface BrandingUpgradeModalProps { + opened: boolean; + onClose: () => void; +} + +export const BrandingUpgradeModal = ({opened, onClose}: BrandingUpgradeModalProps) => { + const {account, canManageBilling, prices} = useBrandingSubscription(); + const [billingInterval, setBillingInterval] = useState(() => defaultBillingInterval(prices)); + const {startCheckout, isStartingCheckout} = useSubscriptionRedirect(account?.id); + + return ( + +
+ +

+ {t`Make your event pages, widgets, tickets and emails look like they're entirely yours. Applies to every organizer and event in this account.`} +

+ + {canManageBilling ? ( + + ) : ( +

+ {t`Only account admins can change billing. Ask an admin to upgrade from Settings → Billing.`} +

+ )} +
+ + + {canManageBilling && ( + + )} + +
+ ); +}; diff --git a/frontend/src/components/common/BrandingUpsell/BrandingUpsell.module.scss b/frontend/src/components/common/BrandingUpsell/BrandingUpsell.module.scss new file mode 100644 index 0000000000..7c8015fe7b --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/BrandingUpsell.module.scss @@ -0,0 +1,110 @@ +.features { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.875rem; + line-height: 1.5; + color: var(--hi-text); + + li { + display: flex; + gap: 8px; + align-items: flex-start; + } + + svg { + flex-shrink: 0; + margin-top: 2px; + color: var(--hi-color-success); + } +} + +.intervals { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + + @media (max-width: 420px) { + grid-template-columns: 1fr; + } +} + +.interval { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 14px; + border: 1px solid var(--hi-color-border); + border-radius: 10px; + transition: border-color 120ms ease, box-shadow 120ms ease; + + &[data-checked] { + border-color: var(--hi-primary); + box-shadow: 0 0 0 1px var(--hi-primary); + } +} + +.intervalHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.intervalLabel { + font-size: 0.875rem; + font-weight: 600; + color: var(--hi-text); +} + +.intervalPrice { + display: flex; + align-items: baseline; + gap: 4px; +} + +.amount { + font-size: 1.375rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--hi-text); +} + +.period { + font-size: 0.8125rem; + color: var(--hi-color-text-muted); +} + +.intervalNote { + font-size: 0.75rem; + color: var(--hi-color-text-muted); + + &[data-highlight] { + font-weight: 600; + color: var(--hi-color-success); + } +} + +.modalBody { + display: flex; + flex-direction: column; + gap: 16px; +} + +.lead { + margin: 0; + font-size: 0.9375rem; + line-height: 1.5; + color: var(--hi-text); +} + +.askAdmin { + margin: 0; + padding: 10px 12px; + border-radius: 8px; + font-size: 0.875rem; + color: var(--hi-text); + background: color-mix(in srgb, var(--hi-text) 4%, transparent); +} diff --git a/frontend/src/components/common/BrandingUpsell/IncludedFeatures.tsx b/frontend/src/components/common/BrandingUpsell/IncludedFeatures.tsx new file mode 100644 index 0000000000..1fa65d648e --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/IncludedFeatures.tsx @@ -0,0 +1,24 @@ +import {t} from "@lingui/macro"; +import {IconCheck} from "@tabler/icons-react"; +import classes from "./BrandingUpsell.module.scss"; + +export const IncludedFeatures = () => ( +
    +
  • + + {t`No Hi.Events branding on your event pages, organizer pages, checkout or embedded widgets`} +
  • +
  • + + {t`Your organizer logo at the top of order and ticket emails, in place of the Hi.Events logo`} +
  • +
  • + + {t`Emails sent under your organizer name, with buttons and links in your event's colour`} +
  • +
  • + + {t`No Hi.Events promotion in the emails your attendees receive`} +
  • +
+); diff --git a/frontend/src/components/common/BrandingUpsell/PlanIntervalPicker.tsx b/frontend/src/components/common/BrandingUpsell/PlanIntervalPicker.tsx new file mode 100644 index 0000000000..dd610f019b --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/PlanIntervalPicker.tsx @@ -0,0 +1,87 @@ +import {Radio} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {SubscriptionBillingInterval, SubscriptionPrice} from "../../../types.ts"; +import {getClientLocale, toIntlLocale} from "../../../locales.ts"; +import {minorUnitFactor} from "../../../utilites/currency.ts"; +import classes from "./BrandingUpsell.module.scss"; + +interface PlanIntervalPickerProps { + value: SubscriptionBillingInterval; + onChange: (interval: SubscriptionBillingInterval) => void; + prices: SubscriptionPrice[]; +} + +const formatPrice = (price: SubscriptionPrice) => { + const amount = price.unit_amount / minorUnitFactor(price.currency); + + return new Intl.NumberFormat(toIntlLocale(getClientLocale()), { + style: 'currency', + currency: price.currency, + minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, + }).format(amount); +}; + +const yearlySavingPercent = (monthly?: SubscriptionPrice, yearly?: SubscriptionPrice) => { + if (!monthly || !yearly || monthly.currency !== yearly.currency) { + return null; + } + const saving = 1 - yearly.unit_amount / (monthly.unit_amount * 12); + + return saving > 0 ? Math.round(saving * 100) : null; +}; + +export const defaultBillingInterval = (prices: SubscriptionPrice[]): SubscriptionBillingInterval => + prices.some(price => price.interval === 'year') ? 'year' : (prices[0]?.interval ?? 'year'); + +export const PlanIntervalPicker = ({value, onChange, prices}: PlanIntervalPickerProps) => { + const monthly = prices.find(price => price.interval === 'month'); + const yearly = prices.find(price => price.interval === 'year'); + const saving = yearlySavingPercent(monthly, yearly); + + const options = [ + { + value: 'month' as const, + label: t`Monthly`, + price: monthly && formatPrice(monthly), + period: t`per month`, + note: t`Cancel anytime`, + }, + { + value: 'year' as const, + label: t`Yearly`, + price: yearly && formatPrice(yearly), + period: t`per year`, + note: saving ? t`Save ${saving}% vs monthly` : t`Billed once a year`, + }, + ].filter(option => option.price); + + return ( + onChange(interval as SubscriptionBillingInterval)} + aria-label={t`Billing period`} + > +
+ {options.map(option => ( + + + {option.label} + + + + {option.price} + {option.period} + + + {option.note} + + + ))} +
+
+ ); +}; diff --git a/frontend/src/components/common/BrandingUpsell/index.ts b/frontend/src/components/common/BrandingUpsell/index.ts new file mode 100644 index 0000000000..5e51fa9c9f --- /dev/null +++ b/frontend/src/components/common/BrandingUpsell/index.ts @@ -0,0 +1,4 @@ +export {BrandingAnimation} from "./BrandingAnimation.tsx"; +export {BrandingDesignerSection} from "./BrandingDesignerSection.tsx"; +export {IncludedFeatures} from "./IncludedFeatures.tsx"; +export {defaultBillingInterval, PlanIntervalPicker} from "./PlanIntervalPicker.tsx"; diff --git a/frontend/src/components/common/PoweredByFooter/index.tsx b/frontend/src/components/common/PoweredByFooter/index.tsx index 945cf3d58d..513047ed3f 100644 --- a/frontend/src/components/common/PoweredByFooter/index.tsx +++ b/frontend/src/components/common/PoweredByFooter/index.tsx @@ -47,9 +47,11 @@ const poweredByLink = (host: string, medium: string): string => { * obscure this notice without a licence. If you hear from us or them, it will start as a friendly * conversation, and you'll have 30 days to get a licence or restore the notice. */ -export const PoweredByFooter = ( - props: React.DetailedHTMLProps, HTMLDivElement> -) => { +type PoweredByFooterProps = React.DetailedHTMLProps, HTMLDivElement> & { + brandingRemoved?: boolean; +}; + +export const PoweredByFooter = ({brandingRemoved, ...props}: PoweredByFooterProps) => { const {data: instance, isPending, isError} = useGetInstanceInfo(); const [link, setLink] = useState(() => poweredByLink(configuredHost(), "app")); @@ -58,7 +60,7 @@ export const PoweredByFooter = ( setLink(poweredByLink(window.location.hostname, window.location.pathname.includes("/widget") ? "widget" : "app")); }, []); - if (iHavePurchasedALicence() || isPending || (!isError && instance?.white_label)) { + if (brandingRemoved || iHavePurchasedALicence() || isPending || (!isError && instance?.white_label)) { return <>; } diff --git a/frontend/src/components/common/WidgetEditor/index.tsx b/frontend/src/components/common/WidgetEditor/index.tsx index e5d999c916..4a328b9f0b 100644 --- a/frontend/src/components/common/WidgetEditor/index.tsx +++ b/frontend/src/components/common/WidgetEditor/index.tsx @@ -26,6 +26,7 @@ import {NameModal} from "./NameModal.tsx"; import {CustomiseGroups} from "./CustomiseGroups.tsx"; import {LayoutPicker, LineupNotice} from "./ContentFields.tsx"; import {InstallPanel} from "./InstallPanel.tsx"; +import {BrandingDesignerSection} from "../BrandingUpsell"; import {useLineupSectionAvailability} from "./sectionAvailability.ts"; const UNSAVED = 'unsaved'; @@ -293,6 +294,8 @@ export const WidgetEditor = ({target, theme, event, occurrences, homepageSection /> + {type !== 'BUTTON' && } + { {isModal && currentStep !== 'summary' && ( <> - + )} diff --git a/frontend/src/components/layouts/EventContentWidget/index.tsx b/frontend/src/components/layouts/EventContentWidget/index.tsx index 099d48e5d3..0babeda410 100644 --- a/frontend/src/components/layouts/EventContentWidget/index.tsx +++ b/frontend/src/components/layouts/EventContentWidget/index.tsx @@ -59,7 +59,7 @@ const EventContentWidget = ({variant}: EventContentWidgetProps) => { : !lineup?.schedule_items.length; return ( - + {isEmpty ? ( ) : ( diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx index a55a0f89fe..1654de728f 100644 --- a/frontend/src/components/layouts/EventHomepage/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/index.tsx @@ -276,6 +276,7 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { mirrorImageUrl={cover?.url} footerSource="app-event-footer" footerRef={setFooterNode} + brandingRemoved={organizer.branding_removed} width={sheetWidth} header={layout.sectionNav && } outside={( diff --git a/frontend/src/components/layouts/HomepageFrame/index.tsx b/frontend/src/components/layouts/HomepageFrame/index.tsx index 998afaa3c7..8bd5a7600c 100644 --- a/frontend/src/components/layouts/HomepageFrame/index.tsx +++ b/frontend/src/components/layouts/HomepageFrame/index.tsx @@ -16,13 +16,14 @@ interface HomepageFrameProps { mirrorImageUrl?: string | null; footerSource: string; footerRef?: Ref; + brandingRemoved?: boolean; width?: SheetWidth; header?: ReactNode; outside?: ReactNode; children: ReactNode; } -export const HomepageFrame = ({themeSettings, mirrorImageUrl, footerSource, footerRef, width = 'wide', header, outside, children}: HomepageFrameProps) => { +export const HomepageFrame = ({themeSettings, mirrorImageUrl, footerSource, footerRef, brandingRemoved, width = 'wide', header, outside, children}: HomepageFrameProps) => { useEffect(() => { ensureHomepageFontLoaded(themeSettings.font_family); }, [themeSettings.font_family]); @@ -48,7 +49,7 @@ export const HomepageFrame = ({themeSettings, mirrorImageUrl, footerSource, foot
{children} - +
{outside} @@ -56,9 +57,9 @@ export const HomepageFrame = ({themeSettings, mirrorImageUrl, footerSource, foot ); }; -const HomepageFooter = ({source, footerRef}: { source: string, footerRef?: Ref }) => ( +const HomepageFooter = ({source, footerRef, brandingRemoved}: { source: string, footerRef?: Ref, brandingRemoved?: boolean }) => (