convex-flutterwave 0.0.1
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 +504 -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 +192 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +364 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +34 -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 +141 -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 +133 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +80 -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 +4 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/lib.d.ts +117 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +223 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/schema.d.ts +73 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +43 -0
- package/dist/component/schema.js.map +1 -0
- package/package.json +106 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.ts +565 -0
- package/src/client/setup.test.ts +26 -0
- package/src/component/_generated/api.ts +50 -0
- package/src/component/_generated/component.ts +183 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +169 -0
- package/src/component/convex.config.ts +5 -0
- package/src/component/lib.test.ts +110 -0
- package/src/component/lib.ts +251 -0
- package/src/component/schema.ts +50 -0
- package/src/component/setup.test.ts +11 -0
- package/src/test.ts +18 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { httpActionGeneric } from "convex/server";
|
|
2
|
+
import type { GenericActionCtx, GenericDataModel } from "convex/server";
|
|
3
|
+
import type { ComponentApi } from "../component/_generated/component.js";
|
|
4
|
+
|
|
5
|
+
const FLUTTERWAVE_API_BASE = "https://api.flutterwave.com/v3";
|
|
6
|
+
|
|
7
|
+
export type FlutterwaveOptions = {
|
|
8
|
+
secretKey: string;
|
|
9
|
+
webhookSecretHash: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type FlutterwavePaymentOption =
|
|
13
|
+
| "card"
|
|
14
|
+
| "account"
|
|
15
|
+
| "banktransfer"
|
|
16
|
+
| "ussd"
|
|
17
|
+
| "nqr"
|
|
18
|
+
| "mpesa"
|
|
19
|
+
| "mobilemoneyghana"
|
|
20
|
+
| "mobilemoneyuganda"
|
|
21
|
+
| "mobilemoneyrwanda"
|
|
22
|
+
| "mobilemoneyzambia"
|
|
23
|
+
| "barter"
|
|
24
|
+
| "credit"
|
|
25
|
+
| "opay"
|
|
26
|
+
| "fawrypay";
|
|
27
|
+
|
|
28
|
+
export type InitializeTransactionArgs = {
|
|
29
|
+
email: string;
|
|
30
|
+
amount: number;
|
|
31
|
+
currency?: string;
|
|
32
|
+
redirectUrl: string;
|
|
33
|
+
txRef?: string;
|
|
34
|
+
customerName?: string;
|
|
35
|
+
customerPhoneNumber?: string;
|
|
36
|
+
/** Restricts which payment methods Flutterwave's hosted page offers. Omit to offer everything enabled on your account. */
|
|
37
|
+
paymentOptions?: FlutterwavePaymentOption[];
|
|
38
|
+
/** Links this checkout to a payment plan, turning it into a subscription. See createPaymentPlan(). */
|
|
39
|
+
paymentPlan?: string;
|
|
40
|
+
metadata?: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type InitializeTransactionResult = {
|
|
44
|
+
paymentLink: string;
|
|
45
|
+
txRef: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type VerifyTransactionResult = {
|
|
49
|
+
status: string;
|
|
50
|
+
txRef: string;
|
|
51
|
+
flwRef?: string;
|
|
52
|
+
transactionId: string;
|
|
53
|
+
amount: number;
|
|
54
|
+
currency: string;
|
|
55
|
+
paymentType?: string;
|
|
56
|
+
narration?: string;
|
|
57
|
+
paidAt?: number;
|
|
58
|
+
customerEmail: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type CreatePaymentPlanArgs = {
|
|
62
|
+
name: string;
|
|
63
|
+
amount: number;
|
|
64
|
+
interval: "hourly" | "daily" | "weekly" | "monthly" | "quarterly" | "yearly" | "bi-annually";
|
|
65
|
+
currency?: string;
|
|
66
|
+
duration?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type PaymentPlanResult = {
|
|
70
|
+
planId: string;
|
|
71
|
+
name: string;
|
|
72
|
+
amount: number;
|
|
73
|
+
interval: string;
|
|
74
|
+
currency: string;
|
|
75
|
+
duration?: number;
|
|
76
|
+
status: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Flutterwave does not sign webhook payloads. Instead, it echoes back the
|
|
80
|
+
// secret hash you configured in the Dashboard, verbatim, in a `verif-hash`
|
|
81
|
+
// header — verification is a direct (constant-time) string comparison, not
|
|
82
|
+
// an HMAC digest of the request body. See:
|
|
83
|
+
// https://developer.flutterwave.com/docs/integration-guides/webhooks
|
|
84
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
85
|
+
if (a.length !== b.length) return false;
|
|
86
|
+
let mismatch = 0;
|
|
87
|
+
for (let i = 0; i < a.length; i++) {
|
|
88
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
89
|
+
}
|
|
90
|
+
return mismatch === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class Flutterwave {
|
|
94
|
+
webhookHandler: ReturnType<typeof httpActionGeneric>;
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
private component: ComponentApi,
|
|
98
|
+
private options: FlutterwaveOptions,
|
|
99
|
+
) {
|
|
100
|
+
const component_ = component;
|
|
101
|
+
const webhookSecretHash = options.webhookSecretHash;
|
|
102
|
+
|
|
103
|
+
this.webhookHandler = httpActionGeneric(async (ctx, request) => {
|
|
104
|
+
const rawBody = await request.text();
|
|
105
|
+
const signature = request.headers.get("verif-hash");
|
|
106
|
+
|
|
107
|
+
if (!signature) {
|
|
108
|
+
return new Response(JSON.stringify({ error: "Missing signature" }), {
|
|
109
|
+
status: 400,
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!timingSafeEqual(webhookSecretHash, signature)) {
|
|
115
|
+
console.error("convex-flutterwave: webhook signature mismatch");
|
|
116
|
+
return new Response(JSON.stringify({ error: "Invalid signature" }), {
|
|
117
|
+
status: 401,
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let event: { event: string; data: Record<string, unknown> };
|
|
123
|
+
try {
|
|
124
|
+
event = JSON.parse(rawBody);
|
|
125
|
+
} catch {
|
|
126
|
+
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
|
|
127
|
+
status: 400,
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const data = event.data ?? {};
|
|
133
|
+
const eventId =
|
|
134
|
+
data.id !== undefined
|
|
135
|
+
? `${event.event}:${data.id}`
|
|
136
|
+
: `${event.event}:${(data.tx_ref as string) ?? crypto.randomUUID()}`;
|
|
137
|
+
|
|
138
|
+
const { alreadyProcessed } = await ctx.runMutation(component_.lib.checkAndRecordEvent, {
|
|
139
|
+
eventId,
|
|
140
|
+
eventType: event.event,
|
|
141
|
+
txRef: (data.tx_ref as string) ?? undefined,
|
|
142
|
+
payload: rawBody,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (alreadyProcessed) {
|
|
146
|
+
return new Response(JSON.stringify({ success: true, duplicate: true }), {
|
|
147
|
+
status: 200,
|
|
148
|
+
headers: { "Content-Type": "application/json" },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
switch (event.event) {
|
|
153
|
+
case "charge.completed": {
|
|
154
|
+
const customer = data.customer as Record<string, unknown> | undefined;
|
|
155
|
+
const status = (data.status as string) === "successful" ? "successful" : "failed";
|
|
156
|
+
await ctx.runMutation(component_.lib.recordTransaction, {
|
|
157
|
+
txRef: data.tx_ref as string,
|
|
158
|
+
flwRef: (data.flw_ref as string) ?? undefined,
|
|
159
|
+
transactionId: data.id !== undefined ? String(data.id) : undefined,
|
|
160
|
+
customerEmail: (customer?.email as string) ?? "",
|
|
161
|
+
amount: data.amount as number,
|
|
162
|
+
currency: (data.currency as string) ?? "NGN",
|
|
163
|
+
status,
|
|
164
|
+
paymentType: (data.payment_type as string) ?? undefined,
|
|
165
|
+
narration: (data.narration as string) ?? undefined,
|
|
166
|
+
paidAt: data.created_at ? new Date(data.created_at as string).getTime() : undefined,
|
|
167
|
+
metadata: data.meta ? JSON.stringify(data.meta) : undefined,
|
|
168
|
+
});
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "subscription.cancelled": {
|
|
172
|
+
const planId = (data.plan as string) ?? undefined;
|
|
173
|
+
const customer = data.customer as Record<string, unknown> | undefined;
|
|
174
|
+
if (data.id !== undefined) {
|
|
175
|
+
await ctx.runMutation(component_.lib.recordSubscriptionEvent, {
|
|
176
|
+
subscriptionId: String(data.id),
|
|
177
|
+
customerEmail: (customer?.email as string) ?? "",
|
|
178
|
+
planId,
|
|
179
|
+
amount: (data.amount as number) ?? undefined,
|
|
180
|
+
status: "cancelled",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
190
|
+
status: 200,
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async initializeTransaction(
|
|
197
|
+
ctx: GenericActionCtx<GenericDataModel>,
|
|
198
|
+
args: InitializeTransactionArgs,
|
|
199
|
+
): Promise<InitializeTransactionResult> {
|
|
200
|
+
const txRef = args.txRef ?? crypto.randomUUID();
|
|
201
|
+
|
|
202
|
+
const res = await fetch(`${FLUTTERWAVE_API_BASE}/payments`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: {
|
|
205
|
+
Authorization: `Bearer ${this.options.secretKey}`,
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
tx_ref: txRef,
|
|
210
|
+
amount: args.amount,
|
|
211
|
+
currency: args.currency ?? "NGN",
|
|
212
|
+
redirect_url: args.redirectUrl,
|
|
213
|
+
// Flutterwave requires payment_plan as a numeric id, not a string —
|
|
214
|
+
// this component's own PaymentPlanResult.planId is a string (for
|
|
215
|
+
// consistency with every other id in this API), so it's converted
|
|
216
|
+
// here. Sending it as a string doesn't error; Flutterwave just
|
|
217
|
+
// silently processes the charge as a normal one-time payment
|
|
218
|
+
// instead of linking it to the plan, so no subscription is created.
|
|
219
|
+
payment_plan: args.paymentPlan !== undefined ? Number(args.paymentPlan) : undefined,
|
|
220
|
+
payment_options: args.paymentOptions?.join(", "),
|
|
221
|
+
customer: {
|
|
222
|
+
email: args.email,
|
|
223
|
+
name: args.customerName,
|
|
224
|
+
phonenumber: args.customerPhoneNumber,
|
|
225
|
+
},
|
|
226
|
+
meta: args.metadata,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
const json = (await res.json()) as {
|
|
230
|
+
status: string;
|
|
231
|
+
message?: string;
|
|
232
|
+
data: { link: string };
|
|
233
|
+
};
|
|
234
|
+
if (json.status !== "success") {
|
|
235
|
+
throw new Error(json.message ?? "Failed to initialize Flutterwave transaction");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await ctx.runMutation(this.component.lib.recordTransaction, {
|
|
239
|
+
txRef,
|
|
240
|
+
customerEmail: args.email,
|
|
241
|
+
amount: args.amount,
|
|
242
|
+
currency: args.currency ?? "NGN",
|
|
243
|
+
status: "pending",
|
|
244
|
+
metadata: args.metadata ? JSON.stringify(args.metadata) : undefined,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return { paymentLink: json.data.link, txRef };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async verifyTransaction(
|
|
251
|
+
ctx: GenericActionCtx<GenericDataModel>,
|
|
252
|
+
args: { transactionId: string },
|
|
253
|
+
): Promise<VerifyTransactionResult> {
|
|
254
|
+
const res = await fetch(
|
|
255
|
+
`${FLUTTERWAVE_API_BASE}/transactions/${encodeURIComponent(args.transactionId)}/verify`,
|
|
256
|
+
{ headers: { Authorization: `Bearer ${this.options.secretKey}` } },
|
|
257
|
+
);
|
|
258
|
+
const json = (await res.json()) as {
|
|
259
|
+
status: string;
|
|
260
|
+
message?: string;
|
|
261
|
+
data: {
|
|
262
|
+
id: number;
|
|
263
|
+
tx_ref: string;
|
|
264
|
+
flw_ref?: string;
|
|
265
|
+
status: string;
|
|
266
|
+
amount: number;
|
|
267
|
+
currency: string;
|
|
268
|
+
payment_type?: string;
|
|
269
|
+
narration?: string;
|
|
270
|
+
created_at?: string;
|
|
271
|
+
customer?: { email?: string };
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
if (json.status !== "success") {
|
|
275
|
+
throw new Error(json.message ?? "Failed to verify Flutterwave transaction");
|
|
276
|
+
}
|
|
277
|
+
const data = json.data;
|
|
278
|
+
const status = data.status === "successful" ? "successful" : "failed";
|
|
279
|
+
|
|
280
|
+
await ctx.runMutation(this.component.lib.recordTransaction, {
|
|
281
|
+
txRef: data.tx_ref,
|
|
282
|
+
flwRef: data.flw_ref ?? undefined,
|
|
283
|
+
transactionId: String(data.id),
|
|
284
|
+
customerEmail: data.customer?.email ?? "",
|
|
285
|
+
amount: data.amount,
|
|
286
|
+
currency: data.currency,
|
|
287
|
+
status,
|
|
288
|
+
paymentType: data.payment_type ?? undefined,
|
|
289
|
+
narration: data.narration ?? undefined,
|
|
290
|
+
paidAt: data.created_at ? new Date(data.created_at).getTime() : undefined,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// recordTransaction keeps the email the transaction was first created
|
|
294
|
+
// under rather than whatever Flutterwave just echoed back (see its
|
|
295
|
+
// comment — test mode rewrites it). Read that back so callers — and
|
|
296
|
+
// anything they render, like a result screen — see the real address
|
|
297
|
+
// too, not just what's in the database.
|
|
298
|
+
const stored = await ctx.runQuery(this.component.lib.getTransaction, { txRef: data.tx_ref });
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
status: data.status,
|
|
302
|
+
txRef: data.tx_ref,
|
|
303
|
+
flwRef: data.flw_ref ?? undefined,
|
|
304
|
+
transactionId: String(data.id),
|
|
305
|
+
amount: data.amount,
|
|
306
|
+
currency: data.currency,
|
|
307
|
+
paymentType: data.payment_type ?? undefined,
|
|
308
|
+
narration: data.narration ?? undefined,
|
|
309
|
+
paidAt: data.created_at ? new Date(data.created_at).getTime() : undefined,
|
|
310
|
+
customerEmail: stored?.customerEmail ?? data.customer?.email ?? "",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async cancelSubscription(
|
|
315
|
+
ctx: GenericActionCtx<GenericDataModel>,
|
|
316
|
+
args: { subscriptionId: string; customerEmail: string },
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
const res = await fetch(
|
|
319
|
+
`${FLUTTERWAVE_API_BASE}/subscriptions/${encodeURIComponent(args.subscriptionId)}/cancel`,
|
|
320
|
+
{ method: "PUT", headers: { Authorization: `Bearer ${this.options.secretKey}` } },
|
|
321
|
+
);
|
|
322
|
+
const json = (await res.json()) as { status: string; message?: string };
|
|
323
|
+
if (json.status !== "success") {
|
|
324
|
+
throw new Error(json.message ?? "Failed to cancel Flutterwave subscription");
|
|
325
|
+
}
|
|
326
|
+
await ctx.runMutation(this.component.lib.recordSubscriptionEvent, {
|
|
327
|
+
subscriptionId: args.subscriptionId,
|
|
328
|
+
customerEmail: args.customerEmail,
|
|
329
|
+
status: "cancelled",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async enableSubscription(
|
|
334
|
+
ctx: GenericActionCtx<GenericDataModel>,
|
|
335
|
+
args: { subscriptionId: string; customerEmail: string },
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
const res = await fetch(
|
|
338
|
+
`${FLUTTERWAVE_API_BASE}/subscriptions/${encodeURIComponent(args.subscriptionId)}/activate`,
|
|
339
|
+
{ method: "PUT", headers: { Authorization: `Bearer ${this.options.secretKey}` } },
|
|
340
|
+
);
|
|
341
|
+
const json = (await res.json()) as { status: string; message?: string };
|
|
342
|
+
if (json.status !== "success") {
|
|
343
|
+
throw new Error(json.message ?? "Failed to activate Flutterwave subscription");
|
|
344
|
+
}
|
|
345
|
+
await ctx.runMutation(this.component.lib.recordSubscriptionEvent, {
|
|
346
|
+
subscriptionId: args.subscriptionId,
|
|
347
|
+
customerEmail: args.customerEmail,
|
|
348
|
+
status: "active",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async createPaymentPlan(
|
|
353
|
+
ctx: GenericActionCtx<GenericDataModel>,
|
|
354
|
+
args: CreatePaymentPlanArgs,
|
|
355
|
+
): Promise<PaymentPlanResult> {
|
|
356
|
+
const res = await fetch(`${FLUTTERWAVE_API_BASE}/payment-plans`, {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: {
|
|
359
|
+
Authorization: `Bearer ${this.options.secretKey}`,
|
|
360
|
+
"Content-Type": "application/json",
|
|
361
|
+
},
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
name: args.name,
|
|
364
|
+
amount: args.amount,
|
|
365
|
+
interval: args.interval,
|
|
366
|
+
currency: args.currency ?? "NGN",
|
|
367
|
+
duration: args.duration,
|
|
368
|
+
}),
|
|
369
|
+
});
|
|
370
|
+
const json = (await res.json()) as {
|
|
371
|
+
status: string;
|
|
372
|
+
message?: string;
|
|
373
|
+
data: {
|
|
374
|
+
id: number;
|
|
375
|
+
name: string;
|
|
376
|
+
amount: number;
|
|
377
|
+
interval: string;
|
|
378
|
+
currency: string;
|
|
379
|
+
duration?: number;
|
|
380
|
+
status: string;
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
if (json.status !== "success") {
|
|
384
|
+
throw new Error(json.message ?? "Failed to create Flutterwave payment plan");
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
planId: String(json.data.id),
|
|
388
|
+
name: json.data.name,
|
|
389
|
+
amount: json.data.amount,
|
|
390
|
+
interval: json.data.interval,
|
|
391
|
+
currency: json.data.currency,
|
|
392
|
+
duration: json.data.duration,
|
|
393
|
+
status: json.data.status,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async listPaymentPlans(
|
|
398
|
+
_ctx: GenericActionCtx<GenericDataModel>,
|
|
399
|
+
): Promise<PaymentPlanResult[]> {
|
|
400
|
+
const res = await fetch(`${FLUTTERWAVE_API_BASE}/payment-plans`, {
|
|
401
|
+
headers: { Authorization: `Bearer ${this.options.secretKey}` },
|
|
402
|
+
});
|
|
403
|
+
const json = (await res.json()) as {
|
|
404
|
+
status: string;
|
|
405
|
+
message?: string;
|
|
406
|
+
data: Array<{
|
|
407
|
+
id: number;
|
|
408
|
+
name: string;
|
|
409
|
+
amount: number;
|
|
410
|
+
interval: string;
|
|
411
|
+
currency: string;
|
|
412
|
+
duration?: number;
|
|
413
|
+
status: string;
|
|
414
|
+
}>;
|
|
415
|
+
};
|
|
416
|
+
if (json.status !== "success") {
|
|
417
|
+
throw new Error(json.message ?? "Failed to list Flutterwave payment plans");
|
|
418
|
+
}
|
|
419
|
+
return json.data.map((plan) => ({
|
|
420
|
+
planId: String(plan.id),
|
|
421
|
+
name: plan.name,
|
|
422
|
+
amount: plan.amount,
|
|
423
|
+
interval: plan.interval,
|
|
424
|
+
currency: plan.currency,
|
|
425
|
+
duration: plan.duration,
|
|
426
|
+
status: plan.status,
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Flutterwave has no "subscription created" webhook — a subscription is
|
|
432
|
+
* created implicitly the first time a customer pays through a checkout
|
|
433
|
+
* initialized with `paymentPlan`, and that charge webhook doesn't carry
|
|
434
|
+
* the subscription's own id (only the plan id). This reads the customer's
|
|
435
|
+
* subscriptions straight from Flutterwave's Subscriptions API and upserts
|
|
436
|
+
* them locally — call it after a plan-linked checkout returns, or from a
|
|
437
|
+
* manual "sync" action, as a reliable way to populate subscription state.
|
|
438
|
+
*/
|
|
439
|
+
private async fetchFlutterwaveSubscriptions(email?: string): Promise<{
|
|
440
|
+
status: string;
|
|
441
|
+
message?: string;
|
|
442
|
+
data?: Array<{
|
|
443
|
+
id: number;
|
|
444
|
+
amount?: number;
|
|
445
|
+
plan?: number;
|
|
446
|
+
status: string;
|
|
447
|
+
customer?: { customer_email?: string };
|
|
448
|
+
}>;
|
|
449
|
+
}> {
|
|
450
|
+
const url = email
|
|
451
|
+
? `${FLUTTERWAVE_API_BASE}/subscriptions?email=${encodeURIComponent(email)}`
|
|
452
|
+
: `${FLUTTERWAVE_API_BASE}/subscriptions`;
|
|
453
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${this.options.secretKey}` } });
|
|
454
|
+
return await res.json();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async syncCustomerSubscriptions(
|
|
458
|
+
ctx: GenericActionCtx<GenericDataModel>,
|
|
459
|
+
args: { email: string },
|
|
460
|
+
): Promise<number> {
|
|
461
|
+
const json = await this.fetchFlutterwaveSubscriptions(args.email);
|
|
462
|
+
if (json.status !== "success") {
|
|
463
|
+
throw new Error(json.message ?? "Failed to fetch Flutterwave subscriptions");
|
|
464
|
+
}
|
|
465
|
+
let subscriptions = json.data ?? [];
|
|
466
|
+
|
|
467
|
+
// Flutterwave's ?email= filter matches against whatever email it has on
|
|
468
|
+
// file for the subscription — in test mode that's the same "ravesb_
|
|
469
|
+
// <hash>_" rewritten address seen elsewhere, not the real one, so
|
|
470
|
+
// filtering by the real email can come back empty even when a matching
|
|
471
|
+
// subscription exists. Fall back to an unfiltered list and match after
|
|
472
|
+
// stripping that rewrite, rather than trusting the filter alone.
|
|
473
|
+
if (subscriptions.length === 0) {
|
|
474
|
+
const all = await this.fetchFlutterwaveSubscriptions();
|
|
475
|
+
if (all.status === "success") {
|
|
476
|
+
subscriptions = (all.data ?? []).filter(
|
|
477
|
+
(sub) =>
|
|
478
|
+
sub.customer?.customer_email?.replace(/^ravesb_[0-9a-f]+_/i, "") === args.email,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
for (const sub of subscriptions) {
|
|
484
|
+
await ctx.runMutation(this.component.lib.recordSubscriptionEvent, {
|
|
485
|
+
subscriptionId: String(sub.id),
|
|
486
|
+
// Use the email the caller queried by, not Flutterwave's own
|
|
487
|
+
// customer_email field — in test mode Flutterwave rewrites it (e.g.
|
|
488
|
+
// "ravesb_<hash>_real@email.com"), which would make the record
|
|
489
|
+
// unfindable by the email a merchant actually knows the customer as.
|
|
490
|
+
customerEmail: args.email,
|
|
491
|
+
planId: sub.plan !== undefined ? String(sub.plan) : undefined,
|
|
492
|
+
amount: sub.amount ?? undefined,
|
|
493
|
+
status: sub.status === "active" ? "active" : "cancelled",
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return subscriptions.length;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Attributes every subscription on a given plan to one email, regardless
|
|
501
|
+
* of what Flutterwave itself has on file as that subscription's customer.
|
|
502
|
+
* This is unsafe to reach for by default — a real plan can have many
|
|
503
|
+
* subscribers — but it's exactly what's needed when Flutterwave's
|
|
504
|
+
* test-mode sandbox has substituted its own fixed customer identity for
|
|
505
|
+
* every subscription (see the README's Subscriptions section), making
|
|
506
|
+
* email-based sync unable to find anything at all. Reach for it only
|
|
507
|
+
* where you already know (or, in a single-developer test/demo context,
|
|
508
|
+
* can safely assume) there's one subscriber to claim — the example app
|
|
509
|
+
* uses it as a fallback for exactly that reason.
|
|
510
|
+
*/
|
|
511
|
+
async syncSubscriptionsByPlan(
|
|
512
|
+
ctx: GenericActionCtx<GenericDataModel>,
|
|
513
|
+
args: { planId: string; email: string },
|
|
514
|
+
): Promise<number> {
|
|
515
|
+
const json = await this.fetchFlutterwaveSubscriptions();
|
|
516
|
+
if (json.status !== "success") {
|
|
517
|
+
throw new Error(json.message ?? "Failed to fetch Flutterwave subscriptions");
|
|
518
|
+
}
|
|
519
|
+
const planId = Number(args.planId);
|
|
520
|
+
const subscriptions = (json.data ?? []).filter((sub) => sub.plan === planId);
|
|
521
|
+
|
|
522
|
+
for (const sub of subscriptions) {
|
|
523
|
+
await ctx.runMutation(this.component.lib.recordSubscriptionEvent, {
|
|
524
|
+
subscriptionId: String(sub.id),
|
|
525
|
+
customerEmail: args.email,
|
|
526
|
+
planId: String(planId),
|
|
527
|
+
amount: sub.amount ?? undefined,
|
|
528
|
+
status: sub.status === "active" ? "active" : "cancelled",
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return subscriptions.length;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async getTransaction(ctx: RunQueryCtx, args: { txRef: string }) {
|
|
535
|
+
return await ctx.runQuery(this.component.lib.getTransaction, args);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async listTransactions(ctx: RunQueryCtx, args: { customerEmail: string; limit?: number }) {
|
|
539
|
+
return await ctx.runQuery(this.component.lib.listTransactions, args);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async getSubscription(ctx: RunQueryCtx, args: { subscriptionId: string }) {
|
|
543
|
+
return await ctx.runQuery(this.component.lib.getSubscription, args);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async listSubscriptions(ctx: RunQueryCtx, args: { customerEmail: string }) {
|
|
547
|
+
return await ctx.runQuery(this.component.lib.listSubscriptions, args);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async hasActiveSubscription(ctx: RunQueryCtx, args: { customerEmail: string }): Promise<boolean> {
|
|
551
|
+
return await ctx.runQuery(this.component.lib.hasActiveSubscription, args);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async listRecentEvents(ctx: RunQueryCtx, args?: { limit?: number }) {
|
|
555
|
+
return await ctx.runQuery(this.component.lib.listRecentEvents, args ?? {});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async getStats(ctx: RunQueryCtx) {
|
|
559
|
+
return await ctx.runQuery(this.component.lib.getStats, {});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
type RunQueryCtx = {
|
|
564
|
+
runQuery: GenericActionCtx<GenericDataModel>["runQuery"];
|
|
565
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { test } from "vitest";
|
|
3
|
+
import { convexTest } from "convex-test";
|
|
4
|
+
export const modules = import.meta.glob("./**/*.*s");
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
defineSchema,
|
|
8
|
+
type GenericSchema,
|
|
9
|
+
type SchemaDefinition,
|
|
10
|
+
} from "convex/server";
|
|
11
|
+
import { type ComponentApi } from "../component/_generated/component.js";
|
|
12
|
+
import { componentsGeneric } from "convex/server";
|
|
13
|
+
import { register } from "../test.js";
|
|
14
|
+
|
|
15
|
+
export function initConvexTest<
|
|
16
|
+
Schema extends SchemaDefinition<GenericSchema, boolean>,
|
|
17
|
+
>(schema?: Schema) {
|
|
18
|
+
const t = convexTest(schema ?? defineSchema({}), modules);
|
|
19
|
+
register(t);
|
|
20
|
+
return t;
|
|
21
|
+
}
|
|
22
|
+
export const components = componentsGeneric() as unknown as {
|
|
23
|
+
convexFlutterwave: ComponentApi;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
test("setup", () => {});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* Generated `api` utility.
|
|
4
|
+
*
|
|
5
|
+
* THIS CODE IS AUTOMATICALLY GENERATED.
|
|
6
|
+
*
|
|
7
|
+
* To regenerate, run `npx convex dev`.
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type * as lib from "../lib.js";
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ApiFromModules,
|
|
15
|
+
FilterApi,
|
|
16
|
+
FunctionReference,
|
|
17
|
+
} from "convex/server";
|
|
18
|
+
import { anyApi, componentsGeneric } from "convex/server";
|
|
19
|
+
|
|
20
|
+
const fullApi: ApiFromModules<{
|
|
21
|
+
lib: typeof lib;
|
|
22
|
+
}> = anyApi as any;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A utility for referencing Convex functions in your app's public API.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* ```js
|
|
29
|
+
* const myFunctionReference = api.myModule.myFunction;
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export const api: FilterApi<
|
|
33
|
+
typeof fullApi,
|
|
34
|
+
FunctionReference<any, "public">
|
|
35
|
+
> = anyApi as any;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A utility for referencing Convex functions in your app's internal API.
|
|
39
|
+
*
|
|
40
|
+
* Usage:
|
|
41
|
+
* ```js
|
|
42
|
+
* const myFunctionReference = internal.myModule.myFunction;
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export const internal: FilterApi<
|
|
46
|
+
typeof fullApi,
|
|
47
|
+
FunctionReference<any, "internal">
|
|
48
|
+
> = anyApi as any;
|
|
49
|
+
|
|
50
|
+
export const components = componentsGeneric() as unknown as {};
|