@abdssamie/adyen-payments 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.
Files changed (69) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +258 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +206 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +566 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +36 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +215 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +3 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/private.d.ts +71 -0
  32. package/dist/component/private.d.ts.map +1 -0
  33. package/dist/component/private.js +250 -0
  34. package/dist/component/private.js.map +1 -0
  35. package/dist/component/public.d.ts +170 -0
  36. package/dist/component/public.d.ts.map +1 -0
  37. package/dist/component/public.js +210 -0
  38. package/dist/component/public.js.map +1 -0
  39. package/dist/component/schema.d.ts +101 -0
  40. package/dist/component/schema.d.ts.map +1 -0
  41. package/dist/component/schema.js +63 -0
  42. package/dist/component/schema.js.map +1 -0
  43. package/dist/react/hooks.d.ts +182 -0
  44. package/dist/react/hooks.d.ts.map +1 -0
  45. package/dist/react/hooks.js +215 -0
  46. package/dist/react/hooks.js.map +1 -0
  47. package/dist/react/index.d.ts +3 -0
  48. package/dist/react/index.d.ts.map +1 -0
  49. package/dist/react/index.js +3 -0
  50. package/dist/react/index.js.map +1 -0
  51. package/package.json +104 -0
  52. package/src/client/_generated/_ignore.ts +1 -0
  53. package/src/client/index.test.ts +196 -0
  54. package/src/client/index.ts +823 -0
  55. package/src/client/setup.test.ts +26 -0
  56. package/src/client/webhooks.test.ts +182 -0
  57. package/src/component/_generated/api.ts +52 -0
  58. package/src/component/_generated/component.ts +293 -0
  59. package/src/component/_generated/dataModel.ts +60 -0
  60. package/src/component/_generated/server.ts +156 -0
  61. package/src/component/convex.config.ts +3 -0
  62. package/src/component/private.ts +277 -0
  63. package/src/component/public.test.ts +92 -0
  64. package/src/component/public.ts +229 -0
  65. package/src/component/schema.ts +67 -0
  66. package/src/component/setup.test.ts +11 -0
  67. package/src/react/hooks.ts +488 -0
  68. package/src/react/index.ts +18 -0
  69. package/src/test.ts +18 -0
