@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.
Files changed (46) hide show
  1. package/README.md +33 -0
  2. package/convex/_generated/api.d.ts +12 -0
  3. package/convex/billing.ts +651 -216
  4. package/convex/crons.ts +17 -0
  5. package/convex/email.ts +135 -82
  6. package/convex/feedback.ts +265 -0
  7. package/convex/http.ts +80 -4
  8. package/convex/logs.ts +287 -0
  9. package/convex/providerKeys.ts +209 -0
  10. package/convex/providers.ts +18 -0
  11. package/convex/schema.ts +115 -0
  12. package/convex/stripeActions.ts +512 -0
  13. package/convex/webhooks.ts +494 -0
  14. package/convex/workspaces.ts +74 -1
  15. package/dist/index.js +178 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/metered.d.ts +62 -0
  18. package/dist/metered.d.ts.map +1 -0
  19. package/dist/metered.js +81 -0
  20. package/dist/metered.js.map +1 -0
  21. package/dist/stripe.d.ts +62 -0
  22. package/dist/stripe.d.ts.map +1 -1
  23. package/dist/stripe.js +212 -0
  24. package/dist/stripe.js.map +1 -1
  25. package/docs/PRD-final-polish.md +117 -0
  26. package/docs/PRD-mobile-responsive.md +56 -0
  27. package/docs/PRD-navigation-expansion.md +295 -0
  28. package/docs/PRD-stripe-billing.md +312 -0
  29. package/docs/PRD-workspace-cleanup.md +200 -0
  30. package/landing/src/app/api/billing/checkout/route.ts +109 -0
  31. package/landing/src/app/api/billing/payment-method/route.ts +118 -0
  32. package/landing/src/app/api/billing/portal/route.ts +64 -0
  33. package/landing/src/app/auth/verify/page.tsx +20 -5
  34. package/landing/src/app/earn/page.tsx +6 -6
  35. package/landing/src/app/login/page.tsx +1 -1
  36. package/landing/src/app/page.tsx +70 -70
  37. package/landing/src/app/providers/dashboard/page.tsx +1 -1
  38. package/landing/src/app/workspace/page.tsx +3497 -535
  39. package/landing/src/components/CheckoutButton.tsx +188 -0
  40. package/landing/src/components/Toast.tsx +84 -0
  41. package/landing/src/lib/stats.json +1 -1
  42. package/landing/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/src/index.ts +205 -0
  45. package/src/metered.ts +149 -0
  46. 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, action } from "./_generated/server";
