diff --git a/apps/server/src/routes/stripe/operations/webhook.ts b/apps/server/src/routes/stripe/operations/webhook.ts index 5e57b4537..8f593454b 100644 --- a/apps/server/src/routes/stripe/operations/webhook.ts +++ b/apps/server/src/routes/stripe/operations/webhook.ts @@ -3,7 +3,7 @@ import type Stripe from 'stripe' import type { RevenueMetrics } from '../../../otel' import type { BillingService } from '../../../services/domain/billing/billing-service' import type { FluxService } from '../../../services/domain/flux' -import type { ProductEventService } from '../../../services/domain/product-events' +import type { ProductAction, ProductEventService } from '../../../services/domain/product-events' import type { StripeService } from '../../../services/domain/stripe' import { useLogger } from '@guiiai/logg' @@ -13,6 +13,16 @@ import { errorMessageFromUnknown } from '../../../utils/error-message' const logger = useLogger('stripe') +interface StripeSubscriptionEventContext { + userId: string + stripeCustomerId: string + stripeSubscriptionId: string + stripePriceId?: string + subscriptionStatus?: string + amountPaid?: number + currency?: string +} + export interface WebhookOperationDeps { stripe: Stripe | null webhookSecret: string | undefined @@ -103,15 +113,29 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { case 'customer.subscription.created': case 'customer.subscription.updated': case 'customer.subscription.deleted': { - await handleSubscriptionEvent(event.data.object, deps.stripeService) + const result = await handleSubscriptionEvent(event.data.object, deps.stripeService) deps.metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') }) + const action = subscriptionActionForWebhookEvent(event.type) + if (result && action) { + void deps.productEventService?.track({ + userId: result.userId, + feature: 'billing', + action, + status: 'succeeded', + source: 'stripe.webhook', + metadata: { + stripe_price_id: result.stripePriceId ?? null, + stripe_subscription_status: result.subscriptionStatus ?? null, + }, + }) + } break } case 'invoice.created': case 'invoice.updated': case 'invoice.paid': case 'invoice.payment_failed': { - await handleInvoiceEvent(event.data.object, deps.stripeService) + const result = await handleInvoiceEvent(event.data.object, deps.stripeService) if (event.type === 'invoice.payment_failed') deps.metrics?.stripePaymentFailed.add(1) if (event.type === 'invoice.paid' && event.data.object.amount_paid && event.data.object.currency) { @@ -120,6 +144,20 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { source: 'invoice', }) } + if (event.type === 'invoice.paid' && event.data.object.billing_reason === 'subscription_cycle' && result?.stripeSubscriptionId) { + void deps.productEventService?.track({ + userId: result.userId, + feature: 'billing', + action: 'subscription_renewed', + status: 'succeeded', + source: 'stripe.webhook', + metadata: { + amount_paid: result.amountPaid ?? null, + currency: result.currency ?? null, + stripe_price_id: result.stripePriceId ?? null, + }, + }) + } break } } @@ -128,6 +166,14 @@ export function createWebhookOperation(deps: WebhookOperationDeps) { } } +function subscriptionActionForWebhookEvent(eventType: Stripe.Event.Type): ProductAction | null { + if (eventType === 'customer.subscription.created') + return 'subscription_started' + if (eventType === 'customer.subscription.deleted') + return 'subscription_cancelled' + return null +} + async function handleCheckoutSessionCompleted( stripeEventId: string, session: Stripe.Checkout.Session, @@ -239,11 +285,11 @@ async function handleCustomerEvent( async function handleSubscriptionEvent( subscription: Stripe.Subscription, stripeService: StripeService, -) { +): Promise { const stripeCustomerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id const customer = await stripeService.getCustomerByStripeId(stripeCustomerId) if (!customer) - return + return null // In newer Stripe API, period info is on subscription items. const firstItem = subscription.items.data[0] @@ -260,19 +306,27 @@ async function handleSubscriptionEvent( endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null, metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null, }) + + return { + userId: customer.userId, + stripeCustomerId, + stripeSubscriptionId: subscription.id, + stripePriceId: firstItem?.price?.id, + subscriptionStatus: subscription.status, + } } async function handleInvoiceEvent( invoice: Stripe.Invoice, stripeService: StripeService, -) { +): Promise { const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id if (!stripeCustomerId) - return + return null const customer = await stripeService.getCustomerByStripeId(stripeCustomerId) if (!customer) - return + return null // In newer Stripe API, subscription is under parent.subscription_details. const subDetails = invoice.parent?.subscription_details @@ -300,4 +354,13 @@ async function handleInvoiceEvent( // TODO: implement subscription-based flux crediting when subscriptions are enabled if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) logger.withFields({ userId: customer.userId, invoiceId: invoice.id, amountPaid: invoice.amount_paid }).warn('Subscription invoice paid but flux crediting for subscriptions is not yet implemented') + + return { + userId: customer.userId, + stripeCustomerId, + stripeSubscriptionId: subscriptionId ?? '', + subscriptionStatus: invoice.status ?? undefined, + amountPaid: invoice.amount_paid, + currency: invoice.currency, + } } diff --git a/apps/server/src/routes/stripe/route.test.ts b/apps/server/src/routes/stripe/route.test.ts index 42fa287ab..eaf566e0e 100644 --- a/apps/server/src/routes/stripe/route.test.ts +++ b/apps/server/src/routes/stripe/route.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { createStripeRoutes, formatPrice } from '.' import { ApiError } from '../../utils/error' +import { createWebhookOperation } from './operations/webhook' // --- Mock helpers --- @@ -452,5 +453,122 @@ describe('stripeRoutes', () => { }) expect(res.status).toBe(503) }) + + it('records subscription lifecycle product events from Stripe webhooks', async () => { + const subscriptionEvent = { + id: 'evt_sub_created', + type: 'customer.subscription.created', + data: { + object: { + id: 'sub_1', + customer: 'cus_1', + status: 'active', + items: { + data: [{ + price: { id: 'price_1' }, + current_period_start: 1_000, + current_period_end: 2_000, + }], + }, + cancel_at_period_end: false, + canceled_at: null, + ended_at: null, + metadata: {}, + }, + }, + } + const productEventService = { track: vi.fn() } + const stripeService = createMockStripeService({ + getCustomerByStripeId: vi.fn(async () => ({ userId: 'user-1', stripeCustomerId: 'cus_1' })), + }) + const webhook = createWebhookOperation({ + stripe: { + webhooks: { + constructEvent: vi.fn(() => subscriptionEvent), + }, + } as any, + webhookSecret: 'whsec_test', + fluxService: createMockFluxService(), + stripeService, + billingService: createMockBillingService(), + productEventService: productEventService as any, + }) + + await webhook({ signature: 'test_sig', body: '{}' }) + + expect(productEventService.track).toHaveBeenCalledWith({ + userId: 'user-1', + feature: 'billing', + action: 'subscription_started', + status: 'succeeded', + source: 'stripe.webhook', + metadata: { + stripe_price_id: 'price_1', + stripe_subscription_status: 'active', + }, + }) + }) + + it('records subscription renewals only for subscription-cycle paid invoices', async () => { + const invoiceEvent = { + id: 'evt_invoice_paid', + type: 'invoice.paid', + data: { + object: { + id: 'inv_1', + customer: 'cus_1', + parent: { + subscription_details: { + subscription: 'sub_1', + }, + }, + billing_reason: 'subscription_cycle', + status: 'paid', + amount_due: 1_200, + amount_paid: 1_200, + currency: 'usd', + hosted_invoice_url: null, + invoice_pdf: null, + period_start: 1_000, + period_end: 2_000, + status_transitions: { + paid_at: 1_500, + }, + metadata: {}, + }, + }, + } + const productEventService = { track: vi.fn() } + const stripeService = createMockStripeService({ + getCustomerByStripeId: vi.fn(async () => ({ userId: 'user-1', stripeCustomerId: 'cus_1' })), + }) + const webhook = createWebhookOperation({ + stripe: { + webhooks: { + constructEvent: vi.fn(() => invoiceEvent), + }, + } as any, + webhookSecret: 'whsec_test', + fluxService: createMockFluxService(), + stripeService, + billingService: createMockBillingService(), + productEventService: productEventService as any, + }) + + await webhook({ signature: 'test_sig', body: '{}' }) + + expect(productEventService.track).toHaveBeenCalledWith({ + userId: 'user-1', + feature: 'billing', + action: 'subscription_renewed', + status: 'succeeded', + source: 'stripe.webhook', + metadata: { + amount_paid: 1200, + currency: 'usd', + stripe_price_id: null, + }, + }) + }) }) }) diff --git a/apps/server/src/services/domain/product-events.ts b/apps/server/src/services/domain/product-events.ts index e2f76a514..7790af3fd 100644 --- a/apps/server/src/services/domain/product-events.ts +++ b/apps/server/src/services/domain/product-events.ts @@ -29,6 +29,10 @@ export type ProductAction | 'voice_pack_disabled' | 'checkout_started' | 'payment_completed' + | 'subscription_started' + | 'subscription_renewed' + | 'subscription_cancelled' + | 'topic_classified' /** * Product event fact written to AIRI's own Postgres analytics table. diff --git a/packages/stage-pages/src/pages/settings/flux.vue b/packages/stage-pages/src/pages/settings/flux.vue index d8d8676e3..74aea2dc0 100644 --- a/packages/stage-pages/src/pages/settings/flux.vue +++ b/packages/stage-pages/src/pages/settings/flux.vue @@ -15,7 +15,13 @@ const route = useRoute() const router = useRouter() const authStore = useAuthStore() const { credits } = storeToRefs(authStore) -const { trackPricingViewed, trackPlanSelected, trackCheckoutStarted } = useAnalytics() +const { + trackCheckoutStarted, + trackPlanSelected, + trackPricingViewed, + trackQuotaLimitReached, + trackUpgradeClicked, +} = useAnalytics() const fluxPurchaseDisabled = isFluxPurchaseDisabled() @@ -232,7 +238,7 @@ async function fetchPackages() { } onMounted(async () => { - Promise.allSettled([authStore.updateCredits(), fetchStats(), fetchAuditHistory(), ...(fluxPurchaseDisabled ? [] : [fetchPackages()])]) + await Promise.allSettled([authStore.updateCredits(), fetchStats(), fetchAuditHistory(), ...(fluxPurchaseDisabled ? [] : [fetchPackages()])]) // PostHog funnel step 1: pricing surface view. Today this is an in-app // settings page (already-authenticated users); when we add a public @@ -240,6 +246,14 @@ onMounted(async () => { // same, so the funnel definition in PostHog doesn't need re-wiring. if (!fluxPurchaseDisabled) { trackPricingViewed('settings_flux', 'one_time') + if (credits.value <= 0) { + trackQuotaLimitReached({ + limit_type: 'flux', + current_usage: credits.value, + limit_value: capacity.value > 0 ? capacity.value : undefined, + entry: 'pricing', + }) + } } if (route.query.success === 'true') { @@ -259,6 +273,11 @@ async function handleBuy(stripePriceId: string) { // the Stripe webhook (server-side `payment_completed`); we deliberately // don't send a formatted-string price from the SPA so funnels don't get // poisoned by currency-formatting drift. + trackUpgradeClicked({ + source_page: 'settings_flux', + current_plan: 'flux', + trigger: 'manual_topup', + }) trackPlanSelected(stripePriceId, { currency: selectedCurrency.value }) try { const res = await client.api.v1.stripe.checkout.$post({ json: { stripePriceId, currency: selectedCurrency.value } }) diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue index e5bf6d5a5..7d189bbfd 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue @@ -1,4 +1,5 @@