@@ -0,0 +1,823 @@
1
+ "use node";
2
+
3
+ import { Client, CheckoutAPI, hmacValidator } from "@adyen/api-library";
4
+ import type { Types } from "@adyen/api-library";
5
+ import type {
6
+ CreateCheckoutSessionRequest,
7
+ PaymentRequest,
8
+ CardDetails,
9
+ StoredPaymentMethod,
10
+ } from "@adyen/api-library/lib/src/typings/checkout/models.js";
11
+ import type {
12
+ GenericActionCtx,
13
+ GenericMutationCtx,
14
+ GenericQueryCtx,
15
+ GenericDataModel,
16
+ HttpRouter,
17
+ } from "convex/server";
18
+ import { httpActionGeneric } from "convex/server";
19
+ import type { ComponentApi } from "../component/_generated/component.js";
20
+ import { NotificationRequestItem } from "@adyen/api-library/lib/src/typings/notification/models.js";
21
+
22
+ // Convenient types for ctx args, matching Stripe component
23
+ export type QueryCtx = Pick<GenericQueryCtx<GenericDataModel>, "runQuery">;
24
+ export type MutationCtx = Pick<
25
+ GenericMutationCtx<GenericDataModel>,
26
+ "runQuery" | "runMutation"
27
+ >;
28
+ export type ActionCtx = Pick<
29
+ GenericActionCtx<GenericDataModel>,
30
+ "runQuery" | "runMutation" | "runAction"
31
+ >;
32
+
33
+ export type AdyenComponent = ComponentApi;
34
+
35
+ export const EventCodeEnum = NotificationRequestItem.EventCodeEnum;
36
+
37
+ export interface AdyenPaymentsOptions {
38
+ ADYEN_API_KEY?: string;
39
+ ADYEN_MERCHANT_ACCOUNT?: string;
40
+ ADYEN_ENVIRONMENT?: "TEST" | "LIVE";
41
+ /**
42
+ * Default delay in hours before capturing the payment.
43
+ * - `0` (default): Capture immediately (Auto-capture).
44
+ * - `-1`: Manual capture (must be captured via API later).
45
+ * - `N` (greater than 0): Capture automatically after N hours.
46
+ */
47
+ captureDelayHours?: number;
48
+ /**
49
+ * Whether to capture the payment automatically.
50
+ * If true (default), capture immediately.
51
+ * If false, perform manual capture (must be captured via API later).
52
+ */
53
+ autoCapture?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Adyen Payments Client
58
+ *
59
+ * Provides methods for managing shoppers, stored card payment methods,
60
+ * and performing checkout sessions or payments via the Adyen API.
61
+ */
62
+ export class AdyenPayments {
63
+ private _apiKey: string;
64
+ private _merchantAccount: string;
65
+ private _environment: "TEST" | "LIVE";
66
+ public captureDelayHours: number;
67
+
68
+ constructor(
69
+ public component: AdyenComponent,
70
+ options?: AdyenPaymentsOptions
71
+ ) {
72
+ this._apiKey = options?.ADYEN_API_KEY ?? process.env.ADYEN_API_KEY!;
73
+ this._merchantAccount =
74
+ options?.ADYEN_MERCHANT_ACCOUNT ?? process.env.ADYEN_MERCHANT_ACCOUNT!;
75
+ const env = options?.ADYEN_ENVIRONMENT ?? process.env.ADYEN_ENVIRONMENT;
76
+ this._environment = env === "LIVE" ? "LIVE" : "TEST";
77
+ if (options?.autoCapture === false) {
78
+ this.captureDelayHours = -1;
79
+ } else if (options?.autoCapture === true && options?.captureDelayHours === -1) {
80
+ this.captureDelayHours = 0;
81
+ } else {
82
+ this.captureDelayHours = options?.captureDelayHours ?? 0;
83
+ }
84
+ }
85
+
86
+ get apiKey(): string {
87
+ if (!this._apiKey) {
88
+ throw new Error("ADYEN_API_KEY environment variable is not set");
89
+ }
90
+ return this._apiKey;
91
+ }
92
+
93
+ get merchantAccount(): string {
94
+ if (!this._merchantAccount) {
95
+ throw new Error("ADYEN_MERCHANT_ACCOUNT environment variable is not set");
96
+ }
97
+ return this._merchantAccount;
98
+ }
99
+
100
+ get environment(): "TEST" | "LIVE" {
101
+ return this._environment;
102
+ }
103
+
104
+ /**
105
+ * Helper to initialize the official Adyen SDK Client
106
+ */
107
+ private getAdyenClient(): CheckoutAPI {
108
+ const client = new Client({
109
+ apiKey: this.apiKey,
110
+ environment: this.environment,
111
+ });
112
+ return new CheckoutAPI(client);
113
+ }
114
+
115
+ // ============================================================================
116
+ // SHOPPER MANAGEMENT
117
+ // ============================================================================
118
+
119
+ /**
120
+ * Manually create or update a shopper mapping in the database.
121
+ */
122
+ async createShopper(
123
+ ctx: ActionCtx,
124
+ args: {
125
+ shopperReference: string;
126
+ email?: string;
127
+ name?: string;
128
+ userId?: string;
129
+ metadata?: Record<string, unknown>;
130
+ }
131
+ ): Promise<string> {
132
+ return await ctx.runMutation(this.component.public.createOrUpdateShopper, {
133
+ shopperReference: args.shopperReference,
134
+ email: args.email,
135
+ name: args.name,
136
+ userId: args.userId,
137
+ metadata: args.metadata,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Get or create a shopper for a user ID.
143
+ * Checks database indexes first to avoid creating duplicates.
144
+ */
145
+ async getOrCreateShopper(
146
+ ctx: ActionCtx,
147
+ args: {
148
+ userId: string;
149
+ email?: string;
150
+ name?: string;
151
+ }
152
+ ): Promise<{ shopperReference: string; isNew: boolean }> {
153
+ // Check by user ID first
154
+ const existingByUserId = await ctx.runQuery(
155
+ this.component.public.getShopperByUserId,
156
+ { userId: args.userId }
157
+ );
158
+ if (existingByUserId) {
159
+ return { shopperReference: existingByUserId.shopperReference, isNew: false };
160
+ }
161
+
162
+ // Check by email second
163
+ if (args.email) {
164
+ const existingByEmail = await ctx.runQuery(
165
+ this.component.public.getShopperByEmail,
166
+ { email: args.email }
167
+ );
168
+ if (existingByEmail) {
169
+ return { shopperReference: existingByEmail.shopperReference, isNew: false };
170
+ }
171
+ }
172
+
173
+ // Otherwise, create a new shopper mapping using the userId as shopperReference
174
+ await ctx.runMutation(this.component.public.createOrUpdateShopper, {
175
+ shopperReference: args.userId,
176
+ userId: args.userId,
177
+ email: args.email,
178
+ name: args.name,
179
+ });
180
+
181
+ return { shopperReference: args.userId, isNew: true };
182
+ }
183
+
184
+ // ============================================================================
185
+ // CHECKOUT SESSIONS
186
+ // ============================================================================
187
+
188
+ /**
189
+ * Create an Adyen Checkout Session to initialize Drop-in/Components SDK.
190
+ */
191
+ async createCheckoutSession(
192
+ ctx: ActionCtx,
193
+ args: {
194
+ amount: number; // minor units
195
+ currency: string;
196
+ successUrl: string;
197
+ cancelUrl: string;
198
+ shopperReference?: string;
199
+ captureDelayHours?: number;
200
+ autoCapture?: boolean;
201
+ metadata?: Record<string, string>;
202
+ }
203
+ ) {
204
+ const checkout = this.getAdyenClient();
205
+ const merchantReference = `sess_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
206
+
207
+ let captureDelayHours = args.captureDelayHours;
208
+ if (args.autoCapture === false) {
209
+ captureDelayHours = -1;
210
+ } else if (args.autoCapture === true) {
211
+ if (captureDelayHours === undefined || captureDelayHours === -1) {
212
+ captureDelayHours = 0;
213
+ }
214
+ } else if (captureDelayHours === undefined) {
215
+ captureDelayHours = this.captureDelayHours;
216
+ }
217
+
218
+ const response = await checkout.PaymentsApi.sessions({
219
+ amount: { value: args.amount, currency: args.currency },
220
+ reference: merchantReference,
221
+ merchantAccount: this.merchantAccount,
222
+ returnUrl: args.successUrl,
223
+ shopperReference: args.shopperReference,
224
+ captureDelayHours: captureDelayHours,
225
+ // If shopper is logged in, enable saved payment methods and ask for consent
226
+ ...(args.shopperReference && {
227
+ storePaymentMethodMode: "askForConsent" as CreateCheckoutSessionRequest.StorePaymentMethodModeEnum,
228
+ recurringProcessingModel: "Subscription" as CreateCheckoutSessionRequest.RecurringProcessingModelEnum,
229
+ }),
230
+ });
231
+
232
+ // Persist session locally
233
+ await ctx.runMutation(this.component.private.insertCheckoutSession, {
234
+ sessionId: response.id,
235
+ sessionData: response.sessionData || "",
236
+ shopperReference: args.shopperReference,
237
+ merchantReference,
238
+ amount: args.amount,
239
+ currency: args.currency,
240
+ url: response.url || undefined,
241
+ autoCapture: captureDelayHours === 0,
242
+ });
243
+
244
+ return {
245
+ sessionId: response.id,
246
+ sessionData: response.sessionData || "",
247
+ url: response.url || null,
248
+ merchantReference,
249
+ };
250
+ }
251
+
252
+ // ============================================================================
253
+ // STORED PAYMENT DETAILS
254
+ // ============================================================================
255
+
256
+ /**
257
+ * Retrieve and sync tokenized payment methods stored for a shopper.
258
+ */
259
+ async listStoredPaymentMethods(
260
+ ctx: ActionCtx,
261
+ args: { shopperReference: string }
262
+ ) {
263
+ const checkout = this.getAdyenClient();
264
+
265
+ const response = await checkout.PaymentsApi.paymentMethods({
266
+ merchantAccount: this.merchantAccount,
267
+ shopperReference: args.shopperReference,
268
+ });
269
+
270
+ const storedMethods = response.storedPaymentMethods || [];
271
+ const mappedMethods = storedMethods.map((m: StoredPaymentMethod) => ({
272
+ recurringDetailReference: m.id || "",
273
+ variant: m.brand || m.type || "unknown",
274
+ cardLast4: m.lastFour || undefined,
275
+ cardExpiryMonth: m.expiryMonth || undefined,
276
+ cardExpiryYear: m.expiryYear || undefined,
277
+ }));
278
+
279
+ // Update database state
280
+ await ctx.runMutation(this.component.private.syncPaymentMethods, {
281
+ shopperReference: args.shopperReference,
282
+ paymentMethods: mappedMethods,
283
+ });
284
+
285
+ return mappedMethods;
286
+ }
287
+
288
+ /**
289
+ * Disable/delete a stored payment method token in Adyen and local database.
290
+ */
291
+ async deleteStoredPaymentMethod(
292
+ ctx: ActionCtx,
293
+ args: { shopperReference: string; recurringDetailReference: string }
294
+ ): Promise<null> {
295
+ const checkout = this.getAdyenClient();
296
+
297
+ await checkout.RecurringApi.deleteTokenForStoredPaymentDetails(
298
+ args.recurringDetailReference,
299
+ args.shopperReference,
300
+ this.merchantAccount
301
+ );
302
+
303
+ // Retrieve current methods, filter out the disabled one, and sync db
304
+ const currentMethods = await ctx.runQuery(
305
+ this.component.public.listPaymentMethods,
306
+ { shopperReference: args.shopperReference }
307
+ );
308
+
309
+ const updatedMethods = currentMethods
310
+ .filter((m) => m.recurringDetailReference !== args.recurringDetailReference)
311
+ .map(({ shopperReference: _shopperReference, ...rest }) => rest);
312
+
313
+ await ctx.runMutation(this.component.private.syncPaymentMethods, {
314
+ shopperReference: args.shopperReference,
315
+ paymentMethods: updatedMethods,
316
+ });
317
+
318
+ return null;
319
+ }
320
+
321
+ // ============================================================================
322
+ // RECURRING CHARGES & PAYMENTS
323
+ // ============================================================================
324
+
325
+ /**
326
+ * Charge a stored payment method token (Merchant Initiated Transaction / Subscription).
327
+ */
328
+ async chargeStoredCard(
329
+ ctx: ActionCtx,
330
+ args: {
331
+ shopperReference: string;
332
+ recurringDetailReference: string;
333
+ amount: number; // minor units
334
+ currency: string;
335
+ reference?: string;
336
+ returnUrl?: string;
337
+ captureDelayHours?: number;
338
+ autoCapture?: boolean;
339
+ metadata?: Record<string, unknown>;
340
+ }
341
+ ) {
342
+ const checkout = this.getAdyenClient();
343
+ const merchantReference =
344
+ args.reference ?? `rec_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
345
+
346
+ const returnUrl = args.returnUrl ?? process.env.APP_URL;
347
+ if (!returnUrl) {
348
+ throw new Error(
349
+ "returnUrl must be provided in chargeStoredCard arguments or configured via the APP_URL environment variable"
350
+ );
351
+ }
352
+
353
+ let captureDelayHours = args.captureDelayHours;
354
+ if (args.autoCapture === false) {
355
+ captureDelayHours = -1;
356
+ } else if (args.autoCapture === true) {
357
+ if (captureDelayHours === undefined || captureDelayHours === -1) {
358
+ captureDelayHours = 0;
359
+ }
360
+ } else if (captureDelayHours === undefined) {
361
+ captureDelayHours = this.captureDelayHours;
362
+ }
363
+
364
+ const response = await checkout.PaymentsApi.payments({
365
+ amount: { value: args.amount, currency: args.currency },
366
+ reference: merchantReference,
367
+ merchantAccount: this.merchantAccount,
368
+ shopperReference: args.shopperReference,
369
+ returnUrl,
370
+ paymentMethod: {
371
+ type: "scheme" as CardDetails.TypeEnum,
372
+ storedPaymentMethodId: args.recurringDetailReference,
373
+ } as CardDetails,
374
+ shopperInteraction: "ContAuth" as PaymentRequest.ShopperInteractionEnum,
375
+ recurringProcessingModel: "Subscription" as PaymentRequest.RecurringProcessingModelEnum,
376
+ captureDelayHours: captureDelayHours,
377
+ });
378
+
379
+ const isAutoCapture = captureDelayHours === 0;
380
+ const status =
381
+ response.resultCode === "Authorised"
382
+ ? (isAutoCapture ? "captured" : "authorised")
383
+ : response.resultCode?.toLowerCase() || "refused";
384
+
385
+ // Record transaction
386
+ await ctx.runMutation(this.component.private.recordPayment, {
387
+ pspReference: response.pspReference || "unknown",
388
+ shopperReference: args.shopperReference,
389
+ merchantReference,
390
+ amount: args.amount,
391
+ currency: args.currency,
392
+ status,
393
+ paymentMethod: response.paymentMethod?.type || "scheme",
394
+ metadata: args.metadata,
395
+ });
396
+
397
+ return {
398
+ pspReference: response.pspReference || null,
399
+ status,
400
+ resultCode: response.resultCode || "Refused",
401
+ merchantReference,
402
+ };
403
+ }
404
+
405
+ // ============================================================================
406
+ // PAYMENT MODIFICATIONS
407
+ // ============================================================================
408
+
409
+ /**
410
+ * Capture an authorised payment.
411
+ */
412
+ async capturePayment(
413
+ ctx: ActionCtx,
414
+ args: {
415
+ pspReference: string;
416
+ amount: number;
417
+ currency: string;
418
+ }
419
+ ) {
420
+ const checkout = this.getAdyenClient();
421
+
422
+ const response = await checkout.ModificationsApi.captureAuthorisedPayment(
423
+ args.pspReference,
424
+ {
425
+ merchantAccount: this.merchantAccount,
426
+ amount: { value: args.amount, currency: args.currency },
427
+ }
428
+ );
429
+
430
+ await ctx.runMutation(this.component.private.updatePaymentStatus, {
431
+ pspReference: args.pspReference,
432
+ status: "captured",
433
+ });
434
+
435
+ return {
436
+ pspReference: response.pspReference,
437
+ status: response.status,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Refund a captured payment.
443
+ */
444
+ async refundPayment(
445
+ ctx: ActionCtx,
446
+ args: {
447
+ pspReference: string;
448
+ amount: number;
449
+ currency: string;
450
+ }
451
+ ) {
452
+ const checkout = this.getAdyenClient();
453
+
454
+ const response = await checkout.ModificationsApi.refundCapturedPayment(
455
+ args.pspReference,
456
+ {
457
+ merchantAccount: this.merchantAccount,
458
+ amount: { value: args.amount, currency: args.currency },
459
+ }
460
+ );
461
+
462
+ await ctx.runMutation(this.component.private.updatePaymentStatus, {
463
+ pspReference: args.pspReference,
464
+ status: "refunded",
465
+ });
466
+
467
+ return {
468
+ pspReference: response.pspReference,
469
+ status: response.status,
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Cancel an uncaptured authorized payment.
475
+ */
476
+ async cancelPayment(
477
+ ctx: ActionCtx,
478
+ args: {
479
+ pspReference: string;
480
+ }
481
+ ) {
482
+ const checkout = this.getAdyenClient();
483
+
484
+ const response = await checkout.ModificationsApi.cancelAuthorisedPaymentByPspReference(
485
+ args.pspReference,
486
+ { merchantAccount: this.merchantAccount }
487
+ );
488
+
489
+ await ctx.runMutation(this.component.private.updatePaymentStatus, {
490
+ pspReference: args.pspReference,
491
+ status: "cancelled",
492
+ });
493
+
494
+ return {
495
+ pspReference: response.pspReference,
496
+ status: response.status,
497
+ };
498
+ }
499
+ }
500
+
501
+ export type AdyenNotificationItem = Types.notification.NotificationRequestItem & {
502
+ shopperReference?: string;
503
+ };
504
+
505
+ export interface AdyenEventHandlers {
506
+ [eventCode: string]: (
507
+ ctx: ActionCtx,
508
+ notification: AdyenNotificationItem
509
+ ) => Promise<void>;
510
+ }
511
+
512
+ export interface RegisterRoutesConfig {
513
+ webhookPath?: string;
514
+ ADYEN_HMAC_KEY?: string;
515
+ events?: AdyenEventHandlers;
516
+ onNotification?: (
517
+ ctx: ActionCtx,
518
+ notification: AdyenNotificationItem
519
+ ) => Promise<void>;
520
+ }
521
+
522
+ /**
523
+ * Creates a raw webhook handler function for use with `httpAction` in a
524
+ * `"use node"` file.
525
+ *
526
+ * Because `http.ts` cannot have the `"use node"` directive, the recommended
527
+ * pattern is:
528
+ *
529
+ * ```ts
530
+ * // adyenWebhooks.ts — "use node"
531
+ * import { httpAction } from "./_generated/server";
532
+ * import { components } from "./_generated/api";
533
+ * import { createWebhookHandler } from "@abdssamie/adyen-payments";
534
+ *
535
+ * export const webhookHandler = httpAction(
536
+ * createWebhookHandler(components.adyenPayments, { ... })
537
+ * );
538
+ *
539
+ * // http.ts — no directive
540
+ * import { httpRouter } from "convex/server";
541
+ * import { webhookHandler } from "./adyenWebhooks";
542
+ *
543
+ * const http = httpRouter();
544
+ * http.route({ path: "/adyen/webhooks", method: "POST", handler: webhookHandler });
545
+ * export default http;
546
+ * ```
547
+ */
548
+ export function createWebhookHandler(
549
+ component: AdyenComponent,
550
+ config?: Omit<RegisterRoutesConfig, "webhookPath">
551
+ ): (
552
+ ctx: GenericActionCtx<GenericDataModel>,
553
+ req: Request
554
+ ) => Promise<Response> {
555
+ const eventHandlers = config?.events ?? {};
556
+
557
+ return async (ctx, req) => {
558
+ const hmacKey = config?.ADYEN_HMAC_KEY || process.env.ADYEN_HMAC_KEY;
559
+
560
+ if (!hmacKey) {
561
+ console.error("❌ ADYEN_HMAC_KEY is not set");
562
+ return new Response("HMAC key not configured", { status: 500 });
563
+ }
564
+
565
+ let bodyText: string;
566
+ try {
567
+ bodyText = await req.text();
568
+ } catch (err) {
569
+ console.error("❌ Failed to read request body:", err);
570
+ return new Response("Failed to read body", { status: 400 });
571
+ }
572
+
573
+ let payload: {
574
+ notificationItems?: Array<{
575
+ NotificationRequestItem?: AdyenNotificationItem;
576
+ }>;
577
+ };
578
+ try {
579
+ payload = JSON.parse(bodyText);
580
+ } catch (err) {
581
+ console.error("❌ Failed to parse JSON:", err);
582
+ return new Response("Invalid JSON", { status: 400 });
583
+ }
584
+
585
+ const notificationItems = payload.notificationItems;
586
+ if (!Array.isArray(notificationItems)) {
587
+ console.error("❌ Invalid Adyen notification format");
588
+ return new Response("Invalid notification format", { status: 400 });
589
+ }
590
+
591
+ const validator = new hmacValidator();
592
+
593
+ for (const wrapper of notificationItems) {
594
+ const item = wrapper.NotificationRequestItem;
595
+ if (!item) continue;
596
+
597
+ // Verify HMAC signature
598
+ let isValid = false;
599
+ try {
600
+ isValid = validator.validateHMAC(item, hmacKey);
601
+ } catch (err) {
602
+ console.error("❌ HMAC validation threw an error:", err);
603
+ }
604
+
605
+ if (!isValid) {
606
+ console.error(
607
+ "❌ Adyen Webhook signature verification failed for PSP:",
608
+ item.pspReference
609
+ );
610
+ return new Response("Invalid signature", { status: 401 });
611
+ }
612
+
613
+ // Process notification with default DB sync handler
614
+ try {
615
+ await processNotification(ctx, component, item);
616
+
617
+ // Call generic handler if provided
618
+ if (config?.onNotification) {
619
+ await config.onNotification(ctx, item);
620
+ }
621
+
622
+ // Call custom event-specific handler if provided
623
+ const eventCode = item.eventCode as unknown as string;
624
+ const customHandler = eventHandlers[eventCode];
625
+ if (customHandler) {
626
+ await customHandler(ctx, item);
627
+ }
628
+ } catch (err) {
629
+ console.error("❌ Error processing webhook notification:", err);
630
+ return new Response("Error processing notification", { status: 500 });
631
+ }
632
+ }
633
+
634
+ // Adyen requires returning "[accepted]" to acknowledge receipt
635
+ return new Response("[accepted]", {
636
+ status: 200,
637
+ headers: { "Content-Type": "text/plain" },
638
+ });
639
+ };
640
+ }
641
+
642
+ /**
643
+ * Convenience helper that registers the Adyen webhook route directly on an
644
+ * `HttpRouter`.
645
+ *
646
+ * @deprecated Prefer `createWebhookHandler` + `httpAction` in a `"use node"`
647
+ * file — see the `createWebhookHandler` JSDoc for the recommended pattern.
648
+ * This function must be called from a `"use node"` file when used.
649
+ */
650
+ export function registerRoutes(
651
+ http: HttpRouter,
652
+ component: AdyenComponent,
653
+ config?: RegisterRoutesConfig
654
+ ) {
655
+ const webhookPath = config?.webhookPath ?? "/adyen/webhooks";
656
+ const { webhookPath: _drop, ...handlerConfig } = config ?? {};
657
+
658
+ http.route({
659
+ path: webhookPath,
660
+ method: "POST",
661
+ handler: httpActionGeneric(createWebhookHandler(component, handlerConfig)),
662
+ });
663
+ }
664
+
665
+ async function processNotification(
666
+ ctx: ActionCtx,
667
+ component: AdyenComponent,
668
+ item: AdyenNotificationItem
669
+ ): Promise<void> {
670
+ const eventCode = item.eventCode;
671
+ const success = (item.success as unknown as string) === "true";
672
+ const pspReference = item.pspReference;
673
+ const originalReference = item.originalReference;
674
+ const merchantReference = item.merchantReference;
675
+ const amountValue = item.amount?.value;
676
+ const amountCurrency = item.amount?.currency;
677
+
678
+ let shopperReference = item.shopperReference;
679
+ let isAutoCapture = false;
680
+ if (merchantReference) {
681
+ const session = await ctx.runQuery(
682
+ component.public.getCheckoutSessionByMerchantReference,
683
+ { merchantReference }
684
+ );
685
+ if (session) {
686
+ if (session.shopperReference) {
687
+ shopperReference = session.shopperReference;
688
+ }
689
+ isAutoCapture = session.autoCapture ?? false;
690
+ }
691
+ }
692
+
693
+ switch (eventCode) {
694
+ case EventCodeEnum.Authorisation: {
695
+ console.log("📥 AUTHORISATION webhook incoming payload:", JSON.stringify(item, null, 2));
696
+ const status = success
697
+ ? (isAutoCapture ? "captured" : "authorised")
698
+ : "refused";
699
+
700
+ await ctx.runMutation(component.private.recordPayment, {
701
+ pspReference,
702
+ originalReference,
703
+ merchantReference,
704
+ amount: amountValue ?? 0,
705
+ currency: amountCurrency ?? "unknown",
706
+ status,
707
+ paymentMethod: item.paymentMethod,
708
+ shopperReference,
709
+ });
710
+
711
+ if (merchantReference) {
712
+ const sessionStatus = success ? "completed" : "refused";
713
+ await ctx.runMutation(component.private.updateCheckoutSessionStatus, {
714
+ merchantReference,
715
+ status: sessionStatus,
716
+ });
717
+ }
718
+
719
+ if (success && item.additionalData) {
720
+ const recurringDetailReference = item.additionalData["recurring.recurringDetailReference"];
721
+ const variant = item.additionalData["paymentMethod"] || item.paymentMethod || "scheme";
722
+ const cardLast4 = item.additionalData["cardSummary"];
723
+
724
+ const expiryDate = item.additionalData["expiryDate"];
725
+ let cardExpiryMonth: string | undefined;
726
+ let cardExpiryYear: string | undefined;
727
+ if (expiryDate && expiryDate.includes("/")) {
728
+ const parts = expiryDate.split("/");
729
+ cardExpiryMonth = parts[0];
730
+ cardExpiryYear = parts[1];
731
+ }
732
+
733
+ if (recurringDetailReference && shopperReference) {
734
+ await ctx.runMutation(component.private.insertPaymentMethod, {
735
+ shopperReference,
736
+ recurringDetailReference,
737
+ variant,
738
+ cardLast4,
739
+ cardExpiryMonth,
740
+ cardExpiryYear,
741
+ });
742
+ }
743
+ }
744
+ break;
745
+ }
746
+
747
+ case EventCodeEnum.RecurringContract: {
748
+ if (success && item.additionalData && shopperReference) {
749
+ const recurringDetailReference = item.additionalData["recurring.recurringDetailReference"];
750
+ const variant = item.additionalData["paymentMethod"] || item.paymentMethod || "scheme";
751
+ const cardLast4 = item.additionalData["cardSummary"];
752
+
753
+ const expiryDate = item.additionalData["expiryDate"];
754
+ let cardExpiryMonth: string | undefined;
755
+ let cardExpiryYear: string | undefined;
756
+ if (expiryDate && expiryDate.includes("/")) {
757
+ const parts = expiryDate.split("/");
758
+ cardExpiryMonth = parts[0];
759
+ cardExpiryYear = parts[1];
760
+ }
761
+
762
+ if (recurringDetailReference) {
763
+ await ctx.runMutation(component.private.insertPaymentMethod, {
764
+ shopperReference,
765
+ recurringDetailReference,
766
+ variant,
767
+ cardLast4,
768
+ cardExpiryMonth,
769
+ cardExpiryYear,
770
+ });
771
+ }
772
+ }
773
+ break;
774
+ }
775
+
776
+ case EventCodeEnum.Capture: {
777
+ const status = success ? "captured" : "capture_failed";
778
+ const targetReference = originalReference || pspReference;
779
+ await ctx.runMutation(component.private.updatePaymentStatus, {
780
+ pspReference: targetReference,
781
+ status,
782
+ originalReference: pspReference,
783
+ });
784
+ break;
785
+ }
786
+
787
+ case EventCodeEnum.Refund: {
788
+ const status = success ? "refunded" : "refund_failed";
789
+ const targetReference = originalReference || pspReference;
790
+ await ctx.runMutation(component.private.updatePaymentStatus, {
791
+ pspReference: targetReference,
792
+ status,
793
+ originalReference: pspReference,
794
+ });
795
+ break;
796
+ }
797
+
798
+ case EventCodeEnum.Cancellation: {
799
+ const status = success ? "cancelled" : "cancel_failed";
800
+ const targetReference = originalReference || pspReference;
801
+ await ctx.runMutation(component.private.updatePaymentStatus, {
802
+ pspReference: targetReference,
803
+ status,
804
+ originalReference: pspReference,
805
+ });
806
+ break;
807
+ }
808
+
809
+ case EventCodeEnum.CancelOrRefund: {
810
+ if (success) {
811
+ const action = item.additionalData?.["modification.action"];
812
+ const status = action === "cancel" ? "cancelled" : "refunded";
813
+ const targetReference = originalReference || pspReference;
814
+ await ctx.runMutation(component.private.updatePaymentStatus, {
815
+ pspReference: targetReference,
816
+ status,
817
+ originalReference: pspReference,
818
+ });
819
+ }
820
+ break;
821
+ }
822
+ }
823
+ }