Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ STRIPE_SECRET_KEY=sk_test_dummy_stripe_secret
STRIPE_WEBHOOK_SECRET_KEY=whsec_dummy_webhook_secret
STRIPE_USAGE_PRICE_ID=price_dummy_usage_id
STRIPE_TEAM_FEE_PRICE_ID=price_dummy_team_fee_id
STRIPE_SUBSCRIPTION_200_PRICE_ID=price_dummy_subscription_200_id

# External Services
LINKUP_API_KEY=dummy_linkup_key
Expand Down
9 changes: 9 additions & 0 deletions common/src/constants/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export enum AnalyticsEvent {
ADVISORY_LOCK_CONTENTION = 'backend.advisory_lock_contention',
TRANSACTION_RETRY_THRESHOLD_EXCEEDED = 'backend.transaction_retry_threshold_exceeded',

// Backend - Subscription
SUBSCRIPTION_CREATED = 'backend.subscription_created',
SUBSCRIPTION_CANCELED = 'backend.subscription_canceled',
SUBSCRIPTION_PAYMENT_FAILED = 'backend.subscription_payment_failed',
SUBSCRIPTION_BLOCK_CREATED = 'backend.subscription_block_created',
SUBSCRIPTION_BLOCK_LIMIT_HIT = 'backend.subscription_block_limit_hit',
SUBSCRIPTION_WEEKLY_LIMIT_HIT = 'backend.subscription_weekly_limit_hit',
SUBSCRIPTION_CREDITS_MIGRATED = 'backend.subscription_credits_migrated',

// Web
SIGNUP = 'web.signup',

Expand Down
1 change: 1 addition & 0 deletions common/src/constants/grant-priorities.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GrantType } from '@codebuff/common/types/grant'

