yorck-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/types.ts ADDED
@@ -0,0 +1,69 @@
1
+ export interface Env {
2
+ CACHE: KVNamespace;
3
+ MCP_OBJECT: DurableObjectNamespace;
4
+ PUBLIC_MCP_OBJECT: DurableObjectNamespace;
5
+ BROWSER: Fetcher;
6
+
7
+ PREFERRED_CINEMAS: string;
8
+ DEFAULT_FORMATS: string;
9
+ COGNITO_USER_POOL: string;
10
+ COGNITO_CLIENT_ID: string;
11
+ COGNITO_REGION: string;
12
+ VISTA_BASE: string;
13
+ YORCK_AUTH_BASE: string;
14
+ YORCK_BASE: string;
15
+ PUBLIC_BASE_URL?: string;
16
+
17
+ YORCK_EMAIL?: string;
18
+ YORCK_PASSWORD?: string;
19
+ YORCK_UNLIMITED_CARD?: string;
20
+ YORCK_MCP_AUTH_TOKEN?: string;
21
+ }
22
+
23
+ export interface Showtime {
24
+ film: string;
25
+ slug: string;
26
+ tagline?: string;
27
+ runtime: number;
28
+ fsk?: number;
29
+ genre?: string;
30
+ yorckPick: boolean;
31
+ start: string;
32
+ end: string;
33
+ cinema: string;
34
+ cinemaSlug: string;
35
+ district?: string;
36
+ format: string;
37
+ url: string;
38
+ sessionId: string;
39
+ }
40
+
41
+ export interface Cinema {
42
+ name: string;
43
+ slug: string;
44
+ shortName: string;
45
+ vistaId: string;
46
+ district: string;
47
+ address: string;
48
+ coordinates?: { lat: number; lon: number };
49
+ numberOfAuditoriums?: number;
50
+ }
51
+
52
+ export interface FilmDetail {
53
+ title: string;
54
+ slug: string;
55
+ vistaId: string;
56
+ runtime: number;
57
+ fsk?: number;
58
+ genre?: string;
59
+ director?: string;
60
+ cast?: string[];
61
+ year?: number;
62
+ countries?: string[];
63
+ originalLanguage?: string;
64
+ tagline?: string;
65
+ about?: string;
66
+ trailerYouTubeId?: string;
67
+ poster?: string;
68
+ url: string;
69
+ }
@@ -0,0 +1,156 @@
1
+ import type { Env } from "../types.ts";
2
+ import { decodeJwt, jwtExpired } from "../lib/jwt.ts";
3
+
4
+ const KV_KEY = "yorck:tokens";
5
+
6
+ interface SigninResponse {
7
+ accessToken: string;
8
+ idToken: string;
9
+ refreshToken: string;
10
+ }
11
+
12
+ interface CachedSession {
13
+ accessToken: string;
14
+ idToken: string;
15
+ refreshToken: string;
16
+ cachedAtMs: number;
17
+ }
18
+
19
+ interface IdTokenClaims {
20
+ email: string;
21
+ given_name?: string;
22
+ family_name?: string;
23
+ name?: string;
24
+ "custom:vistaAccessToken2"?: string;
25
+ "custom:vistaRefreshToken2"?: string;
26
+ "custom:vistaMemberId"?: string;
27
+ exp: number;
28
+ }
29
+
30
+ async function signin(env: Env): Promise<CachedSession> {
31
+ if (!env.YORCK_EMAIL || !env.YORCK_PASSWORD) {
32
+ throw new Error("YORCK_EMAIL / YORCK_PASSWORD secrets not set");
33
+ }
34
+ const r = await fetch(`${env.YORCK_AUTH_BASE}/auth/signin`, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ Accept: "application/json",
39
+ Origin: "https://www.yorck.de",
40
+ Referer: "https://www.yorck.de/",
41
+ "User-Agent": "Mozilla/5.0 (yorck-mcp)",
42
+ },
43
+ body: JSON.stringify({ email: env.YORCK_EMAIL, password: env.YORCK_PASSWORD }),
44
+ });
45
+ if (!r.ok) throw new Error(`signin ${r.status}: ${await r.text()}`);
46
+ const j = (await r.json()) as SigninResponse;
47
+ if (!j.idToken || !j.accessToken) {
48
+ throw new Error(`signin: missing tokens in response ${JSON.stringify(j).slice(0, 200)}`);
49
+ }
50
+ const session: CachedSession = {
51
+ accessToken: j.accessToken,
52
+ idToken: j.idToken,
53
+ refreshToken: j.refreshToken,
54
+ cachedAtMs: Date.now(),
55
+ };
56
+ // The Cognito access/id tokens are 1h. Cache for ~55 min.
57
+ await env.CACHE.put(KV_KEY, JSON.stringify(session), { expirationTtl: 55 * 60 });
58
+ return session;
59
+ }
60
+
61
+ async function refreshSession(env: Env, refreshToken: string): Promise<CachedSession> {
62
+ const r = await fetch(`${env.YORCK_AUTH_BASE}/auth/refresh`, {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ Accept: "application/json",
67
+ Origin: "https://www.yorck.de",
68
+ Referer: "https://www.yorck.de/",
69
+ "User-Agent": "Mozilla/5.0 (yorck-mcp)",
70
+ },
71
+ body: JSON.stringify({ refreshToken }),
72
+ });
73
+ if (!r.ok) throw new Error(`refresh ${r.status}: ${await r.text()}`);
74
+ const j = (await r.json()) as SigninResponse;
75
+ const session: CachedSession = {
76
+ accessToken: j.accessToken,
77
+ idToken: j.idToken,
78
+ refreshToken: j.refreshToken ?? refreshToken,
79
+ cachedAtMs: Date.now(),
80
+ };
81
+ await env.CACHE.put(KV_KEY, JSON.stringify(session), { expirationTtl: 55 * 60 });
82
+ return session;
83
+ }
84
+
85
+ export async function getSession(env: Env, force = false): Promise<CachedSession> {
86
+ if (!force) {
87
+ const cached = await env.CACHE.get<CachedSession>(KV_KEY, "json");
88
+ if (cached && !jwtExpired(cached.idToken, 60)) {
89
+ return cached;
90
+ }
91
+ if (cached?.refreshToken) {
92
+ try {
93
+ return await refreshSession(env, cached.refreshToken);
94
+ } catch {
95
+ // fall through to fresh signin
96
+ }
97
+ }
98
+ }
99
+ return signin(env);
100
+ }
101
+
102
+ export interface VistaAuthHeaders {
103
+ "id-token": string;
104
+ "access-token": string;
105
+ "refresh-token": string;
106
+ connectApiToken: string;
107
+ }
108
+
109
+ export async function authHeaders(env: Env): Promise<VistaAuthHeaders> {
110
+ const s = await getSession(env);
111
+ return {
112
+ "id-token": s.idToken,
113
+ "access-token": s.accessToken,
114
+ "refresh-token": s.refreshToken,
115
+ connectApiToken: "",
116
+ };
117
+ }
118
+
119
+ // Forces fresh tokens if a call fails with an auth error.
120
+ export async function withFreshAuthRetry<T>(env: Env, fn: (h: VistaAuthHeaders) => Promise<T>): Promise<T> {
121
+ let h = await authHeaders(env);
122
+ try {
123
+ return await fn(h);
124
+ } catch (e) {
125
+ const msg = String(e);
126
+ if (
127
+ msg.includes("ExpiredLoyaltyToken") ||
128
+ msg.includes("loyaltySessionToken is expired") ||
129
+ msg.includes("Invalid credentials") ||
130
+ msg.includes("ExpiredToken") ||
131
+ msg.includes("401") ||
132
+ msg.includes("403")
133
+ ) {
134
+ const fresh = await getSession(env, true);
135
+ h = {
136
+ "id-token": fresh.idToken,
137
+ "access-token": fresh.accessToken,
138
+ "refresh-token": fresh.refreshToken,
139
+ connectApiToken: "",
140
+ };
141
+ return await fn(h);
142
+ }
143
+ throw e;
144
+ }
145
+ }
146
+
147
+ export async function whoAmI(env: Env): Promise<{ memberId: string; email: string; name: string }> {
148
+ const s = await getSession(env);
149
+ const claims = decodeJwt<IdTokenClaims>(s.idToken);
150
+ const fullName = `${claims.given_name ?? ""} ${claims.family_name ?? ""}`.trim();
151
+ return {
152
+ memberId: claims["custom:vistaMemberId"] ?? "",
153
+ email: claims.email,
154
+ name: fullName || claims.name || claims.given_name || "",
155
+ };
156
+ }
@@ -0,0 +1,442 @@
1
+ import type { Env } from "../types.ts";
2
+ import { authHeaders, whoAmI, withFreshAuthRetry } from "./auth.ts";
3
+ import { getCinemaBySlug } from "./cinemas.ts";
4
+ import { findSeatByRowAndId, getSeatPlan } from "./seats.ts";
5
+
6
+ const BASE_HEADERS = {
7
+ Accept: "application/json",
8
+ "Content-Type": "application/json",
9
+ Origin: "https://www.yorck.de",
10
+ Referer: "https://www.yorck.de/",
11
+ "User-Agent": "Mozilla/5.0 (yorck-mcp)",
12
+ };
13
+
14
+ // All Vista calls use Yorck's React-app auth chain so the gateway can read
15
+ // the embedded vistaAccessToken2 from the Cognito id-token claim.
16
+ async function vistaCall(env: Env, path: string, init: RequestInit = {}): Promise<Response> {
17
+ const auth = await authHeaders(env);
18
+ return fetch(env.VISTA_BASE + path, {
19
+ ...init,
20
+ headers: {
21
+ ...BASE_HEADERS,
22
+ ...auth,
23
+ ...(init.headers as Record<string, string> | undefined),
24
+ },
25
+ });
26
+ }
27
+
28
+ function apiBase(env: Env): string {
29
+ return env.VISTA_BASE.replace(/\/vista\/?$/, "");
30
+ }
31
+
32
+ export interface OrderSeat {
33
+ areaCategoryCode?: string;
34
+ areaNumber?: number;
35
+ rowDisplay?: string;
36
+ columnDisplay?: string;
37
+ rowIndex: number;
38
+ columnIndex: number;
39
+ }
40
+
41
+ export interface OrderState {
42
+ cinemaId: string;
43
+ userSessionId: string;
44
+ orderTotalValueInCents: number;
45
+ expiryDateUtc: string;
46
+ sessions: Array<{
47
+ id: number;
48
+ filmTitle: string;
49
+ startTime: string;
50
+ screen?: string;
51
+ tickets: Array<{
52
+ ticketDetails?: {
53
+ ticketId: number;
54
+ ticketTypeCode: string;
55
+ description?: string;
56
+ finalPriceInCents: number;
57
+ originalPriceInCents: number;
58
+ } | null;
59
+ seats: OrderSeat[];
60
+ }>;
61
+ }>;
62
+ customer?: {
63
+ firstName: string;
64
+ lastName: string;
65
+ email: string;
66
+ };
67
+ }
68
+
69
+ export async function createOrder(env: Env, cinemaVistaId: string): Promise<OrderState> {
70
+ const r = await vistaCall(env, "/orders", {
71
+ method: "POST",
72
+ body: JSON.stringify({ cinemaId: cinemaVistaId }),
73
+ });
74
+ if (!r.ok) throw new Error(`createOrder ${r.status}: ${await r.text()}`);
75
+ const j = (await r.json()) as { order: OrderState };
76
+ return j.order;
77
+ }
78
+
79
+ export async function getOrder(env: Env, userSessionId: string): Promise<OrderState> {
80
+ const r = await vistaCall(env, `/orders/${userSessionId}`);
81
+ if (!r.ok) throw new Error(`getOrder ${r.status}: ${await r.text()}`);
82
+ const j = (await r.json()) as { order: OrderState };
83
+ return j.order;
84
+ }
85
+
86
+ export async function cancelOrder(env: Env, userSessionId: string): Promise<void> {
87
+ await vistaCall(env, "/RESTTicketing.svc/order/cancel", {
88
+ method: "POST",
89
+ body: JSON.stringify({ UserSessionId: userSessionId }),
90
+ });
91
+ }
92
+
93
+ interface RawTicket {
94
+ TicketTypeCode: string;
95
+ TicketCode: string;
96
+ HOPK?: string;
97
+ HeadOfficeGroupingCode?: string;
98
+ AreaCategoryCode: string;
99
+ Description?: string;
100
+ DescriptionAlt?: string;
101
+ LongDescription?: string;
102
+ LongDescriptionAlt?: string;
103
+ ThirdPartyMembershipName?: string;
104
+ IsThirdPartyMemberTicket?: boolean;
105
+ PriceInCents: number;
106
+ }
107
+
108
+ async function getTickets(env: Env, cinemaVistaId: string, sessionNum: string, userSessionId: string): Promise<RawTicket[]> {
109
+ const r = await vistaCall(env, `/RESTData.svc/cinemas/${cinemaVistaId}/sessions/${sessionNum}/tickets?salesChannelFilter=WWW&userSessionId=${userSessionId}`);
110
+ if (!r.ok) throw new Error(`tickets ${r.status}: ${await r.text()}`);
111
+ const j = (await r.json()) as { Tickets: RawTicket[] };
112
+ return j.Tickets ?? [];
113
+ }
114
+
115
+ async function getUnlimitedTicket(env: Env, cinemaVistaId: string, sessionNum: string, userSessionId: string): Promise<RawTicket> {
116
+ const tickets = await getTickets(env, cinemaVistaId, sessionNum, userSessionId);
117
+ const unlimited = tickets.find((t) => {
118
+ const text = `${t.Description ?? ""} ${t.DescriptionAlt ?? ""} ${t.ThirdPartyMembershipName ?? ""}`.toLowerCase();
119
+ return text.includes("unlimited") && (t.PriceInCents === 0 || t.IsThirdPartyMemberTicket);
120
+ }) ?? tickets.find((t) => t.IsThirdPartyMemberTicket && t.PriceInCents === 0);
121
+
122
+ if (!unlimited) {
123
+ const available = tickets.map((t) => `${t.TicketTypeCode}:${t.DescriptionAlt ?? t.Description ?? "?"}`).join(", ");
124
+ throw new Error(`no Yorck Unlimited ticket type for this session; available: ${available}`);
125
+ }
126
+ return unlimited;
127
+ }
128
+
129
+ // Step observed on yorck.de /checkout/seats: first hold the chosen seat with
130
+ // ticketDetails=null. The actual Unlimited ticket is applied after member
131
+ // validation on /checkout/tickets.
132
+ export async function holdSeat(env: Env, args: {
133
+ userSessionId: string;
134
+ sessionNum: string;
135
+ seat: OrderSeat;
136
+ }): Promise<OrderState> {
137
+ const body = {
138
+ tickets: [
139
+ {
140
+ ticketDetails: null,
141
+ seats: [args.seat],
142
+ },
143
+ ],
144
+ };
145
+ const r = await vistaCall(env, `/orders/${args.userSessionId}/sessions/${args.sessionNum}/set-tickets`, {
146
+ method: "POST",
147
+ body: JSON.stringify(body),
148
+ });
149
+ if (!r.ok) throw new Error(`hold-seat ${r.status}: ${await r.text()}`);
150
+ const j = (await r.json()) as { order: OrderState };
151
+ return j.order;
152
+ }
153
+
154
+ export async function setUnlimitedTicket(env: Env, args: {
155
+ userSessionId: string;
156
+ sessionNum: string;
157
+ ticketTypeCode: string;
158
+ unlimitedCard: string;
159
+ seat: OrderSeat;
160
+ }): Promise<OrderState> {
161
+ const body = {
162
+ tickets: [
163
+ {
164
+ ticketDetails: {
165
+ ticketTypeCode: args.ticketTypeCode,
166
+ thirdPartyMemberScheme: { memberCard: args.unlimitedCard },
167
+ },
168
+ seats: [args.seat],
169
+ },
170
+ ],
171
+ };
172
+ const r = await vistaCall(env, `/orders/${args.userSessionId}/sessions/${args.sessionNum}/set-tickets`, {
173
+ method: "POST",
174
+ body: JSON.stringify(body),
175
+ });
176
+ if (!r.ok) throw new Error(`set-unlimited-ticket ${r.status}: ${await r.text()}`);
177
+ const j = (await r.json()) as { order: OrderState };
178
+ return j.order;
179
+ }
180
+
181
+ export async function setCustomerDetails(env: Env, args: {
182
+ userSessionId: string;
183
+ firstName: string;
184
+ lastName: string;
185
+ email: string;
186
+ }): Promise<void> {
187
+ const r = await vistaCall(env, `/orders/${args.userSessionId}/customer-details`, {
188
+ method: "POST",
189
+ body: JSON.stringify({
190
+ firstName: args.firstName,
191
+ lastName: args.lastName,
192
+ email: args.email,
193
+ }),
194
+ });
195
+ if (!r.ok) throw new Error(`customer-details ${r.status}: ${await r.text()}`);
196
+ }
197
+
198
+ export async function validateMember(env: Env, args: {
199
+ userSessionId: string;
200
+ memberId: string;
201
+ }): Promise<unknown> {
202
+ return withFreshAuthRetry(env, async (auth) => {
203
+ const r = await fetch(env.VISTA_BASE + "/RESTLoyalty.svc/member/validate", {
204
+ method: "POST",
205
+ headers: { ...BASE_HEADERS, ...auth },
206
+ body: JSON.stringify({
207
+ UserSessionId: args.userSessionId,
208
+ MemberId: args.memberId,
209
+ ReturnMember: true,
210
+ }),
211
+ });
212
+ const text = await r.text();
213
+ if (!r.ok) throw new Error(`validate-member ${r.status}: ${text}`);
214
+ try { return JSON.parse(text); } catch { return { raw: text }; }
215
+ });
216
+ }
217
+
218
+ interface ValidateResponse {
219
+ Result: number;
220
+ ErrorDescription: string | null;
221
+ MemberTicketApprovals: Array<{
222
+ TicketTypeCode: string;
223
+ MemberProviderName?: string;
224
+ CardNumber?: string;
225
+ ApprovedPriceInCents: number;
226
+ ApprovedQty?: number;
227
+ }> | null;
228
+ }
229
+
230
+ function parseValidate(text: string): ValidateResponse {
231
+ return JSON.parse(text) as ValidateResponse;
232
+ }
233
+
234
+ function makeValidateBody(args: {
235
+ userSessionId: string;
236
+ cinemaVistaId: string;
237
+ sessionId: number;
238
+ ticketTypeCode: string;
239
+ unlimitedCard: string;
240
+ }): string {
241
+ return JSON.stringify({
242
+ SessionId: args.sessionId,
243
+ UserSessionId: args.userSessionId,
244
+ CinemaId: args.cinemaVistaId,
245
+ TicketTypes: [
246
+ {
247
+ TicketTypeCode: args.ticketTypeCode,
248
+ Qty: 1,
249
+ ThirdPartyMemberScheme: { MemberCard: args.unlimitedCard },
250
+ },
251
+ ],
252
+ });
253
+ }
254
+
255
+ export async function validateUnlimitedMember(env: Env, args: {
256
+ userSessionId: string;
257
+ cinemaVistaId: string;
258
+ sessionId: number;
259
+ ticketTypeCode: string;
260
+ unlimitedCard: string;
261
+ }): Promise<{
262
+ approved: boolean;
263
+ ticketTypeCode?: string;
264
+ approvedPriceInCents?: number;
265
+ raw: unknown;
266
+ }> {
267
+ return withFreshAuthRetry(env, async (auth) => {
268
+ const r = await fetch(env.VISTA_BASE + "/RESTTicketing.svc/order/validate/membertickets", {
269
+ method: "POST",
270
+ headers: { ...BASE_HEADERS, ...auth },
271
+ body: makeValidateBody(args),
272
+ });
273
+ const text = await r.text();
274
+ if (!r.ok) throw new Error(`validate-membertickets ${r.status}: ${text}`);
275
+ const j = parseValidate(text);
276
+ if (j.Result !== 0) throw new Error(`validate-membertickets: ${j.ErrorDescription}`);
277
+ const first = j.MemberTicketApprovals?.[0];
278
+ return {
279
+ approved: !!first,
280
+ ticketTypeCode: first?.TicketTypeCode,
281
+ approvedPriceInCents: first?.ApprovedPriceInCents,
282
+ raw: j as unknown,
283
+ };
284
+ });
285
+ }
286
+
287
+ function splitName(name: string | undefined): { firstName: string; lastName: string; fullName: string } {
288
+ const fullName = (name || "Yorck Member").trim();
289
+ const [firstName = "Yorck", ...rest] = fullName.split(/\s+/);
290
+ return { firstName, lastName: rest.join(" ") || "Member", fullName };
291
+ }
292
+
293
+ async function getSessionScreen(env: Env, fullSessionId: string): Promise<string | undefined> {
294
+ // Public Contentful delivery token embedded in yorck.de's frontend bundle.
295
+ // confirm-order requires this `screen` value, but Vista's order response
296
+ // does not include it for every order.
297
+ const url = `https://cdn.contentful.com/spaces/4mws6uyas4ta/environments/master/entries?sys.id=${encodeURIComponent(fullSessionId)}&locale=en-US`;
298
+ try {
299
+ const r = await fetch(url, {
300
+ headers: {
301
+ Authorization: "Bearer UNY_7-kVS3UkYxAMEIpyO2g7Lh-8e7645oGt2ksDhE8",
302
+ Accept: "application/json",
303
+ },
304
+ });
305
+ if (!r.ok) return undefined;
306
+ const j = await r.json() as { items?: Array<{ fields?: { screenName?: string } }> };
307
+ return j.items?.[0]?.fields?.screenName;
308
+ } catch {
309
+ return undefined;
310
+ }
311
+ }
312
+
313
+ // Full Unlimited booking pipeline up to a validated, zero-cost order.
314
+ // Caller is expected to call commitOrder() to actually finalize, or cancel().
315
+ export async function reserveUnlimited(env: Env, args: {
316
+ cinemaSlug: string;
317
+ sessionId: string; // full Yorck session id, e.g. "1009-5995"
318
+ rowLabel: string; // e.g. "16"
319
+ seatId: string; // e.g. "17"
320
+ }): Promise<{
321
+ order: OrderState;
322
+ approved: boolean;
323
+ ticketTypeCode?: string;
324
+ expiresAt: string;
325
+ }> {
326
+ if (!env.YORCK_UNLIMITED_CARD) throw new Error("YORCK_UNLIMITED_CARD secret not set");
327
+ if (!env.YORCK_EMAIL) throw new Error("YORCK_EMAIL secret not set");
328
+
329
+ const cinema = await getCinemaBySlug(env, args.cinemaSlug);
330
+ if (!cinema) throw new Error(`unknown cinema slug: ${args.cinemaSlug}`);
331
+ const [cidPart, sessionNum] = args.sessionId.split("-");
332
+ if (cidPart !== cinema.vistaId) {
333
+ throw new Error(`cinema slug ${args.cinemaSlug} (vista ${cinema.vistaId}) != session prefix ${cidPart}`);
334
+ }
335
+
336
+ // Resolve seat coordinates.
337
+ const plan = await getSeatPlan(env, cinema.vistaId, sessionNum);
338
+ const seat = findSeatByRowAndId(plan, args.rowLabel, args.seatId);
339
+ if (!seat) throw new Error(`seat Row ${args.rowLabel} Seat ${args.seatId} not found`);
340
+ if (seat.Status !== 0) throw new Error(`seat is not available (status ${seat.Status})`);
341
+
342
+ const orderInit = await createOrder(env, cinema.vistaId);
343
+ const usid = orderInit.userSessionId;
344
+
345
+ try {
346
+ const heldOrder = await holdSeat(env, {
347
+ userSessionId: usid,
348
+ sessionNum,
349
+ seat: {
350
+ areaNumber: seat.Position.AreaNumber,
351
+ rowIndex: seat.Position.RowIndex,
352
+ columnIndex: seat.Position.ColumnIndex,
353
+ },
354
+ });
355
+
356
+ const me = await whoAmI(env);
357
+ if (me.memberId) {
358
+ await validateMember(env, { userSessionId: usid, memberId: me.memberId });
359
+ }
360
+
361
+ const unlimited = await getUnlimitedTicket(env, cinema.vistaId, sessionNum, usid);
362
+ const v = await validateUnlimitedMember(env, {
363
+ userSessionId: usid,
364
+ cinemaVistaId: cinema.vistaId,
365
+ sessionId: parseInt(sessionNum, 10),
366
+ ticketTypeCode: unlimited.TicketTypeCode,
367
+ unlimitedCard: env.YORCK_UNLIMITED_CARD,
368
+ });
369
+
370
+ const heldSeat = heldOrder.sessions[0]?.tickets[0]?.seats[0];
371
+ const ticketSeat: OrderSeat = {
372
+ ...(heldSeat ?? {}),
373
+ areaNumber: heldSeat?.areaNumber ?? seat.Position.AreaNumber,
374
+ rowIndex: heldSeat?.rowIndex ?? seat.Position.RowIndex,
375
+ columnIndex: heldSeat?.columnIndex ?? seat.Position.ColumnIndex,
376
+ };
377
+
378
+ const orderWithTicket = await setUnlimitedTicket(env, {
379
+ userSessionId: usid,
380
+ sessionNum,
381
+ ticketTypeCode: unlimited.TicketTypeCode,
382
+ unlimitedCard: env.YORCK_UNLIMITED_CARD,
383
+ seat: ticketSeat,
384
+ });
385
+
386
+ const name = splitName(me.name);
387
+ await setCustomerDetails(env, {
388
+ userSessionId: usid,
389
+ firstName: name.firstName,
390
+ lastName: name.lastName,
391
+ email: me.email || env.YORCK_EMAIL!,
392
+ });
393
+
394
+ const order = await getOrder(env, usid).catch(() => orderWithTicket);
395
+ return {
396
+ order,
397
+ approved: v.approved,
398
+ ticketTypeCode: v.ticketTypeCode ?? unlimited.TicketTypeCode,
399
+ expiresAt: order.expiryDateUtc,
400
+ };
401
+ } catch (e) {
402
+ // Best-effort cancel on failure.
403
+ await cancelOrder(env, usid).catch(() => {});
404
+ throw e;
405
+ }
406
+ }
407
+
408
+ // Finalize the validated Unlimited order. The React app uses the payment
409
+ // confirm-order wrapper even for €0 Unlimited orders, so mirror that path.
410
+ export async function commitOrder(env: Env, userSessionId: string): Promise<unknown> {
411
+ const me = await whoAmI(env);
412
+ const name = splitName(me.name);
413
+ await setCustomerDetails(env, {
414
+ userSessionId,
415
+ firstName: name.firstName,
416
+ lastName: name.lastName,
417
+ email: me.email || env.YORCK_EMAIL || "",
418
+ });
419
+
420
+ const order = await getOrder(env, userSessionId).catch(() => undefined);
421
+ const session = order?.sessions?.[0];
422
+ const fullSessionId = order?.cinemaId && session?.id ? `${order.cinemaId}-${session.id}` : undefined;
423
+ const screen = session?.screen ?? (fullSessionId ? await getSessionScreen(env, fullSessionId) : undefined) ?? "1";
424
+
425
+ return withFreshAuthRetry(env, async (auth) => {
426
+ const r = await fetch(apiBase(env) + "/payment/confirm-order", {
427
+ method: "POST",
428
+ headers: { ...BASE_HEADERS, ...auth },
429
+ body: JSON.stringify({
430
+ userSessionId,
431
+ screen,
432
+ locale: "en",
433
+ name: name.fullName,
434
+ email: me.email || env.YORCK_EMAIL || "",
435
+ ics: "",
436
+ }),
437
+ });
438
+ const txt = await r.text();
439
+ if (!r.ok) throw new Error(`confirm-order ${r.status}: ${txt}`);
440
+ try { return JSON.parse(txt); } catch { return { raw: txt }; }
441
+ });
442
+ }