3
- import { api } from "./_generated/api";
4
-
5
- // Tier limits
6
- const TIER_LIMITS: Record<string, number> = {
7
- free: 100, // 100 API calls/month
8
- pro: 10000, // 10k API calls/month
9
- enterprise: -1, // unlimited
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
- // STRIPE CUSTOMER MANAGEMENT
17
+ // MUTATIONS
14
18
  // ============================================
15
19
 
16
- // Create Stripe customer for workspace
17
- export const createStripeCustomer = action({
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): Promise<{ success: boolean; customerId?: string; error?: string }> => {
22
- const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
23
- if (!stripeSecretKey) {
24
- return { success: false, error: "Stripe not configured" };
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
- // Get workspace
28
- const workspace = await ctx.runQuery(api.billing.getWorkspace, {
29
- workspaceId: args.workspaceId
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
- return { success: false, error: "Workspace not found" };
55
+ throw new Error("Workspace not found");
34
56
  }
35
57
 
36
- // Check if already has customer
37
- if (workspace.stripeCustomerId) {
38
- return { success: true, customerId: workspace.stripeCustomerId };
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
- try {
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
- const customer = await response.json() as { id: string; error?: { message: string } };
57
-
58
- if (!response.ok || customer.error) {
59
- return { success: false, error: customer.error?.message || "Failed to create customer" };
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
- // Save customer ID to workspace
63
- await ctx.runMutation(api.billing.saveStripeCustomerId, {
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
- stripeCustomerId: customer.id,
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
- // Internal: Save Stripe customer ID to workspace
76
- export const saveStripeCustomerId = mutation({
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
- stripeCustomerId: v.string(),
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
- stripeCustomerId: args.stripeCustomerId,
163
+ lastBillingDate: Date.now(),
84
164
  updatedAt: Date.now(),
85
165
  });
166
+
167
+ return { id, alreadyProcessed: false };
86
168
  },
87
169
  });
88
170
 
89
- // Query workspace (for actions)
90
- export const getWorkspace = query({
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
- return await ctx.db.get(args.workspaceId);
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
- // CHECKOUT SESSION (Add Payment Method)
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
- successUrl: v.string(),
108
- cancelUrl: v.string(),
203
+ amount: v.number(), // in cents
109
204
  },
110
- handler: async (ctx, args): Promise<{ success: boolean; sessionId?: string; url?: string; error?: string }> => {
111
- const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
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
- return { success: false, error: "Workspace not found" };
208
+ throw new Error("Workspace not found");
123
209
  }
124
210
 
125
- // Create customer if needed
126
- let customerId = workspace.stripeCustomerId;
127
- if (!customerId) {
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
- try {
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
- const session = await response.json() as {
159
- id: string;
160
- url: string;
161
- error?: { message: string }
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
- return {
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
- // WORKSPACE UPGRADE
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
- // Upgrade workspace to paid tier
184
- export const upgradeWorkspace = mutation({
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
- return { success: false, error: "workspace_not_found" };
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
- tier: newTier,
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
- // Suspend workspace (e.g., payment failed)
215
- export const suspendWorkspace = mutation({
295
+ /**
296
+ * Update payment method info (from webhook)
297
+ */
298
+ export const updatePaymentMethodInfo = mutation({
216
299
  args: {
217
300
  workspaceId: v.id("workspaces"),
218
- reason: v.optional(v.string()),
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
- return { success: false, error: "workspace_not_found" };
309
+ throw new Error("Workspace not found");
224
310
  }
225
311
 
226
312
  await ctx.db.patch(args.workspaceId, {
227
- status: "suspended",
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
- // Get workspace by Stripe customer ID (for webhooks)
236
- export const getWorkspaceByStripeCustomer = query({
324
+ // ============================================
325
+ // QUERIES
326
+ // ============================================
327
+
328
+ /**
329
+ * Get billing info for a workspace
330
+ */
331
+ export const getInfo = query({
237
332
  args: {
238
- stripeCustomerId: v.string(),
333
+ workspaceId: v.id("workspaces"),
239
334
  },
240
335
  handler: async (ctx, args) => {
241
- return await ctx.db
242
- .query("workspaces")
243
- .withIndex("by_stripeCustomerId", (q) => q.eq("stripeCustomerId", args.stripeCustomerId))
244
- .first();
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
- // USAGE REPORTING
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): Promise<{ success: boolean; error?: string }> => {
261
- // Get workspace
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
- return { success: false, error: "Workspace not found" };
412
+ throw new Error("Workspace not found");
268
413
  }
269
414
 
270
- // Only track usage for paid tiers
271
- if (workspace.tier === "free") {
272
- // Free tier just uses incrementUsage in workspaces.ts
273
- return { success: true };
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
- // For paid tiers, increment the usage counter
277
- // Future: Report to Stripe Billing Meter when metered pricing is set up
278
- await ctx.runMutation(api.billing.incrementWorkspaceUsage, {
279
- workspaceId: args.workspaceId,
280
- amount: args.amount,
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
- // TODO: When metered pricing is configured, report to Stripe:
284
- // await reportToStripeMeter(workspace.stripeCustomerId, args.amount);
427
+ const periodRecords = usageRecords.filter((r) => r.date >= periodStartStr);
428
+ const callCount = periodRecords.reduce((sum, r) => sum + r.callCount, 0);
285
429
 
286
- return { success: true };
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
- // Internal: Increment workspace usage
291
- export const incrementWorkspaceUsage = mutation({
454
+ /**
455
+ * Get invoices for a workspace
456
+ */
457
+ export const getInvoices = query({
292
458
  args: {
293
459
  workspaceId: v.id("workspaces"),
294
- amount: v.number(),
460
+ limit: v.optional(v.number()),
295
461
  },
296
462
  handler: async (ctx, args) => {
297
- const workspace = await ctx.db.get(args.workspaceId);
298
- if (!workspace) return;
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
- await ctx.db.patch(args.workspaceId, {
301
- usageCount: workspace.usageCount + args.amount,
302
- updatedAt: Date.now(),
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
- // QUERIES
533
+ // STRIPE USAGE REPORTING (Internal Actions)
309
534
  // ============================================
310
535
 
311
- // Get billing status for workspace
312
- export const getBillingStatus = query({
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
- const workspace = await ctx.db.get(args.workspaceId);
318
- if (!workspace) {
319
- return null;
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
- const usageRemaining = workspace.usageLimit > 0
323
- ? workspace.usageLimit - workspace.usageCount
324
- : -1;
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
- const usagePercent = workspace.usageLimit > 0
327
- ? Math.round((workspace.usageCount / workspace.usageLimit) * 100)
328
- : 0;
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
- return {
331
- tier: workspace.tier,
332
- status: workspace.status,
333
- usageCount: workspace.usageCount,
334
- usageLimit: workspace.usageLimit,
335
- usageRemaining,
336
- usagePercent,
337
- hasStripe: !!workspace.stripeCustomerId,
338
- email: workspace.email,
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
  });