export const GRANT_PRIORITIES: Record<GrantType, number> = {
subscription: 10,
free: 20,
referral: 30,
ad: 40,
Expand Down
19 changes: 19 additions & 0 deletions common/src/constants/subscription-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const SUBSCRIPTION_DISPLAY_NAME = 'Flex' as const

export interface TierConfig {
monthlyPrice: number
creditsPerBlock: number
blockDurationHours: number
weeklyCreditsLimit: number
}

export const SUBSCRIPTION_TIERS = {
200: {
monthlyPrice: 200,
creditsPerBlock: 1250,
blockDurationHours: 5,
weeklyCreditsLimit: 12500,
},
} as const satisfies Record<number, TierConfig>

export const DEFAULT_TIER = SUBSCRIPTION_TIERS[200]
2 changes: 2 additions & 0 deletions common/src/types/grant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type GrantType =
| 'free'
| 'referral'
| 'subscription'
| 'purchase'
| 'admin'
| 'organization'
Expand All @@ -9,6 +10,7 @@ export type GrantType =
export const GrantTypeValues = [
'free',
'referral',
'subscription',
'purchase',
'admin',
'organization',
Expand Down
4 changes: 2 additions & 2 deletions packages/billing/src/__tests__/usage-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const mockBalance = {
totalRemaining: 1000,
totalDebt: 0,
netBalance: 1000,
breakdown: { free: 500, paid: 500, referral: 0, purchase: 0, admin: 0, organization: 0, ad: 0 },
principals: { free: 500, paid: 500, referral: 0, purchase: 0, admin: 0, organization: 0, ad: 0 },
breakdown: { free: 500, referral: 0, subscription: 0, purchase: 500, admin: 0, organization: 0, ad: 0 },
principals: { free: 500, referral: 0, subscription: 0, purchase: 500, admin: 0, organization: 0, ad: 0 },
}

describe('usage-service', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/billing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,11 @@ export * from './usage-service'
// Credit delegation
export * from './credit-delegation'

// Subscription
export * from './subscription'

// Subscription webhooks
export * from './subscription-webhooks'

// Utilities
export * from './utils'
291 changes: 291 additions & 0 deletions packages/billing/src/subscription-webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { trackEvent } from '@codebuff/common/analytics'
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
import db from '@codebuff/internal/db'
import * as schema from '@codebuff/internal/db/schema'
import {
getUserByStripeCustomerId,
stripeServer,
} from '@codebuff/internal/util/stripe'
import { eq } from 'drizzle-orm'

import { handleSubscribe } from './subscription'

import type { Logger } from '@codebuff/common/types/contracts/logger'
import type Stripe from 'stripe'

type SubscriptionStatus = (typeof schema.subscriptionStatusEnum.enumValues)[number]

/**
* Maps a Stripe subscription status to our local enum.
*/
function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
const validStatuses: readonly string[] = schema.subscriptionStatusEnum.enumValues
if (validStatuses.includes(status)) return status as SubscriptionStatus
return 'incomplete'
}

// ---------------------------------------------------------------------------
// invoice.paid
// ---------------------------------------------------------------------------

/**
* Handles a paid invoice for a subscription.
*
* - On first payment (`subscription_create`): calls `handleSubscribe` to
* migrate the user's renewal date and unused credits (Option B).
* - On every payment: upserts the `subscription` row with fresh billing
* period dates from Stripe.
*/
export async function handleSubscriptionInvoicePaid(params: {
invoice: Stripe.Invoice
logger: Logger
}): Promise<void> {
const { invoice, logger } = params

if (!invoice.subscription) return
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription.id
const customerId =
typeof invoice.customer === 'string'
? invoice.customer
: invoice.customer?.id

if (!customerId) {
logger.warn(
{ invoiceId: invoice.id },
'Subscription invoice has no customer ID',
)
return
}

const stripeSub = await stripeServer.subscriptions.retrieve(subscriptionId)
const priceId = stripeSub.items.data[0]?.price.id
if (!priceId) {
logger.error(
{ subscriptionId },
'Stripe subscription has no price on first item',
)
return
}

// Look up the user for this customer
const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null

// On first invoice, migrate renewal date & credits (Option B)
if (invoice.billing_reason === 'subscription_create') {
if (userId) {
await handleSubscribe({
userId,
stripeSubscription: stripeSub,
logger,
})
} else {
logger.warn(
{ customerId, subscriptionId },
'No user found for customer — skipping handleSubscribe',
)
}
}

// Upsert subscription row
await db
.insert(schema.subscription)
.values({
stripe_subscription_id: subscriptionId,
stripe_customer_id: customerId,
user_id: userId,
stripe_price_id: priceId,
status: 'active',
billing_period_start: new Date(stripeSub.current_period_start * 1000),
billing_period_end: new Date(stripeSub.current_period_end * 1000),
cancel_at_period_end: stripeSub.cancel_at_period_end,
})
.onConflictDoUpdate({
target: schema.subscription.stripe_subscription_id,
set: {
status: 'active',
...(userId ? { user_id: userId } : {}),
stripe_price_id: priceId,
billing_period_start: new Date(
stripeSub.current_period_start * 1000,
),
billing_period_end: new Date(stripeSub.current_period_end * 1000),
cancel_at_period_end: stripeSub.cancel_at_period_end,
updated_at: new Date(),
},
})

logger.info(
{
subscriptionId,
customerId,
billingReason: invoice.billing_reason,
},
'Processed subscription invoice.paid',
)
}

// ---------------------------------------------------------------------------
// invoice.payment_failed
// ---------------------------------------------------------------------------

/**
* Immediately sets the subscription to `past_due` — no grace period.
* User reverts to free-tier behaviour until payment is fixed.
*/
export async function handleSubscriptionInvoicePaymentFailed(params: {
invoice: Stripe.Invoice
logger: Logger
}): Promise<void> {
const { invoice, logger } = params

if (!invoice.subscription) return
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription.id

const customerId =
typeof invoice.customer === 'string'
? invoice.customer
: invoice.customer?.id
const userId = customerId
? (await getUserByStripeCustomerId(customerId))?.id ?? null
: null

await db
.update(schema.subscription)
.set({
status: 'past_due',
updated_at: new Date(),
})
.where(eq(schema.subscription.stripe_subscription_id, subscriptionId))

trackEvent({
event: AnalyticsEvent.SUBSCRIPTION_PAYMENT_FAILED,
userId: userId ?? 'system',
properties: { subscriptionId, invoiceId: invoice.id },
logger,
})

logger.warn(
{ subscriptionId, invoiceId: invoice.id },
'Subscription payment failed — set to past_due',
)
}

// ---------------------------------------------------------------------------
// customer.subscription.updated
// ---------------------------------------------------------------------------

/**
* Syncs plan details and cancellation intent from Stripe.
*/
export async function handleSubscriptionUpdated(params: {
stripeSubscription: Stripe.Subscription
logger: Logger
}): Promise<void> {
const { stripeSubscription, logger } = params
const subscriptionId = stripeSubscription.id
const priceId = stripeSubscription.items.data[0]?.price.id

if (!priceId) {
logger.error(
{ subscriptionId },
'Subscription update has no price — skipping',
)
return
}

const customerId =
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id
const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null

const status = mapStripeStatus(stripeSubscription.status)

// Upsert — webhook ordering is not guaranteed by Stripe, so this event
// may arrive before invoice.paid creates the row.
await db
.insert(schema.subscription)
.values({
stripe_subscription_id: subscriptionId,
stripe_customer_id: customerId,
user_id: userId,
stripe_price_id: priceId,
status,
cancel_at_period_end: stripeSubscription.cancel_at_period_end,
billing_period_start: new Date(
stripeSubscription.current_period_start * 1000,
),
billing_period_end: new Date(
stripeSubscription.current_period_end * 1000,
),
})
.onConflictDoUpdate({
target: schema.subscription.stripe_subscription_id,
set: {
...(userId ? { user_id: userId } : {}),
stripe_price_id: priceId,
status,
cancel_at_period_end: stripeSubscription.cancel_at_period_end,
billing_period_start: new Date(
stripeSubscription.current_period_start * 1000,
),
billing_period_end: new Date(
stripeSubscription.current_period_end * 1000,
),
updated_at: new Date(),
},
})

logger.info(
{
subscriptionId,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
},
'Processed subscription update',
)
}

// ---------------------------------------------------------------------------
// customer.subscription.deleted
// ---------------------------------------------------------------------------

/**
* Marks the subscription as canceled in our database.
*/
export async function handleSubscriptionDeleted(params: {
stripeSubscription: Stripe.Subscription
logger: Logger
}): Promise<void> {
const { stripeSubscription, logger } = params
const subscriptionId = stripeSubscription.id

const customerId =
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id
const userId = (await getUserByStripeCustomerId(customerId))?.id ?? null

await db
.update(schema.subscription)
.set({
status: 'canceled',
canceled_at: new Date(),
updated_at: new Date(),
})
.where(eq(schema.subscription.stripe_subscription_id, subscriptionId))

trackEvent({
event: AnalyticsEvent.SUBSCRIPTION_CANCELED,
userId: userId ?? 'system',
properties: { subscriptionId },
logger,
})

logger.info({ subscriptionId }, 'Subscription canceled')
}
Loading