All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
176 lines
6.6 KiB
TypeScript
176 lines
6.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { SubscriptionStatus, SubscriptionTier } from '@prisma/client';
|
|
|
|
vi.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
subscription: {
|
|
upsert: vi.fn().mockResolvedValue({}),
|
|
findUnique: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
update: vi.fn().mockResolvedValue({}),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/billing/stripe-prices', () => ({
|
|
priceIdToTier: vi.fn((priceId: string) => {
|
|
if (priceId === 'price_pro_monthly_test') return 'PRO';
|
|
if (priceId === 'price_business_monthly_test') return 'BUSINESS';
|
|
return null;
|
|
}),
|
|
}));
|
|
|
|
import { syncSubscriptionFromStripe, handleSubscriptionDeleted } from '@/lib/billing/sync-subscription-from-stripe';
|
|
import { prisma } from '@/lib/prisma';
|
|
import type Stripe from 'stripe';
|
|
|
|
function makeSubscription(overrides: Partial<Stripe.Subscription> = {}): Stripe.Subscription {
|
|
return {
|
|
id: 'sub_test_123',
|
|
object: 'subscription',
|
|
status: 'active',
|
|
customer: 'cus_test_123',
|
|
current_period_start: 1700000000,
|
|
current_period_end: 1702678400,
|
|
cancel_at_period_end: false,
|
|
canceled_at: null,
|
|
trial_end: null,
|
|
metadata: { userId: 'user_test_123', tier: 'PRO' },
|
|
items: {
|
|
object: 'list',
|
|
data: [
|
|
{
|
|
id: 'si_test',
|
|
object: 'subscription_item',
|
|
price: {
|
|
id: 'price_pro_monthly_test',
|
|
object: 'price',
|
|
} as Stripe.Price,
|
|
} as Stripe.SubscriptionItem,
|
|
],
|
|
has_more: false,
|
|
url: '',
|
|
},
|
|
...overrides,
|
|
} as unknown as Stripe.Subscription;
|
|
}
|
|
|
|
describe('syncSubscriptionFromStripe', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('upserts subscription with correct PRO tier from price ID', async () => {
|
|
const sub = makeSubscription();
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
|
|
expect(prisma.subscription.upsert).toHaveBeenCalledOnce();
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.where).toEqual({ stripeSubscriptionId: 'sub_test_123' });
|
|
expect(call.create.tier).toBe(SubscriptionTier.PRO);
|
|
expect(call.create.status).toBe(SubscriptionStatus.ACTIVE);
|
|
expect(call.create.userId).toBe('user_test_123');
|
|
expect(call.create.stripeCustomerId).toBe('cus_test_123');
|
|
expect(call.create.stripeSubscriptionId).toBe('sub_test_123');
|
|
expect(call.create.stripePriceId).toBe('price_pro_monthly_test');
|
|
});
|
|
|
|
it('maps stripe active status to ACTIVE', async () => {
|
|
const sub = makeSubscription({ status: 'active' });
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.status).toBe(SubscriptionStatus.ACTIVE);
|
|
});
|
|
|
|
it('maps stripe past_due status to PAST_DUE', async () => {
|
|
const sub = makeSubscription({ status: 'past_due' });
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.status).toBe(SubscriptionStatus.PAST_DUE);
|
|
});
|
|
|
|
it('maps stripe canceled status to CANCELED', async () => {
|
|
const sub = makeSubscription({ status: 'canceled', canceled_at: 1700100000 });
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.status).toBe(SubscriptionStatus.CANCELED);
|
|
expect(call.create.canceledAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('maps stripe trialing status to TRIALING', async () => {
|
|
const sub = makeSubscription({ status: 'trialing', trial_end: 1700500000 });
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.status).toBe(SubscriptionStatus.TRIALING);
|
|
expect(call.create.trialEndsAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('maps stripe incomplete_expired to INACTIVE', async () => {
|
|
const sub = makeSubscription({ status: 'incomplete_expired' });
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.status).toBe(SubscriptionStatus.INACTIVE);
|
|
});
|
|
|
|
it('falls back to metadata tier when price ID not in map', async () => {
|
|
const sub = makeSubscription({
|
|
metadata: { userId: 'user_test_123', tier: 'BUSINESS' },
|
|
items: {
|
|
object: 'list',
|
|
data: [{ id: 'si_test', object: 'subscription_item', price: { id: 'price_unknown_abc', object: 'price' } as Stripe.Price } as Stripe.SubscriptionItem],
|
|
has_more: false,
|
|
url: '',
|
|
},
|
|
});
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.tier).toBe(SubscriptionTier.BUSINESS);
|
|
});
|
|
|
|
it('sets currentPeriodStart and currentPeriodEnd from Stripe timestamps', async () => {
|
|
const sub = makeSubscription({
|
|
current_period_start: 1700000000,
|
|
current_period_end: 1702678400,
|
|
});
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.currentPeriodStart).toEqual(new Date(1700000000 * 1000));
|
|
expect(call.create.currentPeriodEnd).toEqual(new Date(1702678400 * 1000));
|
|
});
|
|
|
|
it('sets cancelAtPeriodEnd correctly', async () => {
|
|
const sub = makeSubscription({ cancel_at_period_end: true });
|
|
await syncSubscriptionFromStripe(sub, 'user_test_123');
|
|
const call = vi.mocked(prisma.subscription.upsert).mock.calls[0][0];
|
|
expect(call.create.cancelAtPeriodEnd).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('handleSubscriptionDeleted', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('marks subscription as CANCELED when found', async () => {
|
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
|
|
id: 'local_id',
|
|
stripeSubscriptionId: 'sub_test_123',
|
|
} as any);
|
|
|
|
const sub = makeSubscription({ status: 'canceled', canceled_at: 1700100000 });
|
|
await handleSubscriptionDeleted(sub);
|
|
|
|
expect(prisma.subscription.update).toHaveBeenCalledOnce();
|
|
const call = vi.mocked(prisma.subscription.update).mock.calls[0][0];
|
|
expect(call.data.status).toBe(SubscriptionStatus.CANCELED);
|
|
expect(call.data.cancelAtPeriodEnd).toBe(false);
|
|
});
|
|
|
|
it('does nothing when subscription not found locally', async () => {
|
|
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
|
|
const sub = makeSubscription({ status: 'canceled' });
|
|
await handleSubscriptionDeleted(sub);
|
|
expect(prisma.subscription.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|