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,251 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query } from "./_generated/server.js";
|
|
3
|
+
|
|
4
|
+
const transactionStatusValidator = v.union(
|
|
5
|
+
v.literal("pending"),
|
|
6
|
+
v.literal("successful"),
|
|
7
|
+
v.literal("failed"),
|
|
8
|
+
v.literal("cancelled"),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const subscriptionStatusValidator = v.union(
|
|
12
|
+
v.literal("active"),
|
|
13
|
+
v.literal("cancelled"),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const transactionValidator = v.object({
|
|
17
|
+
_id: v.id("transactions"),
|
|
18
|
+
_creationTime: v.number(),
|
|
19
|
+
txRef: v.string(),
|
|
20
|
+
flwRef: v.optional(v.string()),
|
|
21
|
+
transactionId: v.optional(v.string()),
|
|
22
|
+
customerEmail: v.string(),
|
|
23
|
+
amount: v.number(),
|
|
24
|
+
currency: v.string(),
|
|
25
|
+
status: transactionStatusValidator,
|
|
26
|
+
paymentType: v.optional(v.string()),
|
|
27
|
+
narration: v.optional(v.string()),
|
|
28
|
+
paidAt: v.optional(v.number()),
|
|
29
|
+
metadata: v.optional(v.string()),
|
|
30
|
+
createdAt: v.number(),
|
|
31
|
+
updatedAt: v.number(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const subscriptionValidator = v.object({
|
|
35
|
+
_id: v.id("subscriptions"),
|
|
36
|
+
_creationTime: v.number(),
|
|
37
|
+
subscriptionId: v.string(),
|
|
38
|
+
customerEmail: v.string(),
|
|
39
|
+
planId: v.optional(v.string()),
|
|
40
|
+
amount: v.optional(v.number()),
|
|
41
|
+
status: subscriptionStatusValidator,
|
|
42
|
+
createdAt: v.number(),
|
|
43
|
+
updatedAt: v.number(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const webhookEventValidator = v.object({
|
|
47
|
+
_id: v.id("webhookEvents"),
|
|
48
|
+
_creationTime: v.number(),
|
|
49
|
+
eventId: v.string(),
|
|
50
|
+
eventType: v.string(),
|
|
51
|
+
txRef: v.optional(v.string()),
|
|
52
|
+
payload: v.string(),
|
|
53
|
+
receivedAt: v.number(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ─── Queries ────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export const getTransaction = query({
|
|
59
|
+
args: { txRef: v.string() },
|
|
60
|
+
returns: v.union(v.null(), transactionValidator),
|
|
61
|
+
handler: async (ctx, args) => {
|
|
62
|
+
return await ctx.db
|
|
63
|
+
.query("transactions")
|
|
64
|
+
.withIndex("by_txRef", (q) => q.eq("txRef", args.txRef))
|
|
65
|
+
.first();
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const listTransactions = query({
|
|
70
|
+
args: { customerEmail: v.string(), limit: v.optional(v.number()) },
|
|
71
|
+
returns: v.array(transactionValidator),
|
|
72
|
+
handler: async (ctx, args) => {
|
|
73
|
+
return await ctx.db
|
|
74
|
+
.query("transactions")
|
|
75
|
+
.withIndex("by_customerEmail", (q) => q.eq("customerEmail", args.customerEmail))
|
|
76
|
+
.order("desc")
|
|
77
|
+
.take(args.limit ?? 50);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const getSubscription = query({
|
|
82
|
+
args: { subscriptionId: v.string() },
|
|
83
|
+
returns: v.union(v.null(), subscriptionValidator),
|
|
84
|
+
handler: async (ctx, args) => {
|
|
85
|
+
return await ctx.db
|
|
86
|
+
.query("subscriptions")
|
|
87
|
+
.withIndex("by_subscriptionId", (q) => q.eq("subscriptionId", args.subscriptionId))
|
|
88
|
+
.first();
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const listSubscriptions = query({
|
|
93
|
+
args: { customerEmail: v.string() },
|
|
94
|
+
returns: v.array(subscriptionValidator),
|
|
95
|
+
handler: async (ctx, args) => {
|
|
96
|
+
return await ctx.db
|
|
97
|
+
.query("subscriptions")
|
|
98
|
+
.withIndex("by_customerEmail", (q) => q.eq("customerEmail", args.customerEmail))
|
|
99
|
+
.order("desc")
|
|
100
|
+
.collect();
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const hasActiveSubscription = query({
|
|
105
|
+
args: { customerEmail: v.string() },
|
|
106
|
+
returns: v.boolean(),
|
|
107
|
+
handler: async (ctx, args) => {
|
|
108
|
+
const sub = await ctx.db
|
|
109
|
+
.query("subscriptions")
|
|
110
|
+
.withIndex("by_customerEmail", (q) => q.eq("customerEmail", args.customerEmail))
|
|
111
|
+
.order("desc")
|
|
112
|
+
.first();
|
|
113
|
+
return sub?.status === "active";
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const listRecentEvents = query({
|
|
118
|
+
args: { limit: v.optional(v.number()) },
|
|
119
|
+
returns: v.array(webhookEventValidator),
|
|
120
|
+
handler: async (ctx, args) => {
|
|
121
|
+
return await ctx.db.query("webhookEvents").order("desc").take(args.limit ?? 50);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const getStats = query({
|
|
126
|
+
args: {},
|
|
127
|
+
returns: v.object({ transactions: v.number(), subscriptions: v.number(), events: v.number() }),
|
|
128
|
+
handler: async (ctx) => {
|
|
129
|
+
const [transactions, subscriptions, events] = await Promise.all([
|
|
130
|
+
ctx.db.query("transactions").collect(),
|
|
131
|
+
ctx.db.query("subscriptions").collect(),
|
|
132
|
+
ctx.db.query("webhookEvents").collect(),
|
|
133
|
+
]);
|
|
134
|
+
return { transactions: transactions.length, subscriptions: subscriptions.length, events: events.length };
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ─── Mutations ──────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export const recordTransaction = mutation({
|
|
141
|
+
args: {
|
|
142
|
+
txRef: v.string(),
|
|
143
|
+
flwRef: v.optional(v.string()),
|
|
144
|
+
transactionId: v.optional(v.string()),
|
|
145
|
+
customerEmail: v.string(),
|
|
146
|
+
amount: v.number(),
|
|
147
|
+
currency: v.string(),
|
|
148
|
+
status: transactionStatusValidator,
|
|
149
|
+
paymentType: v.optional(v.string()),
|
|
150
|
+
narration: v.optional(v.string()),
|
|
151
|
+
paidAt: v.optional(v.number()),
|
|
152
|
+
metadata: v.optional(v.string()),
|
|
153
|
+
},
|
|
154
|
+
returns: v.id("transactions"),
|
|
155
|
+
handler: async (ctx, args) => {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const existing = await ctx.db
|
|
158
|
+
.query("transactions")
|
|
159
|
+
.withIndex("by_txRef", (q) => q.eq("txRef", args.txRef))
|
|
160
|
+
.first();
|
|
161
|
+
|
|
162
|
+
if (existing) {
|
|
163
|
+
// Keep the email the merchant originally passed to initializeTransaction
|
|
164
|
+
// rather than whatever the webhook/verify response echoes back. Some
|
|
165
|
+
// processors (Flutterwave's test mode, notably) rewrite the customer
|
|
166
|
+
// email in their own responses — e.g. prefixing it with a sandbox
|
|
167
|
+
// routing tag like "ravesb_<hash>_" — which would otherwise silently
|
|
168
|
+
// overwrite the real address callers query by.
|
|
169
|
+
const { customerEmail: _incomingEmail, ...rest } = args;
|
|
170
|
+
await ctx.db.patch(existing._id, { ...rest, updatedAt: now });
|
|
171
|
+
return existing._id;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return await ctx.db.insert("transactions", {
|
|
175
|
+
...args,
|
|
176
|
+
createdAt: now,
|
|
177
|
+
updatedAt: now,
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const recordSubscriptionEvent = mutation({
|
|
183
|
+
args: {
|
|
184
|
+
subscriptionId: v.string(),
|
|
185
|
+
customerEmail: v.string(),
|
|
186
|
+
planId: v.optional(v.string()),
|
|
187
|
+
amount: v.optional(v.number()),
|
|
188
|
+
status: subscriptionStatusValidator,
|
|
189
|
+
},
|
|
190
|
+
returns: v.id("subscriptions"),
|
|
191
|
+
handler: async (ctx, args) => {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
const existing = await ctx.db
|
|
194
|
+
.query("subscriptions")
|
|
195
|
+
.withIndex("by_subscriptionId", (q) => q.eq("subscriptionId", args.subscriptionId))
|
|
196
|
+
.first();
|
|
197
|
+
|
|
198
|
+
if (existing) {
|
|
199
|
+
// Same reasoning as recordTransaction: don't let a later webhook or
|
|
200
|
+
// sync call overwrite the email a subscription was first recorded
|
|
201
|
+
// under with a processor-mangled value.
|
|
202
|
+
const { customerEmail: _incomingEmail, ...rest } = args;
|
|
203
|
+
await ctx.db.patch(existing._id, { ...rest, updatedAt: now });
|
|
204
|
+
return existing._id;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return await ctx.db.insert("subscriptions", {
|
|
208
|
+
...args,
|
|
209
|
+
createdAt: now,
|
|
210
|
+
updatedAt: now,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
export const updateSubscriptionStatus = mutation({
|
|
216
|
+
args: {
|
|
217
|
+
subscriptionId: v.string(),
|
|
218
|
+
status: subscriptionStatusValidator,
|
|
219
|
+
},
|
|
220
|
+
returns: v.null(),
|
|
221
|
+
handler: async (ctx, args) => {
|
|
222
|
+
const existing = await ctx.db
|
|
223
|
+
.query("subscriptions")
|
|
224
|
+
.withIndex("by_subscriptionId", (q) => q.eq("subscriptionId", args.subscriptionId))
|
|
225
|
+
.first();
|
|
226
|
+
if (!existing) return null;
|
|
227
|
+
await ctx.db.patch(existing._id, { status: args.status, updatedAt: Date.now() });
|
|
228
|
+
return null;
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
export const checkAndRecordEvent = mutation({
|
|
233
|
+
args: {
|
|
234
|
+
eventId: v.string(),
|
|
235
|
+
eventType: v.string(),
|
|
236
|
+
txRef: v.optional(v.string()),
|
|
237
|
+
payload: v.string(),
|
|
238
|
+
},
|
|
239
|
+
returns: v.object({ alreadyProcessed: v.boolean() }),
|
|
240
|
+
handler: async (ctx, args) => {
|
|
241
|
+
const existing = await ctx.db
|
|
242
|
+
.query("webhookEvents")
|
|
243
|
+
.withIndex("by_eventId", (q) => q.eq("eventId", args.eventId))
|
|
244
|
+
.first();
|
|
245
|
+
if (existing) {
|
|
246
|
+
return { alreadyProcessed: true };
|
|
247
|
+
}
|
|
248
|
+
await ctx.db.insert("webhookEvents", { ...args, receivedAt: Date.now() });
|
|
249
|
+
return { alreadyProcessed: false };
|
|
250
|
+
},
|
|
251
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
transactions: defineTable({
|
|
6
|
+
txRef: v.string(),
|
|
7
|
+
flwRef: v.optional(v.string()),
|
|
8
|
+
transactionId: v.optional(v.string()),
|
|
9
|
+
customerEmail: v.string(),
|
|
10
|
+
amount: v.number(),
|
|
11
|
+
currency: v.string(),
|
|
12
|
+
status: v.union(
|
|
13
|
+
v.literal("pending"),
|
|
14
|
+
v.literal("successful"),
|
|
15
|
+
v.literal("failed"),
|
|
16
|
+
v.literal("cancelled"),
|
|
17
|
+
),
|
|
18
|
+
paymentType: v.optional(v.string()),
|
|
19
|
+
narration: v.optional(v.string()),
|
|
20
|
+
paidAt: v.optional(v.number()),
|
|
21
|
+
metadata: v.optional(v.string()),
|
|
22
|
+
createdAt: v.number(),
|
|
23
|
+
updatedAt: v.number(),
|
|
24
|
+
})
|
|
25
|
+
.index("by_txRef", ["txRef"])
|
|
26
|
+
.index("by_customerEmail", ["customerEmail"]),
|
|
27
|
+
|
|
28
|
+
subscriptions: defineTable({
|
|
29
|
+
subscriptionId: v.string(),
|
|
30
|
+
customerEmail: v.string(),
|
|
31
|
+
planId: v.optional(v.string()),
|
|
32
|
+
amount: v.optional(v.number()),
|
|
33
|
+
status: v.union(v.literal("active"), v.literal("cancelled")),
|
|
34
|
+
createdAt: v.number(),
|
|
35
|
+
updatedAt: v.number(),
|
|
36
|
+
})
|
|
37
|
+
.index("by_subscriptionId", ["subscriptionId"])
|
|
38
|
+
.index("by_customerEmail", ["customerEmail"])
|
|
39
|
+
.index("by_status", ["status"]),
|
|
40
|
+
|
|
41
|
+
webhookEvents: defineTable({
|
|
42
|
+
eventId: v.string(),
|
|
43
|
+
eventType: v.string(),
|
|
44
|
+
txRef: v.optional(v.string()),
|
|
45
|
+
payload: v.string(),
|
|
46
|
+
receivedAt: v.number(),
|
|
47
|
+
})
|
|
48
|
+
.index("by_eventId", ["eventId"])
|
|
49
|
+
.index("by_eventType", ["eventType"]),
|
|
50
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import { test } from "vitest";
|
|
3
|
+
import schema from "./schema.js";
|
|
4
|
+
import { convexTest } from "convex-test";
|
|
5
|
+
export const modules = import.meta.glob("./**/*.*s");
|
|
6
|
+
|
|
7
|
+
export function initConvexTest() {
|
|
8
|
+
const t = convexTest(schema, modules);
|
|
9
|
+
return t;
|
|
10
|
+
}
|
|
11
|
+
test("setup", () => {});
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
import type { TestConvex } from "convex-test";
|
|
3
|
+
import type { GenericSchema, SchemaDefinition } from "convex/server";
|
|
4
|
+
import schema from "./component/schema.js";
|
|
5
|
+
const modules = import.meta.glob("./component/**/*.ts");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the component with the test convex instance.
|
|
9
|
+
* @param t - The test convex instance, e.g. from calling `convexTest`.
|
|
10
|
+
* @param name - The name of the component, as registered in convex.config.ts.
|
|
11
|
+
*/
|
|
12
|
+
export function register(
|
|
13
|
+
t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
|
|
14
|
+
name: string = "convexFlutterwave",
|
|
15
|
+
) {
|
|
16
|
+
t.registerComponent(name, schema, modules);
|
|
17
|
+
}
|
|
18
|
+
export default { register, schema, modules };
|