|
1 | 1 | import { Autumn } from 'autumn-js'; |
2 | 2 | import { v } from 'convex/values'; |
3 | 3 |
|
| 4 | +import type { Doc } from './_generated/dataModel.js'; |
4 | 5 | import { internal } from './_generated/api.js'; |
5 | | -import { action } from './_generated/server.js'; |
| 6 | +import { action, type ActionCtx } from './_generated/server.js'; |
6 | 7 | import { AnalyticsEvents } from './analyticsEvents.js'; |
| 8 | +import { instances } from './apiHelpers.js'; |
7 | 9 | import { requireInstanceOwnershipAction } from './authHelpers.js'; |
8 | 10 |
|
9 | 11 | type FeatureMetrics = { |
@@ -60,6 +62,16 @@ type BillingSummaryResult = { |
60 | 62 |
|
61 | 63 | type SessionResult = { url: string }; |
62 | 64 |
|
| 65 | +type SubscriptionPlan = 'pro' | 'free' | 'none'; |
| 66 | +type SubscriptionStatus = 'active' | 'trialing' | 'canceled' | 'none'; |
| 67 | +type SubscriptionSnapshot = { |
| 68 | + plan: SubscriptionPlan; |
| 69 | + status: SubscriptionStatus; |
| 70 | + productId?: string; |
| 71 | + currentPeriodEnd?: number | null; |
| 72 | + canceledAt?: number | null; |
| 73 | +}; |
| 74 | + |
63 | 75 | const SANDBOX_IDLE_MINUTES = 2; |
64 | 76 | const CHARS_PER_TOKEN = 4; |
65 | 77 | const FEATURE_IDS = { |
@@ -209,6 +221,83 @@ function getActiveProduct( |
209 | 221 | return null; |
210 | 222 | } |
211 | 223 |
|
| 224 | +function getSubscriptionSnapshot( |
| 225 | + activeProduct: |
| 226 | + | { |
| 227 | + id: string; |
| 228 | + status?: string; |
| 229 | + current_period_end?: number | null; |
| 230 | + canceled_at?: number | null; |
| 231 | + } |
| 232 | + | null |
| 233 | +): SubscriptionSnapshot { |
| 234 | + if (!activeProduct) { |
| 235 | + return { plan: 'none', status: 'none' }; |
| 236 | + } |
| 237 | + |
| 238 | + const plan: SubscriptionPlan = |
| 239 | + activeProduct.id === 'btca_pro' ? 'pro' : activeProduct.id === 'free_plan' ? 'free' : 'none'; |
| 240 | + const status: SubscriptionStatus = activeProduct.status |
| 241 | + ? (activeProduct.status as SubscriptionStatus) |
| 242 | + : 'none'; |
| 243 | + |
| 244 | + return { |
| 245 | + plan, |
| 246 | + status, |
| 247 | + productId: activeProduct.id, |
| 248 | + currentPeriodEnd: activeProduct.current_period_end ?? undefined, |
| 249 | + canceledAt: activeProduct.canceled_at ?? undefined |
| 250 | + }; |
| 251 | +} |
| 252 | + |
| 253 | +async function syncSubscriptionState( |
| 254 | + ctx: ActionCtx, |
| 255 | + instance: Doc<'instances'>, |
| 256 | + snapshot: SubscriptionSnapshot |
| 257 | +): Promise<void> { |
| 258 | + const previousPlan: SubscriptionPlan = instance.subscriptionPlan ?? 'none'; |
| 259 | + const previousStatus: SubscriptionStatus = instance.subscriptionStatus ?? 'none'; |
| 260 | + |
| 261 | + if (previousPlan === snapshot.plan && previousStatus === snapshot.status) { |
| 262 | + return; |
| 263 | + } |
| 264 | + |
| 265 | + await ctx.runMutation(instances.mutations.setSubscriptionState, { |
| 266 | + instanceId: instance._id, |
| 267 | + plan: snapshot.plan, |
| 268 | + status: snapshot.status, |
| 269 | + productId: snapshot.productId, |
| 270 | + currentPeriodEnd: snapshot.currentPeriodEnd ?? undefined, |
| 271 | + canceledAt: snapshot.canceledAt ?? undefined |
| 272 | + }); |
| 273 | + |
| 274 | + const properties = { |
| 275 | + instanceId: instance._id, |
| 276 | + plan: snapshot.plan, |
| 277 | + status: snapshot.status, |
| 278 | + previousPlan, |
| 279 | + previousStatus, |
| 280 | + productId: snapshot.productId ?? null, |
| 281 | + currentPeriodEnd: snapshot.currentPeriodEnd ?? null, |
| 282 | + canceledAt: snapshot.canceledAt ?? null |
| 283 | + }; |
| 284 | + |
| 285 | + const event = |
| 286 | + previousPlan !== 'pro' && |
| 287 | + snapshot.plan === 'pro' && |
| 288 | + (snapshot.status === 'active' || snapshot.status === 'trialing') |
| 289 | + ? AnalyticsEvents.SUBSCRIPTION_CREATED |
| 290 | + : previousPlan === 'pro' && snapshot.plan !== 'pro' |
| 291 | + ? AnalyticsEvents.SUBSCRIPTION_CANCELED |
| 292 | + : AnalyticsEvents.SUBSCRIPTION_UPDATED; |
| 293 | + |
| 294 | + await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { |
| 295 | + distinctId: instance.clerkId, |
| 296 | + event, |
| 297 | + properties |
| 298 | + }); |
| 299 | +} |
| 300 | + |
212 | 301 | async function checkFeature(args: { |
213 | 302 | customerId: string; |
214 | 303 | featureId: string; |
@@ -275,6 +364,7 @@ export const ensureUsageAvailable = action({ |
275 | 364 | : undefined) |
276 | 365 | }); |
277 | 366 | const activeProduct = getActiveProduct(autumnCustomer.products); |
| 367 | + await syncSubscriptionState(ctx, instance, getSubscriptionSnapshot(activeProduct)); |
278 | 368 | if (!activeProduct) { |
279 | 369 | return { |
280 | 370 | ok: false, |
@@ -508,6 +598,14 @@ export const getBillingSummary = action({ |
508 | 598 | ? (activeProduct.status as 'active' | 'trialing' | 'canceled') |
509 | 599 | : 'none'; |
510 | 600 |
|
| 601 | + await syncSubscriptionState(ctx, instance, { |
| 602 | + plan, |
| 603 | + status, |
| 604 | + productId: activeProduct?.id ?? undefined, |
| 605 | + currentPeriodEnd: activeProduct?.current_period_end ?? undefined, |
| 606 | + canceledAt: activeProduct?.canceled_at ?? undefined |
| 607 | + }); |
| 608 | + |
511 | 609 | const [tokensIn, tokensOut, sandboxHours, chatMessages] = await Promise.all([ |
512 | 610 | checkFeature({ |
513 | 611 | customerId: autumnCustomer.id ?? instance.clerkId, |
@@ -607,15 +705,6 @@ export const createCheckoutSession = action({ |
607 | 705 | throw new Error(payload.error.message ?? 'Failed to create checkout session'); |
608 | 706 | } |
609 | 707 | if (payload.data?.url) { |
610 | | - await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { |
611 | | - distinctId: instance.clerkId, |
612 | | - event: AnalyticsEvents.SUBSCRIPTION_CREATED, |
613 | | - properties: { |
614 | | - instanceId: args.instanceId, |
615 | | - plan: 'btca_pro', |
616 | | - wasExistingCustomer: true |
617 | | - } |
618 | | - }); |
619 | 708 | return { url: payload.data.url }; |
620 | 709 | } |
621 | 710 |
|
|
0 commit comments