import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type Stripe from 'stripe'; import { BillingSubscriptionStatus } from '@prisma/client'; import { DEFAULT_TRIAL_PERIOD_DAYS, buildBillingAccessWhereInput, buildExpiredBillingWhereInput, getBillingAccessEndDate, getBillingOverview, getBillingStatusLabel, getDefaultTrialEndsAt, getOrCreateStripeCustomerId, getStorageCleanupEligibleAt, getStripeCheckoutState, getWorkspaceCreationEligibility, hasActiveSubscription, hasActiveTrial, hasBillingAccess, hasRecoverableSubscription, mapStripeSubscriptionStatus, markSubscriptionCanceledByCustomerId, selectAuthoritativeSubscription, syncStripeCustomerSubscriptions, syncStripeSubscriptionToUser, } from '@/lib/billing'; const dbMock = vi.hoisted(() => ({ user: { findUnique: vi.fn(), update: vi.fn() }, workspace: { count: vi.fn() }, workspaceMember: { count: vi.fn() }, projectMember: { count: vi.fn() }, })); const stripeMock = vi.hoisted(() => ({ customers: { create: vi.fn() }, subscriptions: { list: vi.fn() }, })); vi.mock('@/lib/db', () => ({ db: dbMock, default: dbMock, disconnectDb: vi.fn() })); // Only getStripe() is replaced. getStripePriceId() stays real so the entitlement // checks still read STRIPE_PRICE_ID from the stubbed environment. vi.mock('@/lib/stripe', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getStripe: () => stripeMock }; }); const NOW = new Date('2026-01-15T00:00:00.000Z'); const DAY_MS = 24 * 60 * 60 * 1000; const ALL_STATUSES = [ BillingSubscriptionStatus.FREE, BillingSubscriptionStatus.TRIALING, BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.PAST_DUE, BillingSubscriptionStatus.CANCELED, BillingSubscriptionStatus.UNPAID, BillingSubscriptionStatus.INCOMPLETE, BillingSubscriptionStatus.INCOMPLETE_EXPIRED, ] as const; type Subject = Parameters[0]; function subject(overrides: Partial = {}): Subject { return { subscriptionStatus: BillingSubscriptionStatus.FREE, trialEndsAt: null, stripeCurrentPeriodEnd: null, billingAccessEndedAt: null, ...overrides, }; } beforeEach(() => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); }); afterEach(() => { vi.unstubAllEnvs(); }); describe('getDefaultTrialEndsAt', () => { it('adds exactly seven days to the given start date', () => { expect(getDefaultTrialEndsAt(NOW).toISOString()).toBe('2026-01-22T00:00:00.000Z'); }); it('spans a month boundary without shifting the time of day', () => { const end = getDefaultTrialEndsAt(new Date('2026-01-28T13:45:12.500Z')); expect(end.toISOString()).toBe('2026-02-04T13:45:12.500Z'); }); it('exposes the trial length that the offset is built from', () => { const from = new Date('2026-03-01T00:00:00.000Z'); const elapsedDays = (getDefaultTrialEndsAt(from).getTime() - from.getTime()) / DAY_MS; expect(elapsedDays).toBe(DEFAULT_TRIAL_PERIOD_DAYS); }); }); describe('hasActiveTrial', () => { it('returns true one millisecond before the trial end', () => { expect(hasActiveTrial(new Date(NOW.getTime() + 1), NOW)).toBe(true); }); it('returns false at the exact trial end instant', () => { expect(hasActiveTrial(new Date(NOW.getTime()), NOW)).toBe(false); }); it('returns false one millisecond after the trial end', () => { expect(hasActiveTrial(new Date(NOW.getTime() - 1), NOW)).toBe(false); }); it.each([ ['null', null], ['undefined', undefined], ])('returns false for %s', (_label, value) => { expect(hasActiveTrial(value, NOW)).toBe(false); }); }); describe('hasActiveSubscription', () => { const expected: Record = { FREE: false, TRIALING: true, ACTIVE: true, PAST_DUE: false, CANCELED: false, UNPAID: false, INCOMPLETE: false, INCOMPLETE_EXPIRED: false, }; it.each(ALL_STATUSES)('returns %s for status %s', (status) => { expect(hasActiveSubscription(status)).toBe(expected[status]); }); it.each([ ['null', null], ['undefined', undefined], ])('returns false for %s', (_label, value) => { expect(hasActiveSubscription(value)).toBe(false); }); }); describe('hasRecoverableSubscription', () => { const expected: Record = { FREE: false, TRIALING: true, ACTIVE: true, PAST_DUE: true, CANCELED: false, UNPAID: true, INCOMPLETE: true, INCOMPLETE_EXPIRED: false, }; it.each(ALL_STATUSES)('classifies status %s', (status) => { expect(hasRecoverableSubscription(status)).toBe(expected[status]); }); it('returns false for a missing status', () => { expect(hasRecoverableSubscription(null)).toBe(false); }); }); describe('hasBillingAccess', () => { it('grants access on an ACTIVE subscription with no dates at all', () => { expect(hasBillingAccess(subject({ subscriptionStatus: 'ACTIVE' }), NOW)).toBe(true); }); it('grants access on a TRIALING subscription with no dates at all', () => { expect(hasBillingAccess(subject({ subscriptionStatus: 'TRIALING' }), NOW)).toBe(true); }); it('grants access to a PAST_DUE customer whose trial has not ended yet', () => { const result = hasBillingAccess( subject({ subscriptionStatus: 'PAST_DUE', trialEndsAt: new Date(NOW.getTime() + DAY_MS), }), NOW ); expect(result).toBe(true); }); it('grants access to a PAST_DUE customer whose paid period has not ended yet', () => { const result = hasBillingAccess( subject({ subscriptionStatus: 'PAST_DUE', trialEndsAt: new Date(NOW.getTime() - DAY_MS), stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), }), NOW ); expect(result).toBe(true); }); it('denies access to a PAST_DUE customer whose trial and paid period have both ended', () => { const result = hasBillingAccess( subject({ subscriptionStatus: 'PAST_DUE', trialEndsAt: new Date(NOW.getTime() - DAY_MS), stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS), }), NOW ); expect(result).toBe(false); }); it('denies access to a CANCELED customer with no dates', () => { expect(hasBillingAccess(subject({ subscriptionStatus: 'CANCELED' }), NOW)).toBe(false); }); it('denies access when the trial ends at exactly the current instant', () => { expect(hasBillingAccess(subject({ trialEndsAt: new Date(NOW.getTime()) }), NOW)).toBe(false); }); it('denies access when the paid period ends at exactly the current instant', () => { expect( hasBillingAccess(subject({ stripeCurrentPeriodEnd: new Date(NOW.getTime()) }), NOW) ).toBe(false); }); it('grants access when the paid period ends one millisecond from now', () => { expect( hasBillingAccess(subject({ stripeCurrentPeriodEnd: new Date(NOW.getTime() + 1) }), NOW) ).toBe(true); }); it('ignores billingAccessEndedAt while the paid period is still running', () => { const result = hasBillingAccess( subject({ subscriptionStatus: 'CANCELED', stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS), billingAccessEndedAt: new Date(NOW.getTime() - DAY_MS), }), NOW ); expect(result).toBe(true); }); it('grants access to everyone when Stripe is disabled', () => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); const result = hasBillingAccess( subject({ subscriptionStatus: 'INCOMPLETE_EXPIRED', trialEndsAt: new Date('2020-01-01T00:00:00Z'), stripeCurrentPeriodEnd: new Date('2020-01-08T00:00:00Z'), }), NOW ); expect(result).toBe(true); }); }); describe('getBillingAccessEndDate', () => { it('prefers billingAccessEndedAt over every other date', () => { const ended = new Date('2026-01-10T00:00:00Z'); const result = getBillingAccessEndDate( subject({ billingAccessEndedAt: ended, stripeCurrentPeriodEnd: new Date('2026-02-01T00:00:00Z'), trialEndsAt: new Date('2026-03-01T00:00:00Z'), }) ); expect(result).toBe(ended); }); it('falls back to stripeCurrentPeriodEnd when billing has not been marked ended', () => { const periodEnd = new Date('2026-02-01T00:00:00Z'); const result = getBillingAccessEndDate( subject({ stripeCurrentPeriodEnd: periodEnd, trialEndsAt: new Date('2026-03-01T00:00:00Z'), }) ); expect(result).toBe(periodEnd); }); it('falls back to trialEndsAt when there is no paid period', () => { const trialEnd = new Date('2026-03-01T00:00:00Z'); expect(getBillingAccessEndDate(subject({ trialEndsAt: trialEnd }))).toBe(trialEnd); }); it('returns null when the subject has no billing dates', () => { expect(getBillingAccessEndDate(subject())).toBeNull(); }); }); describe('getStorageCleanupEligibleAt', () => { it('adds a fifteen day grace period to the access end date', () => { const result = getStorageCleanupEligibleAt( subject({ billingAccessEndedAt: new Date('2026-01-15T00:00:00.000Z') }) ); expect(result?.toISOString()).toBe('2026-01-30T00:00:00.000Z'); }); it('measures the grace period from the trial end when no other date exists', () => { const result = getStorageCleanupEligibleAt( subject({ trialEndsAt: new Date('2026-01-01T06:30:00.000Z') }) ); expect(result?.toISOString()).toBe('2026-01-16T06:30:00.000Z'); }); it('returns null when there is no access end date to count from', () => { expect(getStorageCleanupEligibleAt(subject())).toBeNull(); }); }); describe('buildBillingAccessWhereInput', () => { it('matches active or trialing subscriptions or an unexpired date', () => { expect(buildBillingAccessWhereInput(NOW)).toEqual({ OR: [ { subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } }, { trialEndsAt: { gt: NOW } }, { stripeCurrentPeriodEnd: { gt: NOW } }, ], }); }); it('uses a strict greater-than so a boundary date does not grant access', () => { const where = buildBillingAccessWhereInput(NOW) as { OR: Array<{ trialEndsAt?: { gt?: Date; gte?: Date } }>; }; expect(where.OR[1].trialEndsAt).not.toHaveProperty('gte'); expect(where.OR[1].trialEndsAt?.gt).toEqual(NOW); }); it('matches every user when Stripe is disabled', () => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); expect(buildBillingAccessWhereInput(NOW)).toEqual({}); }); }); describe('buildExpiredBillingWhereInput', () => { it('negates the access filter and requires the fifteen day grace to have elapsed', () => { const cutoff = new Date('2025-12-31T00:00:00.000Z'); expect(buildExpiredBillingWhereInput(NOW)).toEqual({ AND: [ { NOT: { OR: [ { subscriptionStatus: { in: ['ACTIVE', 'TRIALING'] } }, { trialEndsAt: { gt: NOW } }, { stripeCurrentPeriodEnd: { gt: NOW } }, ], }, }, { OR: [ { billingAccessEndedAt: { lte: cutoff } }, { AND: [{ billingAccessEndedAt: null }, { trialEndsAt: { lte: cutoff } }] }, ], }, ], }); }); it('keeps the grace clause when Stripe is disabled even though NOT {} matches nobody', () => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); const where = buildExpiredBillingWhereInput(NOW) as { AND: Array<{ NOT?: object }> }; expect(where.AND[0].NOT).toEqual({}); }); }); describe('mapStripeSubscriptionStatus', () => { const mapping: Array<[Stripe.Subscription.Status, BillingSubscriptionStatus]> = [ ['trialing', BillingSubscriptionStatus.TRIALING], ['active', BillingSubscriptionStatus.ACTIVE], ['past_due', BillingSubscriptionStatus.PAST_DUE], ['canceled', BillingSubscriptionStatus.CANCELED], ['unpaid', BillingSubscriptionStatus.UNPAID], ['incomplete', BillingSubscriptionStatus.INCOMPLETE], ['incomplete_expired', BillingSubscriptionStatus.INCOMPLETE_EXPIRED], ]; it.each(mapping)('maps the Stripe status %s', (stripeStatus, expected) => { expect(mapStripeSubscriptionStatus(stripeStatus)).toBe(expected); }); it('maps the paused status to FREE because it has no Prisma counterpart', () => { expect(mapStripeSubscriptionStatus('paused')).toBe(BillingSubscriptionStatus.FREE); }); it.each([ ['null', null], ['undefined', undefined], ])('maps %s to FREE', (_label, value) => { expect(mapStripeSubscriptionStatus(value)).toBe(BillingSubscriptionStatus.FREE); }); }); describe('getBillingStatusLabel', () => { const labels: Record = { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELED: 'Canceled', UNPAID: 'Unpaid', INCOMPLETE: 'Incomplete', INCOMPLETE_EXPIRED: 'Expired', FREE: 'Free', }; it.each(ALL_STATUSES)('labels status %s', (status) => { expect(getBillingStatusLabel(status)).toBe(labels[status]); }); }); describe('selectAuthoritativeSubscription', () => { const ENTITLED_PRICE = 'price_entitled'; function sub(options: { id: string; status: Stripe.Subscription.Status; priceIds: string[]; created: number; }): Stripe.Subscription { return { id: options.id, status: options.status, created: options.created, items: { data: options.priceIds.map((id) => ({ price: { id } })) }, } as unknown as Stripe.Subscription; } beforeEach(() => { vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE); }); it('returns null for an empty list', () => { expect(selectAuthoritativeSubscription([])).toBeNull(); }); it('returns the only subscription even when it carries an unrelated price', () => { const only = sub({ id: 'sub_only', status: 'canceled', priceIds: ['price_other'], created: 1 }); expect(selectAuthoritativeSubscription([only])?.id).toBe('sub_only'); }); it('prefers the entitled price over a livelier status on an unrelated price', () => { const entitledButCanceled = sub({ id: 'sub_entitled', status: 'canceled', priceIds: [ENTITLED_PRICE], created: 100, }); const activeUnrelated = sub({ id: 'sub_unrelated', status: 'active', priceIds: ['price_other'], created: 200, }); expect(selectAuthoritativeSubscription([activeUnrelated, entitledButCanceled])?.id).toBe( 'sub_entitled' ); }); it('prefers the livelier status when both subscriptions carry the entitled price', () => { const pastDue = sub({ id: 'sub_past_due', status: 'past_due', priceIds: [ENTITLED_PRICE], created: 300, }); const active = sub({ id: 'sub_active', status: 'active', priceIds: [ENTITLED_PRICE], created: 100, }); expect(selectAuthoritativeSubscription([pastDue, active])?.id).toBe('sub_active'); }); it('ranks trialing above past_due and past_due above canceled', () => { const canceled = sub({ id: 'sub_canceled', status: 'canceled', priceIds: [ENTITLED_PRICE], created: 500, }); const pastDue = sub({ id: 'sub_past_due', status: 'past_due', priceIds: [ENTITLED_PRICE], created: 400, }); const trialing = sub({ id: 'sub_trialing', status: 'trialing', priceIds: [ENTITLED_PRICE], created: 300, }); expect(selectAuthoritativeSubscription([canceled, pastDue, trialing])?.id).toBe('sub_trialing'); expect(selectAuthoritativeSubscription([canceled, pastDue])?.id).toBe('sub_past_due'); }); it('breaks a status tie with the most recently created subscription', () => { const older = sub({ id: 'sub_older', status: 'active', priceIds: [ENTITLED_PRICE], created: 100, }); const newer = sub({ id: 'sub_newer', status: 'active', priceIds: [ENTITLED_PRICE], created: 999, }); expect(selectAuthoritativeSubscription([older, newer])?.id).toBe('sub_newer'); expect(selectAuthoritativeSubscription([newer, older])?.id).toBe('sub_newer'); }); it('matches the entitled price on any item of a multi-item subscription', () => { const multiItem = sub({ id: 'sub_multi', status: 'canceled', priceIds: ['price_addon', ENTITLED_PRICE], created: 10, }); const unrelated = sub({ id: 'sub_unrelated', status: 'active', priceIds: ['price_other'], created: 20, }); expect(selectAuthoritativeSubscription([unrelated, multiItem])?.id).toBe('sub_multi'); }); it('does not mutate the array it was given', () => { const first = sub({ id: 'a', status: 'canceled', priceIds: ['x'], created: 1 }); const second = sub({ id: 'b', status: 'active', priceIds: [ENTITLED_PRICE], created: 2 }); const input = [first, second]; selectAuthoritativeSubscription(input); expect(input.map((s) => s.id)).toEqual(['a', 'b']); }); it('throws when STRIPE_PRICE_ID is unset and more than one subscription must be compared', () => { vi.stubEnv('STRIPE_PRICE_ID', ''); const a = sub({ id: 'a', status: 'active', priceIds: ['x'], created: 1 }); const b = sub({ id: 'b', status: 'active', priceIds: ['y'], created: 2 }); expect(() => selectAuthoritativeSubscription([a, b])).toThrow( 'STRIPE_PRICE_ID is not configured' ); }); }); // --------------------------------------------------------------------------- // Database and Stripe backed helpers. The Prisma client and getStripe() are // mocked, so these still assert branching logic rather than persistence. // --------------------------------------------------------------------------- const ENTITLED_PRICE = 'price_entitled'; function stripeSub(overrides: Record = {}): Stripe.Subscription { return { id: 'sub_1', customer: 'cus_1', status: 'active', created: 1_700_000_000, current_period_end: Math.floor(NOW.getTime() / 1000) + 30 * 86_400, cancel_at: null, cancel_at_period_end: false, trial_end: null, items: { data: [{ price: { id: ENTITLED_PRICE } }] }, ...overrides, } as unknown as Stripe.Subscription; } function updateData(): Record { return dbMock.user.update.mock.calls[0][0].data as Record; } describe('database backed billing helpers', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'true'); vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE); dbMock.user.findUnique.mockReset(); dbMock.user.update.mockReset(); dbMock.workspace.count.mockReset(); dbMock.workspaceMember.count.mockReset(); dbMock.projectMember.count.mockReset(); stripeMock.customers.create.mockReset(); stripeMock.subscriptions.list.mockReset(); dbMock.user.update.mockImplementation(async (args: { data: unknown }) => args.data); }); afterEach(() => { vi.useRealTimers(); }); describe('getStripeCheckoutState', () => { it('throws when the user does not exist', async () => { dbMock.user.findUnique.mockResolvedValue(null); await expect(getStripeCheckoutState('u1')).rejects.toThrow('User u1 not found'); }); it('reports an active subscriber as active, recoverable and trial-consumed', async () => { dbMock.user.findUnique.mockResolvedValue({ subscriptionStatus: BillingSubscriptionStatus.ACTIVE, billingTrialConsumedAt: new Date('2025-06-01T00:00:00Z'), }); await expect(getStripeCheckoutState('u1')).resolves.toEqual({ hasActiveSubscription: true, hasRecoverableSubscription: true, isTrialEligible: false, }); }); it('reports a past_due subscriber as recoverable but not active', async () => { dbMock.user.findUnique.mockResolvedValue({ subscriptionStatus: BillingSubscriptionStatus.PAST_DUE, billingTrialConsumedAt: null, }); await expect(getStripeCheckoutState('u1')).resolves.toEqual({ hasActiveSubscription: false, hasRecoverableSubscription: true, isTrialEligible: true, }); }); it('reports a canceled subscriber as neither active nor recoverable', async () => { dbMock.user.findUnique.mockResolvedValue({ subscriptionStatus: BillingSubscriptionStatus.CANCELED, billingTrialConsumedAt: null, }); const state = await getStripeCheckoutState('u1'); expect(state.hasActiveSubscription).toBe(false); expect(state.hasRecoverableSubscription).toBe(false); }); }); describe('getWorkspaceCreationEligibility', () => { function mockEligibility(options: { user?: Record | null; owned?: number; invited?: number; projectOnly?: number; }) { dbMock.user.findUnique.mockResolvedValue( options.user === undefined ? { subscriptionStatus: BillingSubscriptionStatus.FREE, trialEndsAt: null, billingTrialConsumedAt: null, stripeCustomerId: null, stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, stripeCancelAtPeriodEnd: null, stripeCancelAt: null, billingAccessEndedAt: null, } : options.user ); dbMock.workspace.count.mockResolvedValue(options.owned ?? 0); dbMock.workspaceMember.count.mockResolvedValue(options.invited ?? 0); dbMock.projectMember.count.mockResolvedValue(options.projectOnly ?? 0); } it('throws when the user does not exist', async () => { mockEligibility({ user: null }); await expect(getWorkspaceCreationEligibility('u1')).rejects.toThrow('User u1 not found'); }); it('allows creation while billing access holds, whatever the counts are', async () => { mockEligibility({ user: { subscriptionStatus: BillingSubscriptionStatus.ACTIVE, trialEndsAt: null, billingTrialConsumedAt: new Date('2025-01-01T00:00:00Z'), stripeCustomerId: 'cus_1', stripeSubscriptionId: 'sub_1', stripePriceId: ENTITLED_PRICE, stripeCurrentPeriodEnd: new Date('2026-02-01T00:00:00Z'), stripeCancelAtPeriodEnd: false, stripeCancelAt: null, billingAccessEndedAt: null, }, owned: 5, invited: 3, }); const result = await getWorkspaceCreationEligibility('u1'); expect(result.canCreateWorkspace).toBe(true); expect(result.reason).toBeNull(); expect(result.subscription.label).toBe('Active'); expect(result.subscription.hasBillingAccess).toBe(true); }); it('allows a brand new user with no workspaces and no collaborations', async () => { mockEligibility({}); const result = await getWorkspaceCreationEligibility('u1'); expect(result.canCreateWorkspace).toBe(true); expect(result.reason).toBeNull(); }); it('blocks an expired owner who already has a workspace', async () => { mockEligibility({ owned: 1 }); const result = await getWorkspaceCreationEligibility('u1'); expect(result.canCreateWorkspace).toBe(false); expect(result.reason).toContain('Your trial has ended'); }); it('blocks an expired user who only collaborates in someone else workspace', async () => { mockEligibility({ owned: 0, invited: 1 }); const result = await getWorkspaceCreationEligibility('u1'); expect(result.canCreateWorkspace).toBe(false); expect(result.reason).toContain('currently collaborating'); }); it('counts project-only collaboration towards the same block', async () => { mockEligibility({ owned: 0, projectOnly: 2 }); const result = await getWorkspaceCreationEligibility('u1'); expect(result.canCreateWorkspace).toBe(false); expect(result.reason).toContain('currently collaborating'); expect(result.projectOnlyCollaborationCount).toBe(2); }); it('prefers the trial-ended reason when the user both owns and collaborates', async () => { mockEligibility({ owned: 1, invited: 1 }); expect((await getWorkspaceCreationEligibility('u1')).reason).toContain( 'Your trial has ended' ); }); it('always allows creation when Stripe is disabled', async () => { vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false'); mockEligibility({ owned: 9, invited: 9 }); const result = await getWorkspaceCreationEligibility('u1'); expect(result.canCreateWorkspace).toBe(true); expect(result.reason).toBeNull(); }); it('derives the storage cleanup date from the billing end date', async () => { mockEligibility({ user: { subscriptionStatus: BillingSubscriptionStatus.CANCELED, trialEndsAt: null, billingTrialConsumedAt: new Date('2025-01-01T00:00:00Z'), stripeCustomerId: 'cus_1', stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, stripeCancelAtPeriodEnd: false, stripeCancelAt: null, billingAccessEndedAt: new Date('2026-01-01T00:00:00.000Z'), }, }); const result = await getWorkspaceCreationEligibility('u1'); expect(result.subscription.storageCleanupEligibleAt?.toISOString()).toBe( '2026-01-16T00:00:00.000Z' ); }); }); describe('getBillingOverview', () => { it('reshapes the eligibility result into workspaceCreation and subscription blocks', async () => { dbMock.user.findUnique.mockResolvedValue({ subscriptionStatus: BillingSubscriptionStatus.TRIALING, trialEndsAt: new Date('2099-01-01T00:00:00Z'), billingTrialConsumedAt: null, stripeCustomerId: null, stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, stripeCancelAtPeriodEnd: null, stripeCancelAt: null, billingAccessEndedAt: null, }); dbMock.workspace.count.mockResolvedValue(2); dbMock.workspaceMember.count.mockResolvedValue(1); dbMock.projectMember.count.mockResolvedValue(4); const overview = await getBillingOverview('u1'); expect(overview.workspaceCreation).toEqual({ canCreateWorkspace: true, reason: null, ownedWorkspaceCount: 2, invitedWorkspaceCount: 1, }); expect(overview.subscription.label).toBe('Trialing'); // projectOnlyCollaborationCount is deliberately not surfaced here. expect(overview.workspaceCreation).not.toHaveProperty('projectOnlyCollaborationCount'); }); }); describe('getOrCreateStripeCustomerId', () => { it('throws when the user does not exist', async () => { dbMock.user.findUnique.mockResolvedValue(null); await expect(getOrCreateStripeCustomerId('u1')).rejects.toThrow('User u1 not found'); }); it('returns the stored customer id without calling Stripe', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', email: 'a@example.com', name: 'A', stripeCustomerId: 'cus_existing', }); await expect(getOrCreateStripeCustomerId('u1')).resolves.toBe('cus_existing'); expect(stripeMock.customers.create).not.toHaveBeenCalled(); expect(dbMock.user.update).not.toHaveBeenCalled(); }); it('creates the customer with the user metadata and persists the new id', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', email: 'a@example.com', name: 'A', stripeCustomerId: null, }); stripeMock.customers.create.mockResolvedValue({ id: 'cus_new' }); await expect(getOrCreateStripeCustomerId('u1')).resolves.toBe('cus_new'); expect(stripeMock.customers.create).toHaveBeenCalledWith({ email: 'a@example.com', name: 'A', metadata: { userId: 'u1' }, }); expect(dbMock.user.update).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { stripeCustomerId: 'cus_new' }, }); }); it('sends undefined rather than null for a missing email or name', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', email: null, name: null, stripeCustomerId: null, }); stripeMock.customers.create.mockResolvedValue({ id: 'cus_new' }); await getOrCreateStripeCustomerId('u1'); expect(stripeMock.customers.create).toHaveBeenCalledWith({ email: undefined, name: undefined, metadata: { userId: 'u1' }, }); }); }); describe('syncStripeSubscriptionToUser', () => { it('returns null when no user owns the Stripe customer', async () => { dbMock.user.findUnique.mockResolvedValue(null); await expect(syncStripeSubscriptionToUser(stripeSub())).resolves.toBeNull(); expect(dbMock.user.update).not.toHaveBeenCalled(); }); it('accepts an expanded customer object as well as a customer id', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeSubscriptionToUser(stripeSub({ customer: { id: 'cus_expanded' } })); expect(dbMock.user.findUnique).toHaveBeenCalledWith( expect.objectContaining({ where: { stripeCustomerId: 'cus_expanded' } }) ); }); it('writes the mapped status and dates for an entitled active subscription', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); const periodEnd = Math.floor(NOW.getTime() / 1000) + 30 * 86_400; await syncStripeSubscriptionToUser( stripeSub({ status: 'active', current_period_end: periodEnd }) ); expect(updateData()).toMatchObject({ stripeSubscriptionId: 'sub_1', stripePriceId: ENTITLED_PRICE, subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCancelAtPeriodEnd: false, stripeCancelAt: null, trialEndsAt: null, billingAccessEndedAt: null, }); expect((updateData().stripeCurrentPeriodEnd as Date).getTime()).toBe(periodEnd * 1000); }); it('downgrades a subscription that does not carry the entitled price to FREE', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeSubscriptionToUser( stripeSub({ status: 'active', items: { data: [{ price: { id: 'price_unrelated' } }] }, }) ); expect(updateData()).toMatchObject({ subscriptionStatus: BillingSubscriptionStatus.FREE, stripePriceId: 'price_unrelated', stripeCurrentPeriodEnd: null, trialEndsAt: null, }); expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date); }); it('keeps access while a canceled subscription is still inside its paid period', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeSubscriptionToUser( stripeSub({ status: 'canceled', current_period_end: Math.floor(NOW.getTime() / 1000) + 3600, }) ); expect(updateData()).toMatchObject({ subscriptionStatus: BillingSubscriptionStatus.CANCELED, billingAccessEndedAt: null, }); }); it('ends access at the period end once the paid period has passed', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); const periodEnd = Math.floor(NOW.getTime() / 1000) - 3600; await syncStripeSubscriptionToUser( stripeSub({ status: 'canceled', current_period_end: periodEnd }) ); expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(periodEnd * 1000); }); it('falls back to ended_at when there is no period end', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); const endedAt = Math.floor(NOW.getTime() / 1000) - 7200; await syncStripeSubscriptionToUser( stripeSub({ status: 'canceled', current_period_end: null, ended_at: endedAt }) ); expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(endedAt * 1000); }); it('falls back to canceled_at when there is neither a period end nor an ended_at', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); const canceledAt = Math.floor(NOW.getTime() / 1000) - 10_800; await syncStripeSubscriptionToUser( stripeSub({ status: 'canceled', current_period_end: null, ended_at: null, canceled_at: canceledAt, }) ); expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(canceledAt * 1000); }); it('falls back to the current time when Stripe supplies no timestamps at all', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeSubscriptionToUser( stripeSub({ status: 'canceled', current_period_end: null }) ); expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime()); }); it('marks the trial consumed the first time an entitled trial is seen', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); const trialEnd = Math.floor(NOW.getTime() / 1000) + 5 * 86_400; await syncStripeSubscriptionToUser(stripeSub({ status: 'trialing', trial_end: trialEnd })); expect((updateData().billingTrialConsumedAt as Date).getTime()).toBe(NOW.getTime()); expect((updateData().trialEndsAt as Date).getTime()).toBe(trialEnd * 1000); }); it('does not overwrite an existing trial consumption timestamp', async () => { const consumed = new Date('2025-06-01T00:00:00.000Z'); dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: consumed }); await syncStripeSubscriptionToUser( stripeSub({ status: 'trialing', trial_end: Math.floor(NOW.getTime() / 1000) + 86_400 }) ); expect(updateData().billingTrialConsumedAt).toBe(consumed); }); it('leaves the trial unconsumed when the subscription has no trial', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeSubscriptionToUser(stripeSub({ trial_end: null })); expect(updateData().billingTrialConsumedAt).toBeNull(); }); it('does not consume the trial for an unentitled subscription', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeSubscriptionToUser( stripeSub({ status: 'trialing', trial_end: Math.floor(NOW.getTime() / 1000) + 86_400, items: { data: [{ price: { id: 'price_unrelated' } }] }, }) ); expect(updateData().billingTrialConsumedAt).toBeNull(); }); it('records the cancel_at date and flag when the customer scheduled a cancellation', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); const cancelAt = Math.floor(NOW.getTime() / 1000) + 10 * 86_400; await syncStripeSubscriptionToUser( stripeSub({ cancel_at: cancelAt, cancel_at_period_end: true }) ); expect(updateData().stripeCancelAtPeriodEnd).toBe(true); expect((updateData().stripeCancelAt as Date).getTime()).toBe(cancelAt * 1000); }); it('stores null for the price id when the subscription has no items', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeSubscriptionToUser(stripeSub({ items: { data: [] } })); expect(updateData().stripePriceId).toBeNull(); }); }); describe('markSubscriptionCanceledByCustomerId', () => { it('returns null when no user owns the customer id', async () => { dbMock.user.findUnique.mockResolvedValue(null); await expect(markSubscriptionCanceledByCustomerId('cus_1')).resolves.toBeNull(); expect(dbMock.user.update).not.toHaveBeenCalled(); }); it('clears every subscription field and ends access now by default', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1' }); await markSubscriptionCanceledByCustomerId('cus_1'); expect(updateData()).toMatchObject({ subscriptionStatus: BillingSubscriptionStatus.CANCELED, trialEndsAt: null, stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null, stripeCancelAtPeriodEnd: false, stripeCancelAt: null, }); expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime()); }); it('keeps the supplied period end and uses it as the access end date', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1' }); const periodEnd = new Date('2026-02-01T00:00:00.000Z'); await markSubscriptionCanceledByCustomerId('cus_1', { currentPeriodEnd: periodEnd }); expect(updateData().stripeCurrentPeriodEnd).toBe(periodEnd); expect(updateData().billingAccessEndedAt).toBe(periodEnd); }); it('prefers an explicit endedAt over the period end', async () => { dbMock.user.findUnique.mockResolvedValue({ id: 'u1' }); const periodEnd = new Date('2026-02-01T00:00:00.000Z'); const endedAt = new Date('2026-01-20T00:00:00.000Z'); await markSubscriptionCanceledByCustomerId('cus_1', { currentPeriodEnd: periodEnd, endedAt, }); expect(updateData().billingAccessEndedAt).toBe(endedAt); }); }); describe('syncStripeCustomerSubscriptions', () => { it('lists every subscription status for the customer', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); dbMock.user.findUnique.mockResolvedValue(null); await syncStripeCustomerSubscriptions('cus_1'); expect(stripeMock.subscriptions.list).toHaveBeenCalledWith({ customer: 'cus_1', status: 'all', limit: 100, }); }); it('marks the subscription canceled when Stripe reports none', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); dbMock.user.findUnique.mockResolvedValue({ id: 'u1' }); await syncStripeCustomerSubscriptions('cus_1'); expect(updateData()).toMatchObject({ subscriptionStatus: BillingSubscriptionStatus.CANCELED, stripeSubscriptionId: null, }); }); it('syncs the authoritative subscription rather than the first one listed', async () => { stripeMock.subscriptions.list.mockResolvedValue({ data: [ stripeSub({ id: 'sub_stale', status: 'canceled', items: { data: [{ price: { id: 'price_unrelated' } }] }, }), stripeSub({ id: 'sub_live', status: 'active' }), ], }); dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null }); await syncStripeCustomerSubscriptions('cus_1'); expect(updateData()).toMatchObject({ stripeSubscriptionId: 'sub_live', subscriptionStatus: BillingSubscriptionStatus.ACTIVE, }); }); }); });