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.
Files changed (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +504 -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 +192 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +364 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +34 -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 +141 -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 +133 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +80 -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 +4 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/lib.d.ts +117 -0
  32. package/dist/component/lib.d.ts.map +1 -0
  33. package/dist/component/lib.js +223 -0
  34. package/dist/component/lib.js.map +1 -0
  35. package/dist/component/schema.d.ts +73 -0
  36. package/dist/component/schema.d.ts.map +1 -0
  37. package/dist/component/schema.js +43 -0
  38. package/dist/component/schema.js.map +1 -0
  39. package/package.json +106 -0
  40. package/src/client/_generated/_ignore.ts +1 -0
  41. package/src/client/index.ts +565 -0
  42. package/src/client/setup.test.ts +26 -0
  43. package/src/component/_generated/api.ts +50 -0
  44. package/src/component/_generated/component.ts +183 -0
  45. package/src/component/_generated/dataModel.ts +60 -0
  46. package/src/component/_generated/server.ts +169 -0
  47. package/src/component/convex.config.ts +5 -0
  48. package/src/component/lib.test.ts +110 -0
  49. package/src/component/lib.ts +251 -0
  50. package/src/component/schema.ts +50 -0
  51. package/src/component/setup.test.ts +11 -0
  52. package/src/test.ts +18 -0
package/README.md ADDED
@@ -0,0 +1,504 @@
1
+ # convex-flutterwave
2
+
3
+ **Accept payments and subscriptions with Flutterwave in your Convex app.** Reactive transactions, subscription state, and webhook ingestion.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/convex-flutterwave)](https://www.npmjs.com/package/convex-flutterwave)
6
+ [![Convex Component](https://www.convex.dev/components/badge/sholajegede/convex-flutterwave)](https://www.convex.dev/components/sholajegede/convex-flutterwave)
7
+ [![npm downloads](https://img.shields.io/npm/dw/convex-flutterwave)](https://www.npmjs.com/package/convex-flutterwave)
8
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
9
+
10
+ ```ts
11
+ const flutterwave = new Flutterwave(components.convexFlutterwave, {
12
+ secretKey: process.env.FLW_SECRET_KEY!,
13
+ webhookSecretHash: process.env.FLW_WEBHOOK_SECRET_HASH!,
14
+ });
15
+
16
+ // Start a checkout
17
+ const { paymentLink } = await flutterwave.initializeTransaction(ctx, {
18
+ email: "customer@example.com",
19
+ amount: 5000,
20
+ redirectUrl: "https://yourapp.com/payment/callback",
21
+ });
22
+
23
+ // Confirm it server-side after redirect
24
+ const result = await flutterwave.verifyTransaction(ctx, { transactionId });
25
+
26
+ // Is this customer on an active subscription?
27
+ const active = await flutterwave.hasActiveSubscription(ctx, { customerEmail: "customer@example.com" });
28
+ ```
29
+
30
+ ## What this does
31
+
32
+ Flutterwave fires webhook events every time a payment action happens — a charge completes, a subscription is cancelled. Without this component, you have to write and maintain your own webhook receiver, signature verification, database schema, and reactive queries.
33
+
34
+ This component owns all of that. Drop it in, mount the webhook, and your Convex app immediately has:
35
+
36
+ - **Reactive transaction state** — every transaction, live in Convex, keyed by `tx_ref`
37
+ - **Reactive subscription state** — subscription status for recurring payment plans, live in Convex
38
+ - **Checkout** — `initializeTransaction()` generates a Flutterwave-hosted payment link for one-time payments or, with a `paymentPlan`, for subscriptions
39
+ - **Plan management** — `createPaymentPlan()` / `listPaymentPlans()` manage the billing plans subscriptions are built on
40
+ - **Server-side verification** — `verifyTransaction()` confirms a transaction directly with Flutterwave
41
+ - **Subscription management** — `cancelSubscription()` / `enableSubscription()` call Flutterwave directly and keep local state in sync
42
+ - **Webhook idempotency** — duplicate deliveries of the same event are detected and skipped
43
+
44
+ > **API version:** This component uses Flutterwave's v3 Standard integration (`api.flutterwave.com/v3`) — a static secret key, not the newer v4 OAuth flow. v3 remains fully supported and is what most existing Flutterwave integrations use.
45
+
46
+ > **Webhook timing:** After a payment action occurs in Flutterwave, there is a short delay — usually a few seconds — before the webhook arrives and your Convex data updates. Once the webhook arrives, Convex's real-time reactivity propagates the change to all subscribers instantly.
47
+
48
+ ## Table of Contents
49
+
50
+ - [Install](#install)
51
+ - [Quick Start](#quick-start)
52
+ - [Setup](#setup)
53
+ - [Usage](#usage)
54
+ - [Checkout](#checkout)
55
+ - [Plans](#plans)
56
+ - [Subscriptions](#subscriptions)
57
+ - [API Reference](#api-reference)
58
+ - [Type Reference](#type-reference)
59
+ - [Webhook Events](#webhook-events)
60
+ - [Database Schema](#database-schema)
61
+ - [Customer IDs](#customer-ids)
62
+ - [Testing](#testing)
63
+ - [Example App](#example-app)
64
+ - [Limitations](#limitations)
65
+ - [Troubleshooting](#troubleshooting)
66
+ - [Contributing](#contributing)
67
+ - [Changelog](#changelog)
68
+
69
+ ## Install
70
+
71
+ ```bash
72
+ npm install convex-flutterwave
73
+ ```
74
+
75
+ **Requirements:** Convex v1.33.1 or later, Node.js 18+, a [Flutterwave](https://flutterwave.com) account
76
+
77
+ ## Quick Start
78
+
79
+ Five steps to add Flutterwave to your Convex app.
80
+
81
+ ### 1. Add the component
82
+
83
+ In `convex/convex.config.ts`:
84
+
85
+ ```ts
86
+ import { defineApp } from "convex/server";
87
+ import convexFlutterwave from "convex-flutterwave/convex.config";
88
+
89
+ const app = defineApp();
90
+ app.use(convexFlutterwave);
91
+
92
+ export default app;
93
+ ```
94
+
95
+ ### 2. Set environment variables
96
+
97
+ ```bash
98
+ npx convex env set FLW_SECRET_KEY FLWSECK-xxxxxxxxxxxx
99
+ npx convex env set FLW_WEBHOOK_SECRET_HASH your-chosen-secret-hash
100
+ ```
101
+
102
+ `FLW_WEBHOOK_SECRET_HASH` is a random value **you** choose and enter in the Flutterwave dashboard — it's separate from your API secret key.
103
+
104
+ ### 3. Mount the webhook handler
105
+
106
+ In `convex/http.ts`:
107
+
108
+ ```ts
109
+ import { httpRouter } from "convex/server";
110
+ import { components } from "./_generated/api";
111
+ import { Flutterwave } from "convex-flutterwave";
112
+
113
+ const flutterwave = new Flutterwave(components.convexFlutterwave, {
114
+ secretKey: process.env.FLW_SECRET_KEY!,
115
+ webhookSecretHash: process.env.FLW_WEBHOOK_SECRET_HASH!,
116
+ });
117
+
118
+ const http = httpRouter();
119
+
120
+ http.route({
121
+ path: "/webhooks/flutterwave",
122
+ method: "POST",
123
+ handler: flutterwave.webhookHandler,
124
+ });
125
+
126
+ export default http;
127
+ ```
128
+
129
+ ### 4. Register the webhook in Flutterwave
130
+
131
+ 1. In Flutterwave Dashboard → **Settings → Webhooks**
132
+ 2. Set the webhook URL: `https://your-deployment.convex.site/webhooks/flutterwave`
133
+ 3. Set the same secret hash you stored as `FLW_WEBHOOK_SECRET_HASH`
134
+ 4. Save. Flutterwave sends every event to this URL — the handler ignores events it doesn't recognize.
135
+
136
+ Your Convex site URL is in the Convex dashboard under **Settings → URL & Deploy Key** — it ends in `.convex.site`.
137
+
138
+ ### 5. Initialize the client
139
+
140
+ In `convex/payments.ts`:
141
+
142
+ ```ts
143
+ import { components } from "./_generated/api";
144
+ import { Flutterwave } from "convex-flutterwave";
145
+
146
+ export const flutterwave = new Flutterwave(components.convexFlutterwave, {
147
+ secretKey: process.env.FLW_SECRET_KEY!,
148
+ webhookSecretHash: process.env.FLW_WEBHOOK_SECRET_HASH!,
149
+ });
150
+ ```
151
+
152
+ Import `flutterwave` from this file in any Convex function that needs payments.
153
+
154
+ ## Setup
155
+
156
+ **`convex/payments.ts`** — your central payments module:
157
+
158
+ ```ts
159
+ import { components } from "./_generated/api";
160
+ import { Flutterwave } from "convex-flutterwave";
161
+ import { action } from "./_generated/server";
162
+ import { v } from "convex/values";
163
+
164
+ export const flutterwave = new Flutterwave(components.convexFlutterwave, {
165
+ secretKey: process.env.FLW_SECRET_KEY!,
166
+ webhookSecretHash: process.env.FLW_WEBHOOK_SECRET_HASH!,
167
+ });
168
+
169
+ export const checkout = action({
170
+ args: { email: v.string(), amount: v.number(), redirectUrl: v.string() },
171
+ handler: async (ctx, args) => flutterwave.initializeTransaction(ctx, args),
172
+ });
173
+ ```
174
+
175
+ **`convex/http.ts`** — webhook entry point (shown in [Quick Start](#quick-start)).
176
+
177
+ ## Usage
178
+
179
+ ### Start a checkout
180
+
181
+ ```ts
182
+ export const checkout = action({
183
+ args: { email: v.string(), amount: v.number() },
184
+ handler: async (ctx, args) => {
185
+ return await flutterwave.initializeTransaction(ctx, {
186
+ email: args.email,
187
+ amount: args.amount, // major currency unit — e.g. naira, not kobo
188
+ redirectUrl: "https://yourapp.com/payment/callback",
189
+ });
190
+ },
191
+ });
192
+ // Returns: { paymentLink, txRef }
193
+ // Redirect the customer to paymentLink.
194
+ ```
195
+
196
+ ### Verify a transaction
197
+
198
+ ```ts
199
+ export const confirmPayment = action({
200
+ args: { transactionId: v.string() },
201
+ handler: async (ctx, args) => {
202
+ return await flutterwave.verifyTransaction(ctx, args);
203
+ },
204
+ });
205
+ // Returns: { status, txRef, flwRef, transactionId, amount, currency, customerEmail, ... }
206
+ ```
207
+
208
+ Flutterwave redirects to your `redirectUrl` with `status` and `tx_ref` query params on every outcome, and `transaction_id` **only when a chargeable attempt was made** — a card declined at the gateway or an abandoned checkout redirects back with just `status=failed` and `tx_ref`, no `transaction_id` to verify. Handle that case on your result screen rather than assuming `transaction_id` is always present.
209
+
210
+ ### Read a transaction reactively
211
+
212
+ ```ts
213
+ export const getTransaction = query({
214
+ args: { txRef: v.string() },
215
+ handler: async (ctx, args) => {
216
+ return await flutterwave.getTransaction(ctx, args);
217
+ },
218
+ });
219
+ // Returns: Transaction | null
220
+ ```
221
+
222
+ ### List a customer's transactions
223
+
224
+ ```ts
225
+ export const getHistory = query({
226
+ args: { customerEmail: v.string() },
227
+ handler: async (ctx, args) => {
228
+ return await flutterwave.listTransactions(ctx, {
229
+ customerEmail: args.customerEmail,
230
+ limit: 20,
231
+ });
232
+ },
233
+ });
234
+ // Returns: Transaction[] ordered newest first
235
+ ```
236
+
237
+ ## Checkout
238
+
239
+ `initializeTransaction()` calls Flutterwave's [Standard payment](https://developer.flutterwave.com/docs/collecting-payments/standard) endpoint, records a `pending` transaction locally, and returns the hosted payment link. Send the customer to `paymentLink`; Flutterwave redirects them back to `redirectUrl` after payment.
240
+
241
+ Call `verifyTransaction()` from your callback route (or rely on the `charge.completed` webhook) to confirm the final status — never trust the client-side redirect alone.
242
+
243
+ Two optional arguments shape what the customer sees at checkout:
244
+
245
+ - **`paymentOptions`** restricts which payment methods Flutterwave's hosted page offers — any of `"card"`, `"account"`, `"banktransfer"`, `"ussd"`, `"nqr"`, `"mpesa"`, `"mobilemoneyghana"`, `"mobilemoneyuganda"`, `"mobilemoneyrwanda"`, `"mobilemoneyzambia"`, `"barter"`, `"credit"`, `"opay"`, `"fawrypay"`. Omit it to let Flutterwave offer everything enabled on your account.
246
+ - **`paymentPlan`** turns a one-time checkout into a subscription — see [Plans](#plans).
247
+
248
+ ```ts
249
+ await flutterwave.initializeTransaction(ctx, {
250
+ email: "customer@example.com",
251
+ amount: 5000,
252
+ paymentOptions: ["card", "banktransfer", "ussd"],
253
+ redirectUrl: "https://yourapp.com/payment/callback",
254
+ });
255
+ ```
256
+
257
+ Unlike some processors, Flutterwave takes `amount` in the currency's **major unit** — 5000 means ₦5,000, not ₦50.00 — so there's no subunit conversion to do before calling this.
258
+
259
+ ## Plans
260
+
261
+ Plans are the billing schedule a subscription is built on — a name, an interval (`hourly`, `daily`, `weekly`, `monthly`, `quarterly`, `yearly`, `bi-annually`, or `"every X <unit>"`), and optionally a fixed amount and currency. Create one, then pass its `planId` as `paymentPlan` to `initializeTransaction()`: Flutterwave automatically restricts the checkout to card payments, and the customer's first successful charge starts the subscription.
262
+
263
+ ```ts
264
+ export const createProPlan = action({
265
+ args: {},
266
+ handler: async (ctx) => {
267
+ return await flutterwave.createPaymentPlan(ctx, {
268
+ name: "Pro Monthly",
269
+ amount: 5000, // ₦5,000 — major unit, not kobo
270
+ interval: "monthly",
271
+ currency: "NGN",
272
+ });
273
+ },
274
+ });
275
+ // Returns: { planId, name, amount, interval, currency, status }
276
+
277
+ export const startSubscription = action({
278
+ args: { email: v.string(), planId: v.string(), amount: v.number() },
279
+ handler: async (ctx, args) => {
280
+ return await flutterwave.initializeTransaction(ctx, {
281
+ email: args.email,
282
+ amount: args.amount,
283
+ paymentPlan: args.planId,
284
+ redirectUrl: "https://yourapp.com/payment/callback",
285
+ });
286
+ },
287
+ });
288
+ ```
289
+
290
+ `listPaymentPlans()` returns every plan already created on your Flutterwave account, so you can check for an existing plan by name before creating a duplicate — this is exactly the pattern the [example app](#example-app) uses to bootstrap its demo plans on first run.
291
+
292
+ ## Subscriptions
293
+
294
+ Flutterwave has **no "subscription created" webhook** — a subscription comes into existence the moment a customer's first charge against a `paymentPlan` succeeds, and the only events that fire afterward are `charge.completed` (each recurring charge) and `subscription.cancelled`. The `charge.completed` payload doesn't even carry the subscription's own id, only the plan id — so this component cannot create a local subscription record purely from webhooks the way it can for transactions.
295
+
296
+ Instead, call `syncCustomerSubscriptions()` right after a plan-linked checkout returns successfully — it reads the customer's live subscriptions from Flutterwave's [List Subscriptions](https://developer.flutterwave.com/v3.0/reference/list-all-subscriptions-1) endpoint and upserts them locally:
297
+
298
+ ```ts
299
+ export const syncSubscriptions = action({
300
+ args: { email: v.string() },
301
+ handler: async (ctx, args) => {
302
+ return await flutterwave.syncCustomerSubscriptions(ctx, { email: args.email });
303
+ },
304
+ });
305
+ // Returns: number of subscriptions synced
306
+ ```
307
+
308
+ The [example app](#example-app) calls this automatically once `verifyTransaction()` confirms a subscription checkout succeeded, and again on demand from a "Sync from Flutterwave" button on the history screen — do the same in your app rather than waiting on a webhook that will never arrive for subscription creation.
309
+
310
+ Once a subscription is known locally (synced, or updated by a later webhook), cancel/enable it directly:
311
+
312
+ ```ts
313
+ export const cancelPlan = action({
314
+ args: { subscriptionId: v.string(), customerEmail: v.string() },
315
+ handler: async (ctx, args) => {
316
+ await flutterwave.cancelSubscription(ctx, args);
317
+ return null;
318
+ },
319
+ });
320
+ ```
321
+
322
+ `subscriptionId` is Flutterwave's numeric subscription id, available via `getSubscription()`, `listSubscriptions()`, or the `subscription.cancelled` webhook.
323
+
324
+ > **Test mode:** Flutterwave's sandbox appears to associate every subscription with one fixed test-customer identity — verified directly against their API, every subscription created across multiple plans and multiple different checkout emails came back with the exact same `customer.customer_email` (a `ravesb_<hash>_` — prefixed address), regardless of what email the checkout actually used. `syncCustomerSubscriptions()` already falls back to an unfiltered list and matches after stripping that prefix, which recovers subscriptions Flutterwave filed under the real email — but it can't recover a subscription in test mode at all if Flutterwave discarded the real email entirely. This has not been observed in live mode, where the real customer email is preserved correctly.
325
+ >
326
+ > For exactly that test-mode case, `syncSubscriptionsByPlan(ctx, { planId, email })` attributes every subscription on one plan to a given email, no matching required — unsafe to reach for by default since a real plan can have many subscribers, but fine when you already know (or, testing locally against your own sandbox account, can safely assume) there's one to claim. The [example app](#example-app)'s "Sync from Flutterwave" button already does this automatically as a fallback when syncing by email finds nothing, checking its own two demo plans — so testers never need to know or type Flutterwave's sandbox identity themselves.
327
+
328
+ ## API Reference
329
+
330
+ | Method | Kind | Description |
331
+ | --- | --- | --- |
332
+ | `initializeTransaction(ctx, args)` | action | Starts a Flutterwave checkout, returns the hosted payment link — pass `paymentPlan` to start a subscription |
333
+ | `verifyTransaction(ctx, args)` | action | Confirms a transaction's final status with Flutterwave |
334
+ | `createPaymentPlan(ctx, args)` | action | Creates a billing plan on Flutterwave |
335
+ | `listPaymentPlans(ctx)` | action | Lists every billing plan on your Flutterwave account |
336
+ | `cancelSubscription(ctx, args)` | action | Cancels a subscription on Flutterwave and locally |
337
+ | `enableSubscription(ctx, args)` | action | Re-activates a cancelled subscription |
338
+ | `syncCustomerSubscriptions(ctx, args)` | action | Pulls a customer's subscriptions straight from Flutterwave and upserts them locally — the only way local state learns a subscription exists, since Flutterwave has no subscription-created webhook |
339
+ | `syncSubscriptionsByPlan(ctx, args)` | action | Attributes every subscription on one plan to a given email — a test-mode fallback for when Flutterwave's sandbox makes email-based sync unable to find anything (see [Subscriptions](#subscriptions)) |
340
+ | `getTransaction(ctx, args)` | query | Fetch one transaction by `tx_ref` |
341
+ | `listTransactions(ctx, args)` | query | List a customer's transactions, newest first |
342
+ | `getSubscription(ctx, args)` | query | Fetch one subscription by subscription id |
343
+ | `listSubscriptions(ctx, args)` | query | List a customer's subscriptions |
344
+ | `hasActiveSubscription(ctx, args)` | query | `true` if the customer has an active subscription |
345
+ | `listRecentEvents(ctx, args?)` | query | Raw webhook event log, newest first — audit trail or a live console |
346
+ | `getStats(ctx)` | query | Aggregate transaction/subscription/event counts for a small dashboard |
347
+
348
+ ## Type Reference
349
+
350
+ ```ts
351
+ type FlutterwavePaymentOption =
352
+ | "card" | "account" | "banktransfer" | "ussd" | "nqr" | "mpesa"
353
+ | "mobilemoneyghana" | "mobilemoneyuganda" | "mobilemoneyrwanda" | "mobilemoneyzambia"
354
+ | "barter" | "credit" | "opay" | "fawrypay";
355
+
356
+ type InitializeTransactionArgs = {
357
+ email: string;
358
+ amount: number;
359
+ currency?: string;
360
+ redirectUrl: string;
361
+ txRef?: string;
362
+ customerName?: string;
363
+ customerPhoneNumber?: string;
364
+ paymentOptions?: FlutterwavePaymentOption[];
365
+ paymentPlan?: string;
366
+ metadata?: Record<string, unknown>;
367
+ };
368
+
369
+ type CreatePaymentPlanArgs = {
370
+ name: string;
371
+ interval: string; // e.g. "monthly", "yearly", "every 2 weeks"
372
+ amount?: number;
373
+ currency?: string;
374
+ duration?: number;
375
+ };
376
+
377
+ type PaymentPlanResult = {
378
+ planId: string;
379
+ name: string;
380
+ amount: number;
381
+ interval: string;
382
+ currency: string;
383
+ status: string;
384
+ };
385
+
386
+ type Transaction = {
387
+ txRef: string;
388
+ flwRef?: string;
389
+ transactionId?: string;
390
+ customerEmail: string;
391
+ amount: number;
392
+ currency: string;
393
+ status: "pending" | "successful" | "failed" | "cancelled";
394
+ paymentType?: string;
395
+ narration?: string;
396
+ paidAt?: number;
397
+ metadata?: string;
398
+ };
399
+
400
+ type Subscription = {
401
+ subscriptionId: string;
402
+ customerEmail: string;
403
+ planId?: string;
404
+ amount?: number;
405
+ status: "active" | "cancelled";
406
+ };
407
+ ```
408
+
409
+ ## Webhook Events
410
+
411
+ Flutterwave doesn't sign webhook payloads with a computed digest — it echoes back the exact secret hash you configured in the Dashboard, verbatim, in a `verif-hash` header. The webhook handler compares that header directly (constant-time) against `FLW_WEBHOOK_SECRET_HASH` before processing anything, and de-duplicates by event id so retried deliveries are safe. It currently acts on:
412
+
413
+ | Event | Effect |
414
+ | --- | --- |
415
+ | `charge.completed` | Upserts the transaction as `successful` or `failed` |
416
+ | `subscription.cancelled` | Marks the subscription `cancelled` |
417
+
418
+ All other event types are accepted (HTTP 200) but ignored, so you can register every event on one endpoint without errors. Note there is no `subscription.created`-style event — see [Subscriptions](#subscriptions) for how this component learns a subscription exists.
419
+
420
+ ## Database Schema
421
+
422
+ ```ts
423
+ transactions: {
424
+ txRef, flwRef?, transactionId?, customerEmail, amount, currency, status,
425
+ paymentType?, narration?, paidAt?, metadata?,
426
+ createdAt, updatedAt,
427
+ }
428
+
429
+ subscriptions: {
430
+ subscriptionId, customerEmail, planId?, amount?, status,
431
+ createdAt, updatedAt,
432
+ }
433
+
434
+ webhookEvents: {
435
+ eventId, eventType, txRef?, payload, receivedAt,
436
+ }
437
+ ```
438
+
439
+ Plans are not stored locally — Flutterwave is the source of truth for them, the same way it is for verified transactions. `listPaymentPlans()` reads live from Flutterwave.
440
+
441
+ `listRecentEvents()` reads `webhookEvents` directly — every event this component's webhook handler has ever received, whether or not it changed a transaction or subscription. `getStats()` returns row counts across all three tables with a full scan, intended for a small dashboard rather than a high-volume production metric.
442
+
443
+ ## Customer IDs
444
+
445
+ This component keys everything on `customerEmail` — the email Flutterwave has on file for the transaction or subscription. If your app identifies customers a different way (e.g. an internal user id), keep a mapping from your own id to the email you pass into this component.
446
+
447
+ ## Testing
448
+
449
+ ```bash
450
+ npm run test
451
+ ```
452
+
453
+ Component logic is tested with [`convex-test`](https://www.npmjs.com/package/convex-test) in `src/component/lib.test.ts`. Import `convex-flutterwave/test` in your own app to register this component's schema against your test instance.
454
+
455
+ ## Example App
456
+
457
+ `example/` is a full Vite + React demo, styled with Flutterwave's and Convex's own brand colors, that exercises the entire component end to end against your own Flutterwave test-mode account:
458
+
459
+ - **One-time payment** — pick an amount and currency, choose which payment options to offer, pay, and land on a result screen driven by `verifyTransaction()`.
460
+ - **Subscriptions** — the app bootstraps two demo plans via `createPaymentPlan()` / `listPaymentPlans()` on first load and lets you subscribe to either; after a successful subscription checkout it calls `syncCustomerSubscriptions()` automatically, since Flutterwave never sends a subscription-created webhook.
461
+ - **Retry flow** — a declined or abandoned payment (including the no-`transaction_id` case Flutterwave's failure redirect produces) surfaces a "Try again" action that returns you to the same flow with your details preserved.
462
+ - **Transaction history** — a live Convex query over `listTransactions()` / `listSubscriptions()` that updates the instant a webhook lands, plus a manual "Sync from Flutterwave" button for subscriptions.
463
+ - **Live developer console** — a side-docked panel that interleaves client-side actions (checkout started, verifying…) with the real webhook log from `listRecentEvents()`, reactively, so you can watch the entire lifecycle of a payment or subscription as it happens. Click any webhook row to see its raw payload.
464
+
465
+ Run it with:
466
+
467
+ ```bash
468
+ cd example
469
+ npm install
470
+ npx convex dev
471
+ # in another terminal
472
+ npm run dev
473
+ ```
474
+
475
+ Use Flutterwave's [test cards](https://developer.flutterwave.com/docs/integration-guides/testing-helpers) to exercise both outcomes — `5531 8866 5214 2950` (expiry `09/32`, CVV `564`, OTP `12345`, PIN `3310`) always succeeds, `5143 0105 2233 9965` (expiry `08/32`, CVV `276`) always fails address verification so you can see the retry flow.
476
+
477
+ ## Limitations
478
+
479
+ - `amount` is in the currency's major unit (e.g. naira), unlike some processors that use subunits — this component passes it through as-is.
480
+ - Only the webhook events listed above update local state; other events are received but not persisted beyond the raw idempotency record.
481
+ - There is no webhook for subscription creation — you must call `syncCustomerSubscriptions()` yourself after a plan-linked checkout succeeds (see [Subscriptions](#subscriptions)).
482
+ - `cancelSubscription` / `enableSubscription` require Flutterwave's numeric subscription id, only available after a sync, a `subscription.cancelled` webhook, or a call to `listSubscriptions()`.
483
+ - `createPaymentPlan` / `listPaymentPlans` talk to Flutterwave directly on every call — this component does not cache plans locally.
484
+ - In **test mode**, Flutterwave rewrites the customer email it echoes back in webhook and verify responses — e.g. `real@email.com` comes back as `ravesb_<hash>_real@email.com`. This component always keeps the email you originally passed to `initializeTransaction()` / `syncCustomerSubscriptions()` rather than whatever a later webhook or API response reports, so `listTransactions()` / `listSubscriptions()` stay queryable by the real address. This has not been observed in live mode.
485
+
486
+ ## Troubleshooting
487
+
488
+ **Webhook returns 401** — the `verif-hash` header didn't match. Confirm `FLW_WEBHOOK_SECRET_HASH` matches exactly what's set as the secret hash in the Flutterwave Dashboard webhook settings — it is not your API secret key.
489
+
490
+ **Transaction stays `pending`** — `initializeTransaction` only records `pending`; it becomes `successful`/`failed` once the `charge.completed` webhook arrives or you call `verifyTransaction`.
491
+
492
+ **Subscription never appears** — Flutterwave doesn't send a webhook when a subscription is created, only for later charges and cancellations. Call `syncCustomerSubscriptions({ email })` after a plan-linked checkout succeeds — this is required, not just a fallback.
493
+
494
+ **No `transaction_id` after redirect** — Flutterwave only includes `transaction_id` in the redirect when a chargeable attempt was made. A declined or abandoned checkout redirects with just `status` and `tx_ref`; there's nothing to verify server-side in that case.
495
+
496
+ **A transaction/subscription doesn't show up when searching by the email you used** — if you're in Flutterwave test mode and see a `customerEmail` like `ravesb_<hash>_you@example.com` in the Convex dashboard, that's Flutterwave's sandbox rewriting the email in its own responses, not a bug in your data. Components built against 0.0.1 or earlier stored that mangled value; upgrade and re-run the flow to get the real email back.
497
+
498
+ ## Contributing
499
+
500
+ See [CONTRIBUTING.md](./CONTRIBUTING.md).
501
+
502
+ ## Changelog
503
+
504
+ See [CHANGELOG.md](./CHANGELOG.md).
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=_ignore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_ignore.d.ts","sourceRoot":"","sources":["../../../src/client/_generated/_ignore.ts"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ // This is only here so convex-test can detect a _generated folder
3
+ //# sourceMappingURL=_ignore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_ignore.js","sourceRoot":"","sources":["../../../src/client/_generated/_ignore.ts"],"names":[],"mappings":";AAAA,kEAAkE"}