@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.
- package/LICENSE +201 -0
- package/README.md +258 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +206 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +566 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +36 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +215 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/private.d.ts +71 -0
- package/dist/component/private.d.ts.map +1 -0
- package/dist/component/private.js +250 -0
- package/dist/component/private.js.map +1 -0
- package/dist/component/public.d.ts +170 -0
- package/dist/component/public.d.ts.map +1 -0
- package/dist/component/public.js +210 -0
- package/dist/component/public.js.map +1 -0
- package/dist/component/schema.d.ts +101 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +63 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/react/hooks.d.ts +182 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +215 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +104 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.test.ts +196 -0
- package/src/client/index.ts +823 -0
- package/src/client/setup.test.ts +26 -0
- package/src/client/webhooks.test.ts +182 -0
- package/src/component/_generated/api.ts +52 -0
- package/src/component/_generated/component.ts +293 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/private.ts +277 -0
- package/src/component/public.test.ts +92 -0
- package/src/component/public.ts +229 -0
- package/src/component/schema.ts +67 -0
- package/src/component/setup.test.ts +11 -0
- package/src/react/hooks.ts +488 -0
- package/src/react/index.ts +18 -0
- 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
|
+
}
|