@nordsym/apiclaw 1.3.6 → 1.3.7
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/README.md +33 -0
- package/convex/_generated/api.d.ts +12 -0
- package/convex/billing.ts +651 -216
- package/convex/crons.ts +17 -0
- package/convex/email.ts +135 -82
- package/convex/feedback.ts +265 -0
- package/convex/http.ts +80 -4
- package/convex/logs.ts +287 -0
- package/convex/providerKeys.ts +209 -0
- package/convex/providers.ts +18 -0
- package/convex/schema.ts +115 -0
- package/convex/stripeActions.ts +512 -0
- package/convex/webhooks.ts +494 -0
- package/convex/workspaces.ts +74 -1
- package/dist/index.js +178 -0
- package/dist/index.js.map +1 -1
- package/dist/metered.d.ts +62 -0
- package/dist/metered.d.ts.map +1 -0
- package/dist/metered.js +81 -0
- package/dist/metered.js.map +1 -0
- package/dist/stripe.d.ts +62 -0
- package/dist/stripe.d.ts.map +1 -1
- package/dist/stripe.js +212 -0
- package/dist/stripe.js.map +1 -1
- package/docs/PRD-final-polish.md +117 -0
- package/docs/PRD-mobile-responsive.md +56 -0
- package/docs/PRD-navigation-expansion.md +295 -0
- package/docs/PRD-stripe-billing.md +312 -0
- package/docs/PRD-workspace-cleanup.md +200 -0
- package/landing/src/app/api/billing/checkout/route.ts +109 -0
- package/landing/src/app/api/billing/payment-method/route.ts +118 -0
- package/landing/src/app/api/billing/portal/route.ts +64 -0
- package/landing/src/app/auth/verify/page.tsx +20 -5
- package/landing/src/app/earn/page.tsx +6 -6
- package/landing/src/app/login/page.tsx +1 -1
- package/landing/src/app/page.tsx +70 -70
- package/landing/src/app/providers/dashboard/page.tsx +1 -1
- package/landing/src/app/workspace/page.tsx +3497 -535
- package/landing/src/components/CheckoutButton.tsx +188 -0
- package/landing/src/components/Toast.tsx +84 -0
- package/landing/src/lib/stats.json +1 -1
- package/landing/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/index.ts +205 -0
- package/src/metered.ts +149 -0
- package/src/stripe.ts +253 -0
package/convex/billing.ts
CHANGED
|
@@ -1,230 +1,319 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
|
-
import { mutation, query,
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
import { mutation, query, internalMutation, internalAction, internalQuery } from "./_generated/server";
|
|
3
|
+
import { Id } from "./_generated/dataModel";
|
|
4
|
+
import { internal } from "./_generated/api";
|
|
5
|
+
import Stripe from "stripe";
|
|
6
|
+
|
|
7
|
+
// Initialize Stripe
|
|
8
|
+
function getStripe(): Stripe {
|
|
9
|
+
const key = process.env.STRIPE_SECRET_KEY;
|
|
10
|
+
if (!key) {
|
|
11
|
+
throw new Error("STRIPE_SECRET_KEY not configured");
|
|
12
|
+
}
|
|
13
|
+
return new Stripe(key);
|
|
14
|
+
}
|
|
11
15
|
|
|
12
16
|
// ============================================
|
|
13
|
-
//
|
|
17
|
+
// MUTATIONS
|
|
14
18
|
// ============================================
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Link a Stripe customer to a workspace
|
|
22
|
+
*/
|
|
23
|
+
export const linkCustomer = mutation({
|
|
18
24
|
args: {
|
|
19
25
|
workspaceId: v.id("workspaces"),
|
|
26
|
+
stripeCustomerId: v.string(),
|
|
20
27
|
},
|
|
21
|
-
handler: async (ctx, args)
|
|
22
|
-
const
|
|
23
|
-
if (!
|
|
24
|
-
|
|
28
|
+
handler: async (ctx, args) => {
|
|
29
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
30
|
+
if (!workspace) {
|
|
31
|
+
throw new Error("Workspace not found");
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
await ctx.db.patch(args.workspaceId, {
|
|
35
|
+
stripeCustomerId: args.stripeCustomerId,
|
|
36
|
+
updatedAt: Date.now(),
|
|
30
37
|
});
|
|
31
|
-
|
|
38
|
+
|
|
39
|
+
return { success: true };
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Update subscription status for a workspace
|
|
45
|
+
*/
|
|
46
|
+
export const updateSubscription = mutation({
|
|
47
|
+
args: {
|
|
48
|
+
workspaceId: v.id("workspaces"),
|
|
49
|
+
stripeSubscriptionId: v.optional(v.string()),
|
|
50
|
+
billingPlan: v.string(),
|
|
51
|
+
},
|
|
52
|
+
handler: async (ctx, args) => {
|
|
53
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
32
54
|
if (!workspace) {
|
|
33
|
-
|
|
55
|
+
throw new Error("Workspace not found");
|
|
34
56
|
}
|
|
35
57
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
// Update tier and usage limit based on plan
|
|
59
|
+
const planLimits: Record<string, number> = {
|
|
60
|
+
free: 100,
|
|
61
|
+
usage_based: 999999999, // Effectively unlimited
|
|
62
|
+
starter: 1000,
|
|
63
|
+
pro: 10000,
|
|
64
|
+
scale: 100000,
|
|
65
|
+
};
|
|
40
66
|
|
|
41
|
-
|
|
42
|
-
// Create Stripe customer
|
|
43
|
-
const response = await fetch("https://api.stripe.com/v1/customers", {
|
|
44
|
-
method: "POST",
|
|
45
|
-
headers: {
|
|
46
|
-
"Authorization": `Bearer ${stripeSecretKey}`,
|
|
47
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
48
|
-
},
|
|
49
|
-
body: new URLSearchParams({
|
|
50
|
-
email: workspace.email,
|
|
51
|
-
"metadata[workspaceId]": args.workspaceId,
|
|
52
|
-
"metadata[source]": "apiclaw",
|
|
53
|
-
}),
|
|
54
|
-
});
|
|
67
|
+
const newLimit = planLimits[args.billingPlan] || 100;
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
await ctx.db.patch(args.workspaceId, {
|
|
70
|
+
stripeSubscriptionId: args.stripeSubscriptionId,
|
|
71
|
+
billingPlan: args.billingPlan,
|
|
72
|
+
tier: args.billingPlan === "free" ? "free" : "pro",
|
|
73
|
+
usageLimit: newLimit,
|
|
74
|
+
updatedAt: Date.now(),
|
|
75
|
+
});
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
return { success: true, newLimit };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Record daily usage for billing
|
|
83
|
+
*/
|
|
84
|
+
export const recordUsage = mutation({
|
|
85
|
+
args: {
|
|
86
|
+
workspaceId: v.id("workspaces"),
|
|
87
|
+
callCount: v.number(),
|
|
88
|
+
date: v.string(), // "2026-02-28" format
|
|
89
|
+
},
|
|
90
|
+
handler: async (ctx, args) => {
|
|
91
|
+
// Check if record exists for this date
|
|
92
|
+
const existing = await ctx.db
|
|
93
|
+
.query("usageRecords")
|
|
94
|
+
.withIndex("by_workspaceId_date", (q) =>
|
|
95
|
+
q.eq("workspaceId", args.workspaceId).eq("date", args.date)
|
|
96
|
+
)
|
|
97
|
+
.unique();
|
|
98
|
+
|
|
99
|
+
if (existing) {
|
|
100
|
+
// Update existing record
|
|
101
|
+
await ctx.db.patch(existing._id, {
|
|
102
|
+
callCount: existing.callCount + args.callCount,
|
|
103
|
+
updatedAt: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
return { id: existing._id, callCount: existing.callCount + args.callCount };
|
|
106
|
+
} else {
|
|
107
|
+
// Create new record
|
|
108
|
+
const id = await ctx.db.insert("usageRecords", {
|
|
64
109
|
workspaceId: args.workspaceId,
|
|
65
|
-
|
|
110
|
+
date: args.date,
|
|
111
|
+
callCount: args.callCount,
|
|
112
|
+
reportedToStripe: false,
|
|
113
|
+
createdAt: Date.now(),
|
|
114
|
+
updatedAt: Date.now(),
|
|
66
115
|
});
|
|
67
|
-
|
|
68
|
-
return { success: true, customerId: customer.id };
|
|
69
|
-
} catch (error) {
|
|
70
|
-
return { success: false, error: String(error) };
|
|
116
|
+
return { id, callCount: args.callCount };
|
|
71
117
|
}
|
|
72
118
|
},
|
|
73
119
|
});
|
|
74
120
|
|
|
75
|
-
|
|
76
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Process a successful payment (from webhook)
|
|
123
|
+
*/
|
|
124
|
+
export const processPayment = mutation({
|
|
77
125
|
args: {
|
|
126
|
+
stripeInvoiceId: v.string(),
|
|
78
127
|
workspaceId: v.id("workspaces"),
|
|
79
|
-
|
|
128
|
+
amount: v.number(), // in cents
|
|
129
|
+
periodStart: v.number(),
|
|
130
|
+
periodEnd: v.number(),
|
|
131
|
+
callCount: v.number(),
|
|
132
|
+
pdfUrl: v.optional(v.string()),
|
|
80
133
|
},
|
|
81
134
|
handler: async (ctx, args) => {
|
|
135
|
+
// Check for idempotency - don't process same invoice twice
|
|
136
|
+
const existing = await ctx.db
|
|
137
|
+
.query("invoices")
|
|
138
|
+
.withIndex("by_stripeInvoiceId", (q) =>
|
|
139
|
+
q.eq("stripeInvoiceId", args.stripeInvoiceId)
|
|
140
|
+
)
|
|
141
|
+
.unique();
|
|
142
|
+
|
|
143
|
+
if (existing) {
|
|
144
|
+
// Already processed, return existing
|
|
145
|
+
return { id: existing._id, alreadyProcessed: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Create invoice record
|
|
149
|
+
const id = await ctx.db.insert("invoices", {
|
|
150
|
+
workspaceId: args.workspaceId,
|
|
151
|
+
stripeInvoiceId: args.stripeInvoiceId,
|
|
152
|
+
amount: args.amount,
|
|
153
|
+
status: "paid",
|
|
154
|
+
periodStart: args.periodStart,
|
|
155
|
+
periodEnd: args.periodEnd,
|
|
156
|
+
callCount: args.callCount,
|
|
157
|
+
pdfUrl: args.pdfUrl,
|
|
158
|
+
createdAt: Date.now(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Update workspace last billing date
|
|
82
162
|
await ctx.db.patch(args.workspaceId, {
|
|
83
|
-
|
|
163
|
+
lastBillingDate: Date.now(),
|
|
84
164
|
updatedAt: Date.now(),
|
|
85
165
|
});
|
|
166
|
+
|
|
167
|
+
return { id, alreadyProcessed: false };
|
|
86
168
|
},
|
|
87
169
|
});
|
|
88
170
|
|
|
89
|
-
|
|
90
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Increment credit balance (for prepaid credits)
|
|
173
|
+
*/
|
|
174
|
+
export const incrementCredits = mutation({
|
|
91
175
|
args: {
|
|
92
176
|
workspaceId: v.id("workspaces"),
|
|
177
|
+
amount: v.number(), // in cents
|
|
93
178
|
},
|
|
94
179
|
handler: async (ctx, args) => {
|
|
95
|
-
|
|
180
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
181
|
+
if (!workspace) {
|
|
182
|
+
throw new Error("Workspace not found");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const currentBalance = workspace.creditBalance || 0;
|
|
186
|
+
const newBalance = currentBalance + args.amount;
|
|
187
|
+
|
|
188
|
+
await ctx.db.patch(args.workspaceId, {
|
|
189
|
+
creditBalance: newBalance,
|
|
190
|
+
updatedAt: Date.now(),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { previousBalance: currentBalance, newBalance };
|
|
96
194
|
},
|
|
97
195
|
});
|
|
98
196
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Create checkout session for subscription
|
|
104
|
-
export const createCheckoutSession = action({
|
|
197
|
+
/**
|
|
198
|
+
* Decrement credit balance (when using prepaid credits)
|
|
199
|
+
*/
|
|
200
|
+
export const decrementCredits = mutation({
|
|
105
201
|
args: {
|
|
106
202
|
workspaceId: v.id("workspaces"),
|
|
107
|
-
|
|
108
|
-
cancelUrl: v.string(),
|
|
203
|
+
amount: v.number(), // in cents
|
|
109
204
|
},
|
|
110
|
-
handler: async (ctx, args)
|
|
111
|
-
const
|
|
112
|
-
if (!stripeSecretKey) {
|
|
113
|
-
return { success: false, error: "Stripe not configured" };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Get workspace
|
|
117
|
-
const workspace = await ctx.runQuery(api.billing.getWorkspace, {
|
|
118
|
-
workspaceId: args.workspaceId
|
|
119
|
-
});
|
|
120
|
-
|
|
205
|
+
handler: async (ctx, args) => {
|
|
206
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
121
207
|
if (!workspace) {
|
|
122
|
-
|
|
208
|
+
throw new Error("Workspace not found");
|
|
123
209
|
}
|
|
124
210
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const result = await ctx.runAction(api.billing.createStripeCustomer, {
|
|
129
|
-
workspaceId: args.workspaceId,
|
|
130
|
-
});
|
|
131
|
-
if (!result.success || !result.customerId) {
|
|
132
|
-
return { success: false, error: result.error || "Failed to create customer" };
|
|
133
|
-
}
|
|
134
|
-
customerId = result.customerId;
|
|
211
|
+
const currentBalance = workspace.creditBalance || 0;
|
|
212
|
+
if (currentBalance < args.amount) {
|
|
213
|
+
throw new Error("Insufficient credit balance");
|
|
135
214
|
}
|
|
136
215
|
|
|
137
|
-
|
|
138
|
-
// Create checkout session for Pro subscription
|
|
139
|
-
// Using the existing APIClaw Pro price
|
|
140
|
-
const response = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
|
141
|
-
method: "POST",
|
|
142
|
-
headers: {
|
|
143
|
-
"Authorization": `Bearer ${stripeSecretKey}`,
|
|
144
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
145
|
-
},
|
|
146
|
-
body: new URLSearchParams({
|
|
147
|
-
customer: customerId,
|
|
148
|
-
mode: "subscription",
|
|
149
|
-
"line_items[0][price]": "price_1T1OC2RtJYK3aJTqlJZskgtP", // APIClaw Pro $99/month
|
|
150
|
-
"line_items[0][quantity]": "1",
|
|
151
|
-
success_url: args.successUrl,
|
|
152
|
-
cancel_url: args.cancelUrl,
|
|
153
|
-
"metadata[workspaceId]": args.workspaceId,
|
|
154
|
-
"metadata[type]": "upgrade_pro",
|
|
155
|
-
}),
|
|
156
|
-
});
|
|
216
|
+
const newBalance = currentBalance - args.amount;
|
|
157
217
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
if (!response.ok || session.error) {
|
|
165
|
-
return { success: false, error: session.error?.message || "Failed to create session" };
|
|
166
|
-
}
|
|
218
|
+
await ctx.db.patch(args.workspaceId, {
|
|
219
|
+
creditBalance: newBalance,
|
|
220
|
+
updatedAt: Date.now(),
|
|
221
|
+
});
|
|
167
222
|
|
|
168
|
-
|
|
169
|
-
success: true,
|
|
170
|
-
sessionId: session.id,
|
|
171
|
-
url: session.url,
|
|
172
|
-
};
|
|
173
|
-
} catch (error) {
|
|
174
|
-
return { success: false, error: String(error) };
|
|
175
|
-
}
|
|
223
|
+
return { previousBalance: currentBalance, newBalance };
|
|
176
224
|
},
|
|
177
225
|
});
|
|
178
226
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Mark usage as reported to Stripe
|
|
229
|
+
*/
|
|
230
|
+
export const markUsageReported = internalMutation({
|
|
231
|
+
args: {
|
|
232
|
+
usageRecordId: v.id("usageRecords"),
|
|
233
|
+
stripeUsageRecordId: v.string(),
|
|
234
|
+
},
|
|
235
|
+
handler: async (ctx, args) => {
|
|
236
|
+
await ctx.db.patch(args.usageRecordId, {
|
|
237
|
+
reportedToStripe: true,
|
|
238
|
+
stripeUsageRecordId: args.stripeUsageRecordId,
|
|
239
|
+
updatedAt: Date.now(),
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
});
|
|
182
243
|
|
|
183
|
-
|
|
184
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Update invoice status (from webhook)
|
|
246
|
+
*/
|
|
247
|
+
export const updateInvoiceStatus = mutation({
|
|
248
|
+
args: {
|
|
249
|
+
stripeInvoiceId: v.string(),
|
|
250
|
+
status: v.string(),
|
|
251
|
+
},
|
|
252
|
+
handler: async (ctx, args) => {
|
|
253
|
+
const invoice = await ctx.db
|
|
254
|
+
.query("invoices")
|
|
255
|
+
.withIndex("by_stripeInvoiceId", (q) =>
|
|
256
|
+
q.eq("stripeInvoiceId", args.stripeInvoiceId)
|
|
257
|
+
)
|
|
258
|
+
.unique();
|
|
259
|
+
|
|
260
|
+
if (!invoice) {
|
|
261
|
+
return { found: false };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await ctx.db.patch(invoice._id, {
|
|
265
|
+
status: args.status,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return { found: true, id: invoice._id };
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Reset usage count on subscription cancellation
|
|
274
|
+
* Gives user a clean slate when downgrading to free
|
|
275
|
+
*/
|
|
276
|
+
export const resetUsageOnCancellation = mutation({
|
|
185
277
|
args: {
|
|
186
278
|
workspaceId: v.id("workspaces"),
|
|
187
|
-
tier: v.optional(v.string()),
|
|
188
|
-
stripeSubscriptionId: v.optional(v.string()),
|
|
189
279
|
},
|
|
190
280
|
handler: async (ctx, args) => {
|
|
191
281
|
const workspace = await ctx.db.get(args.workspaceId);
|
|
192
282
|
if (!workspace) {
|
|
193
|
-
|
|
283
|
+
throw new Error("Workspace not found");
|
|
194
284
|
}
|
|
195
285
|
|
|
196
|
-
const newTier = args.tier || "pro";
|
|
197
|
-
const newLimit = TIER_LIMITS[newTier] || TIER_LIMITS.pro;
|
|
198
|
-
|
|
199
286
|
await ctx.db.patch(args.workspaceId, {
|
|
200
|
-
|
|
201
|
-
usageLimit: newLimit,
|
|
202
|
-
status: "active",
|
|
287
|
+
usageCount: 0,
|
|
203
288
|
updatedAt: Date.now(),
|
|
204
289
|
});
|
|
205
290
|
|
|
206
|
-
return {
|
|
207
|
-
success: true,
|
|
208
|
-
tier: newTier,
|
|
209
|
-
usageLimit: newLimit,
|
|
210
|
-
};
|
|
291
|
+
return { success: true, previousUsage: workspace.usageCount };
|
|
211
292
|
},
|
|
212
293
|
});
|
|
213
294
|
|
|
214
|
-
|
|
215
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Update payment method info (from webhook)
|
|
297
|
+
*/
|
|
298
|
+
export const updatePaymentMethodInfo = mutation({
|
|
216
299
|
args: {
|
|
217
300
|
workspaceId: v.id("workspaces"),
|
|
218
|
-
|
|
301
|
+
hasPaymentMethod: v.boolean(),
|
|
302
|
+
paymentMethodType: v.optional(v.string()),
|
|
303
|
+
cardBrand: v.optional(v.string()),
|
|
304
|
+
cardLast4: v.optional(v.string()),
|
|
219
305
|
},
|
|
220
306
|
handler: async (ctx, args) => {
|
|
221
307
|
const workspace = await ctx.db.get(args.workspaceId);
|
|
222
308
|
if (!workspace) {
|
|
223
|
-
|
|
309
|
+
throw new Error("Workspace not found");
|
|
224
310
|
}
|
|
225
311
|
|
|
226
312
|
await ctx.db.patch(args.workspaceId, {
|
|
227
|
-
|
|
313
|
+
hasPaymentMethod: args.hasPaymentMethod,
|
|
314
|
+
paymentMethodType: args.paymentMethodType,
|
|
315
|
+
cardBrand: args.cardBrand,
|
|
316
|
+
cardLast4: args.cardLast4,
|
|
228
317
|
updatedAt: Date.now(),
|
|
229
318
|
});
|
|
230
319
|
|
|
@@ -232,110 +321,456 @@ export const suspendWorkspace = mutation({
|
|
|
232
321
|
},
|
|
233
322
|
});
|
|
234
323
|
|
|
235
|
-
//
|
|
236
|
-
|
|
324
|
+
// ============================================
|
|
325
|
+
// QUERIES
|
|
326
|
+
// ============================================
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get billing info for a workspace
|
|
330
|
+
*/
|
|
331
|
+
export const getInfo = query({
|
|
237
332
|
args: {
|
|
238
|
-
|
|
333
|
+
workspaceId: v.id("workspaces"),
|
|
239
334
|
},
|
|
240
335
|
handler: async (ctx, args) => {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
336
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
337
|
+
if (!workspace) {
|
|
338
|
+
throw new Error("Workspace not found");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Get recent invoices
|
|
342
|
+
const invoices = await ctx.db
|
|
343
|
+
.query("invoices")
|
|
344
|
+
.withIndex("by_workspaceId_createdAt", (q) =>
|
|
345
|
+
q.eq("workspaceId", args.workspaceId)
|
|
346
|
+
)
|
|
347
|
+
.order("desc")
|
|
348
|
+
.take(10);
|
|
349
|
+
|
|
350
|
+
// Calculate current period usage
|
|
351
|
+
const now = new Date();
|
|
352
|
+
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
353
|
+
const periodStartStr = periodStart.toISOString().split("T")[0];
|
|
354
|
+
|
|
355
|
+
const usageRecords = await ctx.db
|
|
356
|
+
.query("usageRecords")
|
|
357
|
+
.withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
|
|
358
|
+
.collect();
|
|
359
|
+
|
|
360
|
+
const currentPeriodUsage = usageRecords
|
|
361
|
+
.filter((r) => r.date >= periodStartStr)
|
|
362
|
+
.reduce((sum, r) => sum + r.callCount, 0);
|
|
363
|
+
|
|
364
|
+
// Determine plan limits
|
|
365
|
+
const plan = workspace.billingPlan || "free";
|
|
366
|
+
const planLimits: Record<string, number> = {
|
|
367
|
+
free: 100,
|
|
368
|
+
usage_based: 999999999,
|
|
369
|
+
starter: 1000,
|
|
370
|
+
pro: 10000,
|
|
371
|
+
scale: 100000,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
plan,
|
|
376
|
+
tier: workspace.tier,
|
|
377
|
+
usage: workspace.usageCount,
|
|
378
|
+
currentPeriodUsage,
|
|
379
|
+
limit: workspace.usageLimit,
|
|
380
|
+
creditBalance: workspace.creditBalance || 0,
|
|
381
|
+
stripeCustomerId: workspace.stripeCustomerId,
|
|
382
|
+
stripeSubscriptionId: workspace.stripeSubscriptionId,
|
|
383
|
+
lastBillingDate: workspace.lastBillingDate,
|
|
384
|
+
invoices: invoices.map((inv) => ({
|
|
385
|
+
id: inv._id,
|
|
386
|
+
stripeInvoiceId: inv.stripeInvoiceId,
|
|
387
|
+
amount: inv.amount,
|
|
388
|
+
status: inv.status,
|
|
389
|
+
periodStart: inv.periodStart,
|
|
390
|
+
periodEnd: inv.periodEnd,
|
|
391
|
+
callCount: inv.callCount,
|
|
392
|
+
pdfUrl: inv.pdfUrl,
|
|
393
|
+
createdAt: inv.createdAt,
|
|
394
|
+
})),
|
|
395
|
+
// Check if payment method needed
|
|
396
|
+
needsPaymentMethod:
|
|
397
|
+
plan === "free" && workspace.usageCount >= workspace.usageLimit,
|
|
398
|
+
};
|
|
245
399
|
},
|
|
246
400
|
});
|
|
247
401
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// Report usage to Stripe (for metered billing)
|
|
253
|
-
// Currently APIClaw Pro is flat-rate, but this prepares for metered billing
|
|
254
|
-
export const reportUsage = action({
|
|
402
|
+
/**
|
|
403
|
+
* Get current period usage
|
|
404
|
+
*/
|
|
405
|
+
export const getCurrentUsage = query({
|
|
255
406
|
args: {
|
|
256
407
|
workspaceId: v.id("workspaces"),
|
|
257
|
-
amount: v.number(),
|
|
258
|
-
description: v.optional(v.string()),
|
|
259
408
|
},
|
|
260
|
-
handler: async (ctx, args)
|
|
261
|
-
|
|
262
|
-
const workspace = await ctx.runQuery(api.billing.getWorkspace, {
|
|
263
|
-
workspaceId: args.workspaceId
|
|
264
|
-
});
|
|
265
|
-
|
|
409
|
+
handler: async (ctx, args) => {
|
|
410
|
+
const workspace = await ctx.db.get(args.workspaceId);
|
|
266
411
|
if (!workspace) {
|
|
267
|
-
|
|
412
|
+
throw new Error("Workspace not found");
|
|
268
413
|
}
|
|
269
414
|
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
415
|
+
// Current billing period (month)
|
|
416
|
+
const now = new Date();
|
|
417
|
+
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
418
|
+
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
419
|
+
const periodStartStr = periodStart.toISOString().split("T")[0];
|
|
275
420
|
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
workspaceId
|
|
280
|
-
|
|
281
|
-
});
|
|
421
|
+
// Get usage records for this period
|
|
422
|
+
const usageRecords = await ctx.db
|
|
423
|
+
.query("usageRecords")
|
|
424
|
+
.withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
|
|
425
|
+
.collect();
|
|
282
426
|
|
|
283
|
-
|
|
284
|
-
|
|
427
|
+
const periodRecords = usageRecords.filter((r) => r.date >= periodStartStr);
|
|
428
|
+
const callCount = periodRecords.reduce((sum, r) => sum + r.callCount, 0);
|
|
285
429
|
|
|
286
|
-
|
|
430
|
+
// Calculate estimated cost (for usage-based billing)
|
|
431
|
+
const FREE_CALLS = 100;
|
|
432
|
+
const COST_PER_CALL = 1; // 1 cent = $0.01
|
|
433
|
+
const billableCalls = Math.max(0, callCount - FREE_CALLS);
|
|
434
|
+
const estimatedCost = billableCalls * COST_PER_CALL;
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
callCount,
|
|
438
|
+
periodStart: periodStart.getTime(),
|
|
439
|
+
periodEnd: periodEnd.getTime(),
|
|
440
|
+
limit: workspace.usageLimit,
|
|
441
|
+
remaining: Math.max(0, workspace.usageLimit - workspace.usageCount),
|
|
442
|
+
percentUsed: Math.round((workspace.usageCount / workspace.usageLimit) * 100),
|
|
443
|
+
billableCalls,
|
|
444
|
+
estimatedCostCents: estimatedCost,
|
|
445
|
+
dailyBreakdown: periodRecords.map((r) => ({
|
|
446
|
+
date: r.date,
|
|
447
|
+
calls: r.callCount,
|
|
448
|
+
reportedToStripe: r.reportedToStripe,
|
|
449
|
+
})),
|
|
450
|
+
};
|
|
287
451
|
},
|
|
288
452
|
});
|
|
289
453
|
|
|
290
|
-
|
|
291
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Get invoices for a workspace
|
|
456
|
+
*/
|
|
457
|
+
export const getInvoices = query({
|
|
292
458
|
args: {
|
|
293
459
|
workspaceId: v.id("workspaces"),
|
|
294
|
-
|
|
460
|
+
limit: v.optional(v.number()),
|
|
295
461
|
},
|
|
296
462
|
handler: async (ctx, args) => {
|
|
297
|
-
const
|
|
298
|
-
|
|
463
|
+
const limit = args.limit || 20;
|
|
464
|
+
|
|
465
|
+
const invoices = await ctx.db
|
|
466
|
+
.query("invoices")
|
|
467
|
+
.withIndex("by_workspaceId_createdAt", (q) =>
|
|
468
|
+
q.eq("workspaceId", args.workspaceId)
|
|
469
|
+
)
|
|
470
|
+
.order("desc")
|
|
471
|
+
.take(limit);
|
|
472
|
+
|
|
473
|
+
return invoices.map((inv) => ({
|
|
474
|
+
id: inv._id,
|
|
475
|
+
stripeInvoiceId: inv.stripeInvoiceId,
|
|
476
|
+
amount: inv.amount,
|
|
477
|
+
amountFormatted: `$${(inv.amount / 100).toFixed(2)}`,
|
|
478
|
+
status: inv.status,
|
|
479
|
+
periodStart: inv.periodStart,
|
|
480
|
+
periodEnd: inv.periodEnd,
|
|
481
|
+
callCount: inv.callCount,
|
|
482
|
+
pdfUrl: inv.pdfUrl,
|
|
483
|
+
createdAt: inv.createdAt,
|
|
484
|
+
}));
|
|
485
|
+
},
|
|
486
|
+
});
|
|
299
487
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
488
|
+
/**
|
|
489
|
+
* Get unreported usage records (for cron job)
|
|
490
|
+
*/
|
|
491
|
+
export const getUnreportedUsage = query({
|
|
492
|
+
args: {},
|
|
493
|
+
handler: async (ctx) => {
|
|
494
|
+
const unreported = await ctx.db
|
|
495
|
+
.query("usageRecords")
|
|
496
|
+
.withIndex("by_reportedToStripe", (q) => q.eq("reportedToStripe", false))
|
|
497
|
+
.collect();
|
|
498
|
+
|
|
499
|
+
return unreported;
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Get workspace by Stripe customer ID
|
|
505
|
+
*/
|
|
506
|
+
export const getByStripeCustomerId = query({
|
|
507
|
+
args: {
|
|
508
|
+
stripeCustomerId: v.string(),
|
|
509
|
+
},
|
|
510
|
+
handler: async (ctx, args) => {
|
|
511
|
+
return await ctx.db
|
|
512
|
+
.query("workspaces")
|
|
513
|
+
.withIndex("by_stripeCustomerId", (q) =>
|
|
514
|
+
q.eq("stripeCustomerId", args.stripeCustomerId)
|
|
515
|
+
)
|
|
516
|
+
.unique();
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Get workspace by ID
|
|
522
|
+
*/
|
|
523
|
+
export const getWorkspace = query({
|
|
524
|
+
args: {
|
|
525
|
+
id: v.id("workspaces"),
|
|
526
|
+
},
|
|
527
|
+
handler: async (ctx, args) => {
|
|
528
|
+
return await ctx.db.get(args.id);
|
|
304
529
|
},
|
|
305
530
|
});
|
|
306
531
|
|
|
307
532
|
// ============================================
|
|
308
|
-
//
|
|
533
|
+
// STRIPE USAGE REPORTING (Internal Actions)
|
|
309
534
|
// ============================================
|
|
310
535
|
|
|
311
|
-
|
|
312
|
-
|
|
536
|
+
/**
|
|
537
|
+
* Get all workspaces with active Stripe subscriptions (internal)
|
|
538
|
+
*/
|
|
539
|
+
export const getActiveSubscriptions = internalQuery({
|
|
540
|
+
args: {},
|
|
541
|
+
handler: async (ctx) => {
|
|
542
|
+
// Get all workspaces with a subscription ID
|
|
543
|
+
const workspaces = await ctx.db
|
|
544
|
+
.query("workspaces")
|
|
545
|
+
.filter((q) => q.neq(q.field("stripeSubscriptionId"), undefined))
|
|
546
|
+
.collect();
|
|
547
|
+
|
|
548
|
+
// Filter to only those with billing plan that requires reporting
|
|
549
|
+
return workspaces.filter(
|
|
550
|
+
(w) => w.billingPlan === "usage_based" && w.stripeSubscriptionId
|
|
551
|
+
);
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Get unreported usage records for a specific workspace (internal)
|
|
557
|
+
*/
|
|
558
|
+
export const getUnreportedUsageForWorkspace = internalQuery({
|
|
313
559
|
args: {
|
|
314
560
|
workspaceId: v.id("workspaces"),
|
|
315
561
|
},
|
|
316
562
|
handler: async (ctx, args) => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
563
|
+
return await ctx.db
|
|
564
|
+
.query("usageRecords")
|
|
565
|
+
.withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
|
|
566
|
+
.filter((q) => q.eq(q.field("reportedToStripe"), false))
|
|
567
|
+
.collect();
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Mark multiple usage records as reported (internal)
|
|
573
|
+
*/
|
|
574
|
+
export const markUsageRecordsReported = internalMutation({
|
|
575
|
+
args: {
|
|
576
|
+
usageRecordIds: v.array(v.id("usageRecords")),
|
|
577
|
+
stripeUsageRecordId: v.string(),
|
|
578
|
+
},
|
|
579
|
+
handler: async (ctx, args) => {
|
|
580
|
+
const now = Date.now();
|
|
581
|
+
for (const recordId of args.usageRecordIds) {
|
|
582
|
+
await ctx.db.patch(recordId, {
|
|
583
|
+
reportedToStripe: true,
|
|
584
|
+
stripeUsageRecordId: args.stripeUsageRecordId,
|
|
585
|
+
updatedAt: now,
|
|
586
|
+
});
|
|
320
587
|
}
|
|
588
|
+
},
|
|
589
|
+
});
|
|
321
590
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
591
|
+
/**
|
|
592
|
+
* Report usage to Stripe for a single workspace
|
|
593
|
+
* Internal action - called by the daily cron
|
|
594
|
+
*/
|
|
595
|
+
export const reportUsageToStripe = internalAction({
|
|
596
|
+
args: {
|
|
597
|
+
workspaceId: v.id("workspaces"),
|
|
598
|
+
stripeSubscriptionId: v.string(),
|
|
599
|
+
},
|
|
600
|
+
handler: async (ctx, args): Promise<{ success: boolean; callCount: number; error?: string }> => {
|
|
601
|
+
try {
|
|
602
|
+
// Get unreported usage records
|
|
603
|
+
const usageRecords = await ctx.runQuery(
|
|
604
|
+
internal.billing.getUnreportedUsageForWorkspace,
|
|
605
|
+
{ workspaceId: args.workspaceId }
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
if (usageRecords.length === 0) {
|
|
609
|
+
return { success: true, callCount: 0 };
|
|
610
|
+
}
|
|
325
611
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
612
|
+
// Sum up all unreported calls
|
|
613
|
+
const totalCalls = usageRecords.reduce(
|
|
614
|
+
(sum: number, r: { callCount: number }) => sum + r.callCount,
|
|
615
|
+
0
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
if (totalCalls === 0) {
|
|
619
|
+
// Mark as reported even if 0 calls (to prevent re-processing)
|
|
620
|
+
await ctx.runMutation(internal.billing.markUsageRecordsReported, {
|
|
621
|
+
usageRecordIds: usageRecords.map((r: { _id: Id<"usageRecords"> }) => r._id),
|
|
622
|
+
stripeUsageRecordId: "zero_usage",
|
|
623
|
+
});
|
|
624
|
+
return { success: true, callCount: 0 };
|
|
625
|
+
}
|
|
329
626
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
627
|
+
const stripe = getStripe();
|
|
628
|
+
|
|
629
|
+
// Get subscription to find the metered subscription item
|
|
630
|
+
const subscription = await stripe.subscriptions.retrieve(args.stripeSubscriptionId);
|
|
631
|
+
|
|
632
|
+
// Find the metered price item (usage_based price)
|
|
633
|
+
const meteredItem = subscription.items.data.find((item) => {
|
|
634
|
+
return item.price.recurring?.usage_type === "metered";
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
if (!meteredItem) {
|
|
638
|
+
console.error(
|
|
639
|
+
`No metered subscription item found for subscription ${args.stripeSubscriptionId}`
|
|
640
|
+
);
|
|
641
|
+
return {
|
|
642
|
+
success: false,
|
|
643
|
+
callCount: totalCalls,
|
|
644
|
+
error: "No metered subscription item found",
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Report usage to Stripe using usage records API
|
|
649
|
+
// Using raw fetch since SDK method varies by version
|
|
650
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
651
|
+
const usageRecordResponse = await fetch(
|
|
652
|
+
`https://api.stripe.com/v1/subscription_items/${meteredItem.id}/usage_records`,
|
|
653
|
+
{
|
|
654
|
+
method: "POST",
|
|
655
|
+
headers: {
|
|
656
|
+
"Authorization": `Bearer ${stripeKey}`,
|
|
657
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
658
|
+
},
|
|
659
|
+
body: new URLSearchParams({
|
|
660
|
+
quantity: String(totalCalls),
|
|
661
|
+
timestamp: String(Math.floor(Date.now() / 1000)),
|
|
662
|
+
action: "increment",
|
|
663
|
+
}),
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
if (!usageRecordResponse.ok) {
|
|
668
|
+
const errorData = await usageRecordResponse.text();
|
|
669
|
+
throw new Error(`Stripe API error: ${usageRecordResponse.status} - ${errorData}`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const usageRecord = await usageRecordResponse.json() as { id: string };
|
|
673
|
+
|
|
674
|
+
// Mark records as reported
|
|
675
|
+
await ctx.runMutation(internal.billing.markUsageRecordsReported, {
|
|
676
|
+
usageRecordIds: usageRecords.map((r: { _id: Id<"usageRecords"> }) => r._id),
|
|
677
|
+
stripeUsageRecordId: usageRecord.id,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
console.log(
|
|
681
|
+
`Reported ${totalCalls} calls for workspace ${args.workspaceId} to Stripe`
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
return { success: true, callCount: totalCalls };
|
|
685
|
+
} catch (error: any) {
|
|
686
|
+
console.error(
|
|
687
|
+
`Failed to report usage for workspace ${args.workspaceId}:`,
|
|
688
|
+
error
|
|
689
|
+
);
|
|
690
|
+
return {
|
|
691
|
+
success: false,
|
|
692
|
+
callCount: 0,
|
|
693
|
+
error: error.message || "Unknown error",
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// Type for workspace with active subscription
|
|
700
|
+
type ActiveWorkspace = {
|
|
701
|
+
_id: Id<"workspaces">;
|
|
702
|
+
email: string;
|
|
703
|
+
stripeSubscriptionId?: string;
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// Type for cron results
|
|
707
|
+
type CronResults = {
|
|
708
|
+
total: number;
|
|
709
|
+
success: number;
|
|
710
|
+
failed: number;
|
|
711
|
+
skipped: number;
|
|
712
|
+
totalCallsReported: number;
|
|
713
|
+
errors: string[];
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Daily cron job: Report all unreported usage to Stripe
|
|
718
|
+
* Runs at 00:05 UTC
|
|
719
|
+
*/
|
|
720
|
+
export const reportAllUsageToStripe = internalAction({
|
|
721
|
+
args: {},
|
|
722
|
+
handler: async (ctx): Promise<CronResults> => {
|
|
723
|
+
console.log("[Cron] Starting daily usage reporting to Stripe...");
|
|
724
|
+
|
|
725
|
+
// Get all workspaces with active subscriptions
|
|
726
|
+
const workspaces: ActiveWorkspace[] = await ctx.runQuery(
|
|
727
|
+
internal.billing.getActiveSubscriptions,
|
|
728
|
+
{}
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
console.log(`[Cron] Found ${workspaces.length} workspaces with active subscriptions`);
|
|
732
|
+
|
|
733
|
+
const results: CronResults = {
|
|
734
|
+
total: workspaces.length,
|
|
735
|
+
success: 0,
|
|
736
|
+
failed: 0,
|
|
737
|
+
skipped: 0,
|
|
738
|
+
totalCallsReported: 0,
|
|
739
|
+
errors: [],
|
|
339
740
|
};
|
|
741
|
+
|
|
742
|
+
// Process each workspace
|
|
743
|
+
for (const workspace of workspaces) {
|
|
744
|
+
if (!workspace.stripeSubscriptionId) {
|
|
745
|
+
results.skipped++;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
const result = await ctx.runAction(internal.billing.reportUsageToStripe, {
|
|
751
|
+
workspaceId: workspace._id,
|
|
752
|
+
stripeSubscriptionId: workspace.stripeSubscriptionId,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (result.success) {
|
|
756
|
+
results.success++;
|
|
757
|
+
results.totalCallsReported += result.callCount;
|
|
758
|
+
} else {
|
|
759
|
+
results.failed++;
|
|
760
|
+
results.errors.push(
|
|
761
|
+
`${workspace.email}: ${result.error || "Unknown error"}`
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
} catch (error: any) {
|
|
765
|
+
results.failed++;
|
|
766
|
+
results.errors.push(`${workspace.email}: ${error.message || "Unknown error"}`);
|
|
767
|
+
console.error(`[Cron] Error processing workspace ${workspace._id}:`, error);
|
|
768
|
+
// Continue with next workspace
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
console.log("[Cron] Daily usage reporting complete:", JSON.stringify(results));
|
|
773
|
+
|
|
774
|
+
return results;
|
|
340
775
|
},
|
|
341
776
|
});
|