Skip to content

Commit 185333e

Browse files
committed
Render offline payment instruction tokens
Offline payment instructions can include order-specific references in organizer configuration, but public checkout resources were returning the stored template verbatim. That left buyers seeing unreplaced Liquid tokens and prevented organizers from matching bank transfers to orders. Render the instructions only when the public event settings resource has both event and order context, reusing the existing order-confirmation token builder so checkout and email references stay consistent. Purify the rendered HTML before returning it, and fall back to the stored instructions if rendering fails. Also prefer the order-loaded event payload on the payment page so the offline payment method receives the resource that contains order-aware settings.
1 parent eecd9b3 commit 185333e

5 files changed

Lines changed: 129 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
@@ -94,4 +102,28 @@ public function toArray($request): array
94102
'waitlist_offer_timeout_minutes' => $this->getWaitlistOfferTimeoutMinutes(),
95103
];
96104
}
105+
106+
private function getOfflinePaymentInstructionsForOrder(): ?string
107+
{
108+
$instructions = $this->getOfflinePaymentInstructions();
109+
$organizer = $this->eventContext?->getOrganizer();
110+
111+
if (!$instructions || !$this->eventContext || !$this->orderContext || !$organizer) {
112+
return $instructions;
113+
}
114+
115+
try {
116+
$context = app(EmailTokenContextBuilder::class)->buildOrderConfirmationContext(
117+
order: $this->orderContext,
118+
event: $this->eventContext,
119+
organizer: $organizer,
120+
eventSettings: $this->resource,
121+
);
122+
$rendered = app(LiquidTemplateRenderer::class)->render($instructions, $context);
123+
124+
return app(HtmlPurifierService::class)->purify($rendered);
125+
} catch (RuntimeException) {
126+
return $instructions;
127+
}
128+
}
97129
}

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(

backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
namespace Tests\Unit\Resources\Event;
44

5+
use HiEvents\DomainObjects\Enums\PaymentProviders;
6+
use HiEvents\DomainObjects\EventDomainObject;
57
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;
612
use HiEvents\Resources\Event\EventSettingsResourcePublic;
713
use Illuminate\Http\Request;
814
use Tests\TestCase;
@@ -31,4 +37,79 @@ public function test_public_resource_exposes_allow_copy_details_when_disabled():
3137

3238
$this->assertFalse($resource['allow_copy_details_to_all_attendees']);
3339
}
40+
41+
public function test_offline_payment_instructions_render_order_tokens(): void
42+
{
43+
$resource = $this->makeResource(
44+
'<p>Use {{ order.number }} for {{ event.title }}</p>',
45+
orderFirstName: 'Jane',
46+
);
47+
48+
$data = $resource->toArray(Request::create('/'));
49+
50+
$this->assertSame(
51+
'<p>Use ORD-12345 for Summer Session</p>',
52+
$data['offline_payment_instructions'],
53+
);
54+
}
55+
56+
public function test_rendered_offline_payment_instructions_are_purified(): void
57+
{
58+
$resource = $this->makeResource(
59+
'<p>Reference {{ order.first_name }}</p>',
60+
orderFirstName: '<script>alert("xss")</script>Jane',
61+
);
62+
63+
$data = $resource->toArray(Request::create('/'));
64+
65+
$this->assertStringNotContainsString('<script>', $data['offline_payment_instructions']);
66+
$this->assertStringContainsString('Jane', $data['offline_payment_instructions']);
67+
}
68+
69+
private function makeResource(
70+
string $offlinePaymentInstructions,
71+
string $orderFirstName,
72+
): EventSettingsResourcePublic {
73+
$organizer = (new OrganizerDomainObject())
74+
->setId(1)
75+
->setName('Example Organizer')
76+
->setEmail('organizer@example.com');
77+
78+
$event = (new EventDomainObject())
79+
->setId(10)
80+
->setTitle('Summer Session')
81+
->setDescription('An evening event')
82+
->setStartDate('2026-08-15 18:00:00')
83+
->setCurrency('GBP')
84+
->setTimezone('UTC')
85+
->setOrganizer($organizer);
86+
87+
$settings = (new EventSettingDomainObject())
88+
->setId(20)
89+
->setEventId(10)
90+
->setPaymentProviders([PaymentProviders::OFFLINE->value])
91+
->setSupportEmail('support@example.com')
92+
->setOfflinePaymentInstructions($offlinePaymentInstructions);
93+
94+
$order = (new OrderDomainObject())
95+
->setId(30)
96+
->setEventId(10)
97+
->setShortId('order-short-id')
98+
->setPublicId('ORD-12345')
99+
->setFirstName($orderFirstName)
100+
->setLastName('Buyer')
101+
->setEmail('buyer@example.com')
102+
->setTotalGross(125.50)
103+
->setCurrency('GBP')
104+
->setCreatedAt('2026-08-01 12:00:00')
105+
->setStatus(OrderStatus::AWAITING_OFFLINE_PAYMENT->name)
106+
->setPaymentStatus(OrderPaymentStatus::AWAITING_OFFLINE_PAYMENT->name)
107+
->setPaymentProvider(PaymentProviders::OFFLINE->value);
108+
109+
return new EventSettingsResourcePublic(
110+
resource: $settings,
111+
eventContext: $event,
112+
orderContext: $order,
113+
);
114+
}
34115
}

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)