Skip to content

Commit 6eb1709

Browse files
Render offline payment instruction tokens (#1235)
Co-authored-by: Dave Earley <dave@hi.events>
1 parent 81a02f3 commit 6eb1709

13 files changed

Lines changed: 404 additions & 174 deletions

File tree

backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@
2020
use HiEvents\Repository\Eloquent\Value\Relationship;
2121
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
2222
use HiEvents\Services\Application\Handlers\Order\DTO\GetOrderPublicDTO;
23+
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
2324
use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
2425
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
2526

2627
class GetOrderPublicHandler
2728
{
2829
public function __construct(
29-
private readonly OrderRepositoryInterface $orderRepository,
30-
private readonly CheckoutSessionManagementService $sessionIdentifierService
30+
private readonly OrderRepositoryInterface $orderRepository,
31+
private readonly CheckoutSessionManagementService $sessionIdentifierService,
32+
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
3133
)
3234
{
3335
}
@@ -49,6 +51,8 @@ public function handle(GetOrderPublicDTO $getOrderData): OrderDomainObject
4951
$this->verifySessionId($order->getSessionId());
5052
}
5153

54+
$this->offlinePaymentInstructionsRenderService->renderForOrder($order);
55+
5256
return $order;
5357
}
5458

backend/app/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandler.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@
2323
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
2424
use HiEvents\Repository\Interfaces\TicketLookupTokenRepositoryInterface;
2525
use HiEvents\Services\Application\Handlers\TicketLookup\DTO\GetOrdersByLookupTokenDTO;
26+
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
2627
use Illuminate\Support\Collection;
2728

