Skip to content

Commit a21e3a7

Browse files
committed
Render offline payment instruction tokens
Offline payment instructions are shown on the public payment and summary pages, where organizers need an order-specific reference for bank transfers. The saved event settings cannot be rendered on the anonymous event endpoint because no order exists there, so this threads the order context only through public order responses and reuses the existing Liquid email context. The rendered HTML is purified again before serialization so buyer-controlled values cannot become browser HTML. The payment page now prefers the order-loaded event payload for offline instructions, while falling back to the standalone event response for the rest of the checkout flow. Verification: php backend/vendor/bin/phpunit backend/tests/Unit/Resources --no-coverage; php -l on touched PHP files; git diff --check; touched-file secret pattern scan.
1 parent b551d55 commit a21e3a7

5 files changed

Lines changed: 140 additions & 7 deletions

File tree

backend/app/Resources/Event/EventResourcePublic.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace HiEvents\Resources\Event;
44

55
use HiEvents\DomainObjects\EventDomainObject;
6+
use HiEvents\DomainObjects\OrderDomainObject;
67
use HiEvents\Resources\BaseResource;
78
use HiEvents\Resources\Image\ImageResource;
89
use HiEvents\Resources\Organizer\OrganizerResourcePublic;
@@ -17,9 +18,12 @@ class EventResourcePublic extends BaseResource
1718
{
1819
private readonly bool $includePostCheckoutData;
1920

21+
private readonly ?OrderDomainObject $orderContext;
22+
2023
public function __construct(
2124
mixed $resource,
2225
mixed $includePostCheckoutData = false,
26+
?OrderDomainObject $orderContext = null,
2327
)
2428
{
2529
// This is a hacky workaround to handle when this resource is instantiated
@@ -28,6 +32,7 @@ public function __construct(
2832
$this->includePostCheckoutData = is_bool($includePostCheckoutData)
2933
? $includePostCheckoutData
3034
: false;
35+
$this->orderContext = $orderContext;
3136

3237
parent::__construct($resource);
3338
}
@@ -56,7 +61,9 @@ public function toArray(Request $request): array
5661
condition: !is_null($this->getEventSettings()),
5762
value: fn() => new EventSettingsResourcePublic(
5863
$this->getEventSettings(),
59-
$this->includePostCheckoutData
64+
$this->includePostCheckoutData,
65+
$this->resource instanceof EventDomainObject ? $this->resource : null,
66+
$this->orderContext,
6067
),
6168
),
6269
// @TODO - public question resource

backend/app/Resources/Event/EventSettingsResourcePublic.php

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@
22

33
namespace HiEvents\Resources\Event;
44

5+
use HiEvents\DomainObjects\EventDomainObject;
56
use HiEvents\DomainObjects\EventSettingDomainObject;
7+
use HiEvents\DomainObjects\OrderDomainObject;
8+
use HiEvents\Services\Domain\Email\EmailTokenContextBuilder;
9+
use HiEvents\Services\Infrastructure\Email\LiquidTemplateRenderer;
10+
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
611
use Illuminate\Http\Resources\Json\JsonResource;
12+
use RuntimeException;
713

814
/**
915
* @mixin EventSettingDomainObject
1016
*/
1117
class EventSettingsResourcePublic extends JsonResource
1218
{
1319
public function __construct(
14-
mixed $resource,
15-
private readonly bool $includePostCheckoutData = false,
20+
mixed $resource,
21+
private readonly bool $includePostCheckoutData = false,
22+
private readonly ?EventDomainObject $eventContext = null,
23+
private readonly ?OrderDomainObject $orderContext = null,
1624
)
1725
{
1826
parent::__construct($resource);
@@ -67,7 +75,7 @@ public function toArray($request): array
6775

6876
// Payment settings
6977
'payment_providers' => $this->getPaymentProviders(),
70-
'offline_payment_instructions' => $this->getOfflinePaymentInstructions(),
78+
'offline_payment_instructions' => $this->getOfflinePaymentInstructionsForOrder(),
7179
'allow_orders_awaiting_offline_payment_to_check_in' => $this->getAllowOrdersAwaitingOfflinePaymentToCheckIn(),
7280

7381
// Invoice settings
@@ -91,4 +99,28 @@ public function toArray($request): array
9199
'waitlist_offer_timeout_minutes' => $this->getWaitlistOfferTimeoutMinutes(),
92100
];
93101
}
102+
103+
private function getOfflinePaymentInstructionsForOrder(): ?string
104+
{
105+
$instructions = $this->getOfflinePaymentInstructions();
106+
$organizer = $this->eventContext?->getOrganizer();
107+
108+
if (!$instructions || !$this->eventContext || !$this->orderContext || !$organizer) {
109+
return $instructions;
110+
}
111+
112+
try {
113+
$context = app(EmailTokenContextBuilder::class)->buildOrderConfirmationContext(
114+
order: $this->orderContext,
115+
event: $this->eventContext,
116+
organizer: $organizer,
117+
eventSettings: $this->resource,
118+
);
119+
$rendered = app(LiquidTemplateRenderer::class)->render($instructions, $context);
120+
121+
return app(HtmlPurifierService::class)->purify($rendered);
122+
} catch (RuntimeException) {
123+
return $instructions;
124+
}
125+
}
94126
}

