Skip to content
Merged
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
45 changes: 44 additions & 1 deletion apps/tradinggoose/lib/billing/core/subscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,53 @@ describe('subscription billing helpers', () => {
expect(snapshot.currentPeriodCost).toBe(12.5)
})

it('throws when billing is enabled but no active subscription exists', async () => {
it('restores the default subscription when a billed user has no entitled row', async () => {
// The reported failure: a missed Stripe cancellation leaves the user's only personal row
// non-entitled, so every billing read used to throw.
const restoredSubscription = {
id: 'sub_default_user_123',
referenceType: 'user',
referenceId: 'user_123',
status: 'active',
tier: { id: 'tier_default', isDefault: true },
}
mockGetResolvedBillingSettings.mockResolvedValue({ billingEnabled: true })
const selectResults: unknown[] = [[], [], [restoredSubscription]]
mockDb.select.mockImplementation(() =>
createSelectQueryMock(selectResults.shift() ?? [], 'where')
)
mockDb.insert.mockImplementation(() => ({
values: vi.fn(() => ({ onConflictDoUpdate: vi.fn(() => Promise.resolve()) })),
}))

const { getEffectiveSubscription } = await import('./subscription')

await expect(getEffectiveSubscription('user_123')).resolves.toBe(restoredSubscription)
expect(mockDb.insert).toHaveBeenCalled()
})

it('does not restore a subscription when billing is disabled', async () => {
mockGetResolvedBillingSettings.mockResolvedValue({ billingEnabled: false })
mockDb.select.mockImplementation(() => createSelectQueryMock([], 'where'))
mockDb.insert.mockImplementation(() => ({
values: vi.fn(() => ({ onConflictDoUpdate: vi.fn(() => Promise.resolve()) })),
}))

const { getEffectiveSubscription } = await import('./subscription')

await expect(getEffectiveSubscription('user_123')).resolves.toBeNull()
expect(mockDb.insert).not.toHaveBeenCalled()
})

it('throws when billing is enabled and entitlement cannot be restored', async () => {
mockGetResolvedBillingSettings.mockResolvedValue({
billingEnabled: true,
})
// Recovery runs, fails to provision a default subscription, and the original billing
// error surfaces rather than a new one.
mockDb.insert.mockImplementation(() => ({
values: vi.fn(() => ({ onConflictDoUpdate: vi.fn(() => Promise.resolve()) })),
}))
mockDb.select
.mockImplementationOnce(() => createSelectQueryMock([], 'where'))
.mockImplementationOnce(() =>
Expand Down
49 changes: 46 additions & 3 deletions apps/tradinggoose/lib/billing/core/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ export function getConfiguredPersonalUsageLimit(
* Consolidates logic from both lib/subscription.ts and lib/subscription/subscription.ts
*/

/**
* Give a billed user back the default tier when they hold no entitled subscription.
*
* Personal Stripe subscriptions reuse the user's default subscription row, so a cancellation
* that Stripe reported but we failed to finish (a dropped `customer.subscription.deleted`,
* or a settlement error part-way through one) leaves that single row non-entitled and every
* billing read throwing. The local status is enough to act on: the row is not entitling
* anyone, and the default tier is the floor rather than a revocation.
*
* Never throws - callers use it behind a normal read, so a repair failure must surface as
* the original billing error rather than a new one.
*/
async function restorePersonalEntitlement(userId: string): Promise<SubscriptionWithTier | null> {
try {
// With billing disabled there is no default tier to grant, and callers already treat a
// missing subscription as unlimited.
const { billingEnabled } = await getResolvedBillingSettings()
if (!billingEnabled) {
return null
}

const restoredSubscription = await ensureDefaultUserSubscription(userId)

logger.warn('Restored default personal subscription for a user left without one', {
userId,
subscriptionId: restoredSubscription.id,
})

return restoredSubscription
} catch (error) {
logger.error('Failed to restore default personal subscription', { userId, error })
return null
}
}

/**
* Get the active subscription that currently governs a billing reference.
*/
Expand All @@ -102,7 +137,13 @@ export async function getActiveSubscriptionForReference(
)

const hydratedSubscriptions = await hydrateSubscriptionsWithTiers(rows)
return selectEffectiveSubscription(hydratedSubscriptions)
const effectiveSubscription = selectEffectiveSubscription(hydratedSubscriptions)

if (effectiveSubscription || reference.referenceType !== 'user') {
return effectiveSubscription
}

return restorePersonalEntitlement(reference.referenceId)
}

export async function requireActiveSubscriptionForReference(
Expand Down Expand Up @@ -135,7 +176,8 @@ export async function getSubscriptionByStripeSubscriptionId(
export async function getEffectiveSubscription(
userId: string
): Promise<SubscriptionWithTier | null> {
return getPersonalEffectiveSubscription(userId)
const personalSubscription = await getPersonalEffectiveSubscription(userId)
return personalSubscription ?? restorePersonalEntitlement(userId)
}

async function getActivePersonalSubscriptions(
Expand Down Expand Up @@ -287,7 +329,8 @@ export async function getPersonalBillingSnapshot(userId: string): Promise<Person
try {
const [{ billingEnabled }, subscription, statsRecords] = await Promise.all([
getResolvedBillingSettings(),
getPersonalEffectiveSubscription(userId),
// Not the raw personal read: this one repairs a user left without an entitled row.
getEffectiveSubscription(userId),
db
.select({
currentPeriodCost: userStats.currentPeriodCost,
Expand Down
85 changes: 77 additions & 8 deletions apps/tradinggoose/lib/billing/webhooks/subscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ const {
mockEnsureDefaultUserSubscription,
mockEq,
mockGetBilledOverageForSubscription,
mockGetResolvedBillingSettings,
mockGetSubscriptionByStripeSubscriptionId,
mockIsPaidBillingTier,
mockNe,
Expand All @@ -33,7 +32,6 @@ const {
mockEnsureDefaultUserSubscription: vi.fn(),
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
mockGetBilledOverageForSubscription: vi.fn(),
mockGetResolvedBillingSettings: vi.fn(),
mockGetSubscriptionByStripeSubscriptionId: vi.fn(),
mockIsPaidBillingTier: vi.fn(),
mockNe: vi.fn((field: unknown, value: unknown) => ({ field, value })),
Expand Down Expand Up @@ -79,10 +77,6 @@ vi.mock('@/lib/billing/core/subscription', () => ({
getSubscriptionByStripeSubscriptionId: mockGetSubscriptionByStripeSubscriptionId,
}))

vi.mock('@/lib/billing/settings', () => ({
getResolvedBillingSettings: mockGetResolvedBillingSettings,
}))

vi.mock('@/lib/billing/tiers', () => ({
isPaidBillingTier: mockIsPaidBillingTier,
}))
Expand Down Expand Up @@ -211,7 +205,6 @@ describe('handleSubscriptionCreated', () => {
mockDb.update.mockImplementation(() => createUpdateQueryMock())
mockCalculateSubscriptionOverage.mockResolvedValue(0)
mockGetBilledOverageForSubscription.mockResolvedValue(0)
mockGetResolvedBillingSettings.mockResolvedValue({ billingEnabled: true })
mockRequireStripeClient.mockReturnValue({})
mockIsPaidBillingTier.mockReturnValue(false)
})
Expand Down Expand Up @@ -289,7 +282,6 @@ describe('handleStripeSubscriptionDeleted', () => {
mockDb.update.mockImplementation(() => createUpdateQueryMock())
mockCalculateSubscriptionOverage.mockResolvedValue(0)
mockGetBilledOverageForSubscription.mockResolvedValue(0)
mockGetResolvedBillingSettings.mockResolvedValue({ billingEnabled: true })
mockGetSubscriptionByStripeSubscriptionId.mockResolvedValue(null)
mockRequireStripeClient.mockReturnValue({})
mockSyncSubscriptionBillingTierFromStripeSubscription.mockResolvedValue(undefined)
Expand Down Expand Up @@ -390,6 +382,57 @@ describe('handleStripeSubscriptionDeleted', () => {
expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled()
})

it('restores default entitlement even when settlement fails, then rethrows', async () => {
const stripeBackedSubscription = createDefaultSubscription({
status: 'canceled',
stripeSubscriptionId: 'sub_stripe_123',
})
const defaultSubscription = createDefaultSubscription()
mockGetSubscriptionByStripeSubscriptionId
.mockResolvedValueOnce(stripeBackedSubscription)
.mockResolvedValueOnce(stripeBackedSubscription)
mockEnsureDefaultUserSubscription.mockResolvedValue(defaultSubscription)
mockCalculateSubscriptionOverage.mockRejectedValue(new Error('Stripe invoice failed'))

const { handleStripeSubscriptionDeleted } = await import('./subscription')
await expect(
handleStripeSubscriptionDeleted(createDeletedSubscriptionEvent() as any)
).rejects.toThrow('Stripe invoice failed')

// Leaving the user with no entitled subscription is what breaks every billing read -
// a failed final invoice must not cost them their default tier.
expect(mockEnsureDefaultUserSubscription).toHaveBeenCalledWith('user-1', mockDb)
expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledWith(defaultSubscription)
})

it('skips final settlement when the billing tier sync fails, but still restores entitlement', async () => {
const stripeBackedSubscription = createDefaultSubscription({
status: 'canceled',
stripeSubscriptionId: 'sub_stripe_123',
})
const defaultSubscription = createDefaultSubscription()
mockGetSubscriptionByStripeSubscriptionId
.mockResolvedValueOnce(stripeBackedSubscription)
.mockResolvedValueOnce(stripeBackedSubscription)
mockEnsureDefaultUserSubscription.mockResolvedValue(defaultSubscription)
mockSyncSubscriptionBillingTierFromStripeSubscription.mockRejectedValue(
new Error('No billing tier matched the provided tier or Stripe identifiers')
)

const { handleStripeSubscriptionDeleted } = await import('./subscription')
await expect(
handleStripeSubscriptionDeleted(createDeletedSubscriptionEvent() as any)
).rejects.toThrow('No billing tier matched')

// Overage is priced off the tier the sync writes, and the final invoice is created under a
// fixed idempotency key - billing against an unverified tier would be locked in for good.
expect(mockCalculateSubscriptionOverage).not.toHaveBeenCalled()
expect(mockResetUsageForSubscription).not.toHaveBeenCalled()
// Entitlement still has to come back; that never depended on settlement.
expect(mockEnsureDefaultUserSubscription).toHaveBeenCalledWith('user-1', mockDb)
expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledWith(defaultSubscription)
})

it('does not reset onboarding usage when another personal subscription remains entitled', async () => {
const canceledSubscription = createDefaultSubscription({
status: 'canceled',
Expand Down Expand Up @@ -420,6 +463,32 @@ describe('handleStripeSubscriptionDeleted', () => {
)
})

it('keeps the Stripe id on the restored row when settlement could not complete', async () => {
const stripeBackedSubscription = createDefaultSubscription({
status: 'canceled',
stripeSubscriptionId: 'sub_stripe_123',
})
const defaultSubscription = createDefaultSubscription()
mockGetSubscriptionByStripeSubscriptionId
.mockResolvedValueOnce(stripeBackedSubscription)
.mockResolvedValueOnce(stripeBackedSubscription)
mockEnsureDefaultUserSubscription.mockResolvedValue(defaultSubscription)
mockSyncSubscriptionBillingTierFromStripeSubscription.mockRejectedValue(
new Error('Billing tier could not be resolved')
)

const { handleStripeSubscriptionDeleted } = await import('./subscription')
await expect(
handleStripeSubscriptionDeleted(createDeletedSubscriptionEvent() as any)
).rejects.toThrow('Billing tier could not be resolved')

// Without this the retry cannot resolve the row and the final overage is lost for good,
// even though the usage ledger was already reset.
expect(mockEnsureDefaultUserSubscription).toHaveBeenCalledWith('user-1', mockDb)
expect(updateCalls).toContainEqual({ stripeSubscriptionId: 'sub_stripe_123' })
expect(mockCalculateSubscriptionOverage).not.toHaveBeenCalled()
})

it('syncs usage limits for organization members after deleting an organization subscription', async () => {
const organizationSubscription = createDefaultSubscription({
id: 'sub_org',
Expand Down
91 changes: 72 additions & 19 deletions apps/tradinggoose/lib/billing/webhooks/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
resetUserDefaultUsageToOnboardingAllowanceBalance,
} from '@/lib/billing/core/usage'
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
import { getResolvedBillingSettings } from '@/lib/billing/settings'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { type BillingTierRecord, isPaidBillingTier } from '@/lib/billing/tiers'
import { syncSubscriptionBillingTierFromStripeSubscription } from '@/lib/billing/tiers/persistence'
Expand Down Expand Up @@ -279,10 +278,26 @@ export async function handleStripeSubscriptionDeleted(event: Stripe.Event) {
})
.where(eq(subscription.stripeSubscriptionId, stripeSubscriptionId))

await syncSubscriptionBillingTierFromStripeSubscription(
resolvedSubscription.id,
stripeSubscription
)
// Settlement talks to Stripe and can fail. Entitlement restore below must not depend on it:
// personal Stripe subscriptions reuse the user's default subscription row, so bailing out
// here is exactly what leaves a user with no entitled subscription at all and breaks every
// billing read. Failures are rethrown once entitlement is safe. Settlement itself still
// depends on this sync, because it prices the final invoice off the tier the sync writes.
let settlementError: unknown = null

try {
await syncSubscriptionBillingTierFromStripeSubscription(
resolvedSubscription.id,
stripeSubscription
)
} catch (error) {
settlementError = error
logger.error('Failed to sync billing tier for a cancelled subscription', {
subscriptionId: resolvedSubscription.id,
stripeSubscriptionId,
error,
})
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const hydratedSubscription = await getSubscriptionByStripeSubscriptionId(stripeSubscriptionId)
if (!hydratedSubscription) {
Expand All @@ -298,28 +313,61 @@ export async function handleStripeSubscriptionDeleted(event: Stripe.Event) {
}
let subscriptionForUsageLimits: TieredSubscriptionLifecycleRecord = subscriptionToSettle

await handleSubscriptionDeleted(subscriptionToSettle)
if (settlementError) {
// The re-read above exists to pick up the tier sync's write, so a failed sync means the tier
// driving overage pricing is unverified. Final invoices are created under a fixed idempotency
// key, so billing against a stale allowance here would be locked in - the retry this rethrow
// triggers would return the prior invoice rather than a corrected one.
logger.error('Skipping final settlement because the billing tier could not be verified', {
subscriptionId: subscriptionToSettle.id,
referenceType: subscriptionToSettle.referenceType,
referenceId: subscriptionToSettle.referenceId,
})
} else {
try {
await handleSubscriptionDeleted(subscriptionToSettle)
} catch (error) {
settlementError = error
logger.error('Failed to settle a cancelled subscription; restoring entitlement anyway', {
subscriptionId: subscriptionToSettle.id,
referenceType: subscriptionToSettle.referenceType,
referenceId: subscriptionToSettle.referenceId,
error,
})
}
}

// No billing-enabled gate here: a signature-verified Stripe webhook resolving to a local row
// that carries a stripeSubscriptionId is only reachable while billing is configured and
// running. Re-deriving that from settings only added a fallible read that could skip the
// restore and leave the user unentitled.
if (subscriptionToSettle.referenceType === 'user') {
const { billingEnabled } = await getResolvedBillingSettings()
subscriptionForUsageLimits = await db.transaction(async (tx) => {
const nextSubscription = await ensureDefaultUserSubscription(
subscriptionToSettle.referenceId,
tx
)

if (billingEnabled) {
subscriptionForUsageLimits = await db.transaction(async (tx) => {
const nextSubscription = await ensureDefaultUserSubscription(
if (nextSubscription.tier?.isDefault && !nextSubscription.stripeSubscriptionId) {
await resetUserDefaultUsageToOnboardingAllowanceBalance(
subscriptionToSettle.referenceId,
tx
)
}

if (nextSubscription.tier?.isDefault && !nextSubscription.stripeSubscriptionId) {
await resetUserDefaultUsageToOnboardingAllowanceBalance(
subscriptionToSettle.referenceId,
tx
)
}
if (settlementError) {
// The restore just cleared stripe_subscription_id, the only key the retry resolves
// rows by - and for a personal subscription the paid row IS this default row. Put it
// back while settlement is still owed, or the retry finds nothing and the final
// overage is lost even though the usage ledger was already reset.
await tx
.update(subscription)
.set({ stripeSubscriptionId })
.where(eq(subscription.id, nextSubscription.id))
}

return nextSubscription
})
}
return nextSubscription
})
}

await syncSubscriptionUsageLimits(subscriptionForUsageLimits)
Expand All @@ -330,5 +378,10 @@ export async function handleStripeSubscriptionDeleted(event: Stripe.Event) {
referenceType: subscriptionToSettle.referenceType,
referenceId: subscriptionToSettle.referenceId,
stripeSubscriptionId,
settlementFailed: Boolean(settlementError),
})

if (settlementError) {
throw settlementError
}
}
Loading