2829
class GetOrdersByLookupTokenHandler
2930
{
3031
public function __construct(
3132
private readonly TicketLookupTokenRepositoryInterface $ticketLookupTokenRepository,
3233
private readonly OrderRepositoryInterface $orderRepository,
34+
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
3335
) {
3436
}
3537

@@ -41,7 +43,8 @@ public function handle(GetOrdersByLookupTokenDTO $dto): Collection
4143
{
4244
$tokenRecord = $this->validateAndFetchToken($dto->token);
4345

44-
return $this->getOrdersForEmail($tokenRecord->getEmail());
46+
return $this->getOrdersForEmail($tokenRecord->getEmail())
47+
->each(fn(OrderDomainObject $order) => $this->offlinePaymentInstructionsRenderService->renderForOrder($order));
4548
}
4649

4750
/**

backend/app/Services/Domain/Email/EmailTokenContextBuilder.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,19 @@
1616
use HiEvents\Helper\IdHelper;
1717
use HiEvents\Helper\Url;
1818
use HiEvents\Locale;
19+
use HiEvents\Services\Infrastructure\Email\LiquidTemplateRenderer;
20+
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
21+
use Throwable;
1922

2023
class EmailTokenContextBuilder
2124
{
25+
public function __construct(
26+
private readonly LiquidTemplateRenderer $liquidTemplateRenderer,
27+
private readonly HtmlPurifierService $htmlPurifierService,
28+
)
29+
{
30+
}
31+
2232
public function buildOrderConfirmationContext(
2333
OrderDomainObject $order,
2434
EventDomainObject $event,
@@ -29,7 +39,7 @@ public function buildOrderConfirmationContext(
2939
$eventStartDate = new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone()));
3040
$eventEndDate = $event->getEndDate() ? new Carbon(DateHelper::convertFromUTC($event->getEndDate(), $event->getTimezone())) : null;
3141

32-
return [
42+
$context = [
3343
// Event object
3444
'event' => [
3545
'title' => $event->getTitle(),
@@ -75,6 +85,27 @@ public function buildOrderConfirmationContext(
7585
'post_checkout_message' => $eventSettings->getPostCheckoutMessage() ?? '',
7686
],
7787
];
88+
89+
$context['settings']['offline_payment_instructions'] = $this->renderOfflinePaymentInstructions($context);
90+
91+
return $context;
92+
}
93+
94+
private function renderOfflinePaymentInstructions(array $context): string
95+
{
96+
$instructions = $context['settings']['offline_payment_instructions'];
97+
98+
if ($instructions === '') {
99+
return $instructions;
100+
}
101+
102+
try {
103+
$rendered = $this->liquidTemplateRenderer->render($instructions, $context);
104+
105+
return $this->htmlPurifierService->purify($rendered) ?? $instructions;
106+
} catch (Throwable) {
107+
return $instructions;
108+
}
78109
}
79110

80111
public function buildAttendeeTicketContext(

backend/app/Services/Domain/Email/MailBuilderService.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
use HiEvents\Mail\Attendee\AttendeeTicketMail;
1313
use HiEvents\Mail\Order\OrderSummary;
1414
use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO;
15+
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
1516

1617
class MailBuilderService
1718
{
1819
public function __construct(
1920
private readonly EmailTemplateService $emailTemplateService,
2021
private readonly EmailTokenContextBuilder $tokenContextBuilder,
22+
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
2123
) {
2224
}
2325

@@ -60,6 +62,10 @@ public function buildOrderSummaryMail(
6062
$organizer
6163
);
6264

65+
if (!$renderedTemplate) {
66+
$this->offlinePaymentInstructionsRenderService->render($order, $event, $organizer, $eventSettings);
67+
}
68+
6369
return new OrderSummary(
6470
order: $order,
6571
event: $event,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HiEvents\Services\Domain\Order;
6+
7+
use HiEvents\DomainObjects\EventDomainObject;
8+
use HiEvents\DomainObjects\EventSettingDomainObject;
9+
use HiEvents\DomainObjects\OrderDomainObject;
10+
use HiEvents\DomainObjects\OrganizerDomainObject;
11+
use HiEvents\Services\Domain\Email\EmailTokenContextBuilder;
12+
use Throwable;
13+
14+
class OfflinePaymentInstructionsRenderService
15+
{
16+
public function __construct(
17+
private readonly EmailTokenContextBuilder $tokenContextBuilder,
18+
)
19+
{
20+
}
21+
22+
public function renderForOrder(OrderDomainObject $order): void
23+
{
24+
$event = $order->getEvent();
25+
$eventSettings = $event?->getEventSettings();
26+
$organizer = $event?->getOrganizer();
27+
28+
if (!$event || !$eventSettings || !$organizer) {
29+
return;
30+
}
31+
32+
$this->render($order, $event, $organizer, $eventSettings);
33+
}
34+
35+
public function render(
36+
OrderDomainObject $order,
37+
EventDomainObject $event,
38+
OrganizerDomainObject $organizer,
39+
EventSettingDomainObject $eventSettings,
40+
): void
41+
{
42+
if (!$eventSettings->getOfflinePaymentInstructions()) {
43+
return;
44+
}
45+
46+
try {
47+
$context = $this->tokenContextBuilder->buildOrderConfirmationContext(
48+
order: $order,
49+
event: $event,
50+
organizer: $organizer,
51+
eventSettings: $eventSettings,
52+
);
53+
54+
$eventSettings->setOfflinePaymentInstructions(
55+
$context['settings']['offline_payment_instructions']
56+
);
57+
} catch (Throwable) {
58+
return;
59+
}
60+
}
61+
}

backend/tests/Unit/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandlerTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use HiEvents\Repository\Interfaces\TicketLookupTokenRepositoryInterface;
1111
use HiEvents\Services\Application\Handlers\TicketLookup\DTO\GetOrdersByLookupTokenDTO;
1212
use HiEvents\Services\Application\Handlers\TicketLookup\GetOrdersByLookupTokenHandler;
13+
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
1314
use Illuminate\Support\Collection;
1415
use Mockery as m;
1516
use Tests\TestCase;
@@ -30,6 +31,7 @@ protected function setUp(): void
3031
$this->handler = new GetOrdersByLookupTokenHandler(
3132
$this->ticketLookupTokenRepository,
3233
$this->orderRepository,
34+
app(OfflinePaymentInstructionsRenderService::class),
3335
);
3436
}
3537

@@ -46,6 +48,7 @@ public function testHandleSuccessfullyReturnsOrdersWhenTokenIsValid(): void
4648
->andReturn($email);
4749

4850
$order = m::mock(OrderDomainObject::class);
51+
$order->shouldReceive('getEvent')->andReturn(null);
4952
$orders = new Collection([$order]);
5053

5154
$this->ticketLookupTokenRepository

backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class EmailTokenContextBuilderTest extends TestCase
2121
protected function setUp(): void
2222
{
2323
parent::setUp();
24-
$this->contextBuilder = new EmailTokenContextBuilder();
24+
$this->contextBuilder = app(EmailTokenContextBuilder::class);
2525
}
2626

2727
public function test_builds_order_confirmation_context(): void
@@ -101,6 +101,70 @@ public function test_builds_attendee_ticket_context(): void
101101
$this->assertEquals('Great Organizer', $context['organizer']['name']);
102102
}
103103

104+
public function test_offline_payment_instructions_tokens_are_rendered_into_context(): void
105+
{
106+
$order = $this->createMockOrder();
107+
$event = $this->createMockEvent();
108+
$organizer = $this->createMockOrganizer();
109+
$eventSettings = $this->createMockEventSettings(
110+
offlinePaymentInstructions: '<p>Use {{ order.number }} for {{ event.title }}</p>',
111+
);
112+
113+
$context = $this->contextBuilder->buildOrderConfirmationContext(
114+
$order,
115+
$event,
116+
$organizer,
117+
$eventSettings
118+
);
119+
120+
$this->assertSame(
121+
'<p>Use ORD-123456 for Amazing Event</p>',
122+
$context['settings']['offline_payment_instructions'],
123+
);
124+
}
125+
126+
public function test_rendered_offline_payment_instructions_are_purified(): void
127+
{
128+
$order = $this->createMockOrder(firstName: '<script>alert("xss")</script>John');
129+
$event = $this->createMockEvent();
130+
$organizer = $this->createMockOrganizer();
131+
$eventSettings = $this->createMockEventSettings(
132+
offlinePaymentInstructions: '<p>Reference {{ order.first_name }}</p>',
133+
);
134+
135+
$context = $this->contextBuilder->buildOrderConfirmationContext(
136+
$order,
137+
$event,
138+
$organizer,
139+
$eventSettings
140+
);
141+
142+
$this->assertStringNotContainsString('<script>', $context['settings']['offline_payment_instructions']);
143+
$this->assertStringContainsString('John', $context['settings']['offline_payment_instructions']);
144+
}
145+
146+
public function test_unrenderable_offline_payment_instructions_fall_back_to_raw_value(): void
147+
{
148+
$order = $this->createMockOrder();
149+
$event = $this->createMockEvent();
150+
$organizer = $this->createMockOrganizer();
151+
$eventSettings = $this->createMockEventSettings(
152+
offlinePaymentInstructions: '<p>{% unknown_tag %}</p>',
153+
);
154+
155+
$context = $this->contextBuilder->buildOrderConfirmationContext(
156+
$order,
157+
$event,
158+
$organizer,
159+
$eventSettings
160+
);
161+
162+
$this->assertSame(
163+
'<p>{% unknown_tag %}</p>',
164+
$context['settings']['offline_payment_instructions'],
165+
);
166+
}
167+
104168
public function test_whitelists_only_allowed_tokens_for_order_confirmation(): void
105169
{
106170
$order = $this->createMockOrder();
@@ -155,7 +219,7 @@ public function test_whitelists_only_allowed_tokens_for_attendee_ticket(): void
155219
$this->assertArrayHasKey('title', $context['event']);
156220
}
157221

158-
private function createMockOrder(): OrderDomainObject
222+
private function createMockOrder(string $firstName = 'John'): OrderDomainObject
159223
{
160224
$orderItem = Mockery::mock(OrderItemDomainObject::class, [
161225
'getProductPriceId' => 123,
@@ -168,7 +232,7 @@ private function createMockOrder(): OrderDomainObject
168232
return Mockery::mock(OrderDomainObject::class, [
169233
'getPublicId' => 'ORD-123456',
170234
'getTotalGross' => 9999,
171-
'getFirstName' => 'John',
235+
'getFirstName' => $firstName,
172236
'getLastName' => 'Doe',
173237
'getEmail' => 'john@example.com',
174238
'getCreatedAt' => '2024-01-15 10:30:00',
@@ -202,11 +266,11 @@ private function createMockOrganizer(): OrganizerDomainObject
202266
]);
203267
}
204268

205-
private function createMockEventSettings(): EventSettingDomainObject
269+
private function createMockEventSettings(string $offlinePaymentInstructions = 'Pay by bank transfer'): EventSettingDomainObject
206270
{
207271
return Mockery::mock(EventSettingDomainObject::class, [
208272
'getSupportEmail' => 'support@event.com',
209-
'getOfflinePaymentInstructions' => 'Pay by bank transfer',
273+
'getOfflinePaymentInstructions' => $offlinePaymentInstructions,
210274
'getPostCheckoutMessage' => 'Thank you for your purchase!',
211275
'getLocationDetails' => null,
212276
]);

0 commit comments

Comments
 (0)