backend/app/Resources/Order/OrderResourcePublic.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public function toArray(Request $request): array
4545
fn() => new EventResourcePublic(
4646
resource: $this->getEvent(),
4747
includePostCheckoutData: $this->getStatus() === OrderStatus::COMPLETED->name,
48+
orderContext: $this->resource instanceof OrderDomainObject ? $this->resource : null,
4849
),
4950
),
5051
'latest_invoice' => $this->when(
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace Tests\Unit\Resources\Event;
4+
5+
use HiEvents\DomainObjects\Enums\PaymentProviders;
6+
use HiEvents\DomainObjects\EventDomainObject;
7+
use HiEvents\DomainObjects\EventSettingDomainObject;
8+
use HiEvents\DomainObjects\OrderDomainObject;
9+
use HiEvents\DomainObjects\OrganizerDomainObject;
10+
use HiEvents\DomainObjects\Status\OrderPaymentStatus;
11+
use HiEvents\DomainObjects\Status\OrderStatus;
12+
use HiEvents\Resources\Event\EventSettingsResourcePublic;
13+
use Illuminate\Http\Request;
14+
use Tests\TestCase;
15+
16+
class EventSettingsResourcePublicTest extends TestCase
17+
{
18+
public function test_offline_payment_instructions_render_order_tokens(): void
19+
{
20+
$resource = $this->makeResource(
21+
'<p>Use {{ order.number }} for {{ event.title }}</p>',
22+
orderFirstName: 'Jane',
23+
);
24+
25+
$data = $resource->toArray(Request::create('/'));
26+
27+
$this->assertSame(
28+
'<p>Use ORD-12345 for Summer Session</p>',
29+
$data['offline_payment_instructions'],
30+
);
31+
}
32+
33+
public function test_rendered_offline_payment_instructions_are_purified(): void
34+
{
35+
$resource = $this->makeResource(
36+
'<p>Reference {{ order.first_name }}</p>',
37+
orderFirstName: '<script>alert("xss")</script>Jane',
38+
);
39+
40+
$data = $resource->toArray(Request::create('/'));
41+
42+
$this->assertStringNotContainsString('<script>', $data['offline_payment_instructions']);
43+
$this->assertStringContainsString('Jane', $data['offline_payment_instructions']);
44+
}
45+
46+
private function makeResource(
47+
string $offlinePaymentInstructions,
48+
string $orderFirstName,
49+
): EventSettingsResourcePublic {
50+
$organizer = (new OrganizerDomainObject)
51+
->setId(1)
52+
->setName('Example Organizer')
53+
->setEmail('organizer@example.com');
54+
55+
$event = (new EventDomainObject)
56+
->setId(10)
57+
->setTitle('Summer Session')
58+
->setDescription('An evening event')
59+
->setStartDate('2026-08-15 18:00:00')
60+
->setCurrency('GBP')
61+
->setTimezone('UTC')
62+
->setOrganizer($organizer);
63+
64+
$settings = (new EventSettingDomainObject)
65+
->setId(20)
66+
->setEventId(10)
67+
->setPaymentProviders([PaymentProviders::OFFLINE->value])
68+
->setSupportEmail('support@example.com')
69+
->setOfflinePaymentInstructions($offlinePaymentInstructions);
70+
71+
$order = (new OrderDomainObject)
72+
->setId(30)
73+
->setEventId(10)
74+
->setShortId('order-short-id')
75+
->setPublicId('ORD-12345')
76+
->setFirstName($orderFirstName)
77+
->setLastName('Buyer')
78+
->setEmail('buyer@example.com')
79+
->setTotalGross(125.50)
80+
->setCurrency('GBP')
81+
->setCreatedAt('2026-08-01 12:00:00')
82+
->setStatus(OrderStatus::AWAITING_OFFLINE_PAYMENT->name)
83+
->setPaymentStatus(OrderPaymentStatus::AWAITING_OFFLINE_PAYMENT->name)
84+
->setPaymentProvider(PaymentProviders::OFFLINE->value);
85+
86+
return new EventSettingsResourcePublic(
87+
resource: $settings,
88+
eventContext: $event,
89+
orderContext: $order,
90+
);
91+
}
92+
}

frontend/src/components/routes/product-widget/Payment/index.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const Payment = () => {
2626
const {data: event, isFetched: isEventFetched} = useGetEventPublic(eventId);
2727
const {data: order, isFetched: isOrderFetched} = useGetOrderPublic(eventId, orderShortId, ['event']);
2828
const isLoading = !isOrderFetched;
29+
const checkoutEvent = order?.event || event;
2930
const [isPaymentLoading, setIsPaymentLoading] = useState(false);
3031
const [activePaymentMethod, setActivePaymentMethod] = useState<'STRIPE' | 'OFFLINE' | null>(null);
3132
const [submitHandler, setSubmitHandler] = useState<(() => Promise<void>) | null>(null);
@@ -93,8 +94,8 @@ const Payment = () => {
9394
return (
9495
<>
9596
<CheckoutContent>
96-
{(event && order) && (
97-
<InlineOrderSummary event={event} order={order} defaultExpanded={false}/>
97+
{(checkoutEvent && order) && (
98+
<InlineOrderSummary event={checkoutEvent} order={order} defaultExpanded={false}/>
9899
)}
99100
{isStripeEnabled && (
100101
<div style={{display: activePaymentMethod === 'STRIPE' ? 'block' : 'none'}}>
@@ -104,7 +105,7 @@ const Payment = () => {
104105

105106
{isOfflineEnabled && (
106107
<div style={{display: activePaymentMethod === 'OFFLINE' ? 'block' : 'none'}}>
107-
<OfflinePaymentMethod event={event as Event}/>
108+
<OfflinePaymentMethod event={checkoutEvent as Event}/>
108109
</div>
109110
)}
110111

0 commit comments

Comments
 (0)