722 lines
22 KiB
TypeScript
722 lines
22 KiB
TypeScript
import type { StripeCheckoutSession, StripeInvoice } from '../../schemas/stripe'
|
|
import type { ConfigKVService } from '../../services/adapters/config-kv'
|
|
import type { BillingService } from '../../services/domain/billing/billing-service'
|
|
import type { FluxService } from '../../services/domain/flux'
|
|
import type { StripeService } from '../../services/domain/stripe'
|
|
import type { HonoEnv } from '../../types/hono'
|
|
|
|
import { Hono } from 'hono'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import { createStripeRoutes, formatPrice } from '.'
|
|
import { createTestRedis } from '../../libs/tests/redis'
|
|
import { ApiError } from '../../utils/error'
|
|
import { createCheckoutOperation } from './operations/checkout'
|
|
import { createWebhookOperation } from './operations/webhook'
|
|
|
|
// --- Mock helpers ---
|
|
|
|
function createMockBillingService(): BillingService {
|
|
return {
|
|
creditFlux: vi.fn(),
|
|
creditFluxFromInvoice: vi.fn(async () => ({ applied: true, balanceAfter: 500 })),
|
|
creditFluxFromStripeCheckout: vi.fn(async () => ({ applied: true, balanceAfter: 500 })),
|
|
debitFlux: vi.fn(),
|
|
} as any
|
|
}
|
|
|
|
function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVService {
|
|
const defaults: Record<string, any> = {
|
|
STRIPE_FLUX_PRODUCT_ID: 'prod_test_flux',
|
|
STRIPE_PAYMENT_METHODS: ['card'],
|
|
...overrides,
|
|
}
|
|
return {
|
|
get: vi.fn(async (key: string) => defaults[key]),
|
|
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
|
|
getOrThrow: vi.fn(async (key: string) => {
|
|
if (defaults[key] === undefined)
|
|
throw new Error(`Config key "${key}" is not set`)
|
|
return defaults[key]
|
|
}),
|
|
set: vi.fn(),
|
|
} as any
|
|
}
|
|
|
|
function createMockFluxService(): FluxService {
|
|
return {
|
|
getFlux: vi.fn(async () => ({ flux: 100, userId: 'user-1' })),
|
|
updateStripeCustomerId: vi.fn(),
|
|
} as any
|
|
}
|
|
|
|
function createMockStripeCustomer(
|
|
overrides: Partial<NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>>> = {},
|
|
): NonNullable<Awaited<ReturnType<StripeService['getCustomerByStripeId']>>> {
|
|
const now = new Date()
|
|
return {
|
|
createdAt: now,
|
|
deletedAt: null,
|
|
email: null,
|
|
id: 'stripe-customer-1',
|
|
name: null,
|
|
stripeCustomerId: 'cus_1',
|
|
updatedAt: now,
|
|
userId: 'user-1',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function createMockStripeService(overrides: Partial<StripeService> = {}): StripeService {
|
|
return {
|
|
getActiveSubscription: vi.fn(async () => undefined),
|
|
getCheckoutSessionsByUserId: vi.fn(async () => []),
|
|
getCustomerByStripeId: vi.fn(async () => undefined),
|
|
getCustomerByUserId: vi.fn(async () => undefined),
|
|
getInvoicesByUserId: vi.fn(async () => []),
|
|
upsertCheckoutSession: vi.fn(async data => ({ createdAt: new Date(), fluxCredited: false, id: 'id-1', updatedAt: new Date(), ...data })),
|
|
upsertCustomer: vi.fn(async data => ({ createdAt: new Date(), id: 'id-1', updatedAt: new Date(), ...data })),
|
|
upsertInvoice: vi.fn(async data => ({ createdAt: new Date(), fluxCredited: false, id: 'id-1', updatedAt: new Date(), ...data })),
|
|
upsertSubscription: vi.fn(async data => ({ createdAt: new Date(), id: 'id-1', updatedAt: new Date(), ...data })),
|
|
...overrides,
|
|
} as any
|
|
}
|
|
|
|
const testEnv = {
|
|
API_SERVER_URL: 'http://localhost:8787',
|
|
STRIPE_SECRET_KEY: 'sk_test_fake',
|
|
STRIPE_WEBHOOK_SECRET: 'whsec_test_fake',
|
|
} as any
|
|
|
|
const testUser = { email: 'test@example.com', id: 'user-1', name: 'Test User' }
|
|
|
|
function createCheckoutSession(overrides: Partial<StripeCheckoutSession> = {}): StripeCheckoutSession {
|
|
return {
|
|
amountTotal: 500,
|
|
cancelUrl: 'http://localhost/cancel',
|
|
createdAt: new Date(),
|
|
currency: 'usd',
|
|
deletedAt: null,
|
|
expiresAt: null,
|
|
fluxCredited: false,
|
|
id: 'checkout-1',
|
|
metadata: null,
|
|
mode: 'payment',
|
|
paymentStatus: null,
|
|
status: 'open',
|
|
stripeCustomerId: null,
|
|
stripePaymentIntentId: null,
|
|
stripeSessionId: 'cs_1',
|
|
stripeSubscriptionId: null,
|
|
successUrl: 'http://localhost/success',
|
|
updatedAt: new Date(),
|
|
userId: 'user-1',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function createInvoice(overrides: Partial<StripeInvoice> = {}): StripeInvoice {
|
|
return {
|
|
amountDue: 500,
|
|
amountPaid: 500,
|
|
createdAt: new Date(),
|
|
currency: 'usd',
|
|
deletedAt: null,
|
|
fluxCredited: false,
|
|
id: 'invoice-1',
|
|
invoicePdf: null,
|
|
invoiceUrl: null,
|
|
metadata: null,
|
|
paidAt: null,
|
|
periodEnd: null,
|
|
periodStart: null,
|
|
status: 'paid',
|
|
stripeCustomerId: null,
|
|
stripeInvoiceId: 'inv_1',
|
|
stripeSubscriptionId: null,
|
|
updatedAt: new Date(),
|
|
userId: 'user-1',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function createTestApp(
|
|
fluxService: FluxService,
|
|
stripeService: StripeService,
|
|
billingService: BillingService,
|
|
configKV: ConfigKVService,
|
|
envOverrides: Record<string, any> = {},
|
|
) {
|
|
const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, { ...testEnv, ...envOverrides }, createTestRedis())
|
|
const app = new Hono<HonoEnv>()
|
|
|
|
app.onError((err, c) => {
|
|
if (err instanceof ApiError) {
|
|
return c.json({
|
|
details: err.details,
|
|
error: err.errorCode,
|
|
message: err.message,
|
|
}, err.statusCode)
|
|
}
|
|
return c.json({ error: 'Internal Server Error', message: err.message }, 500)
|
|
})
|
|
|
|
// Inject user from env (simulates sessionMiddleware)
|
|
app.use('*', async (c, next) => {
|
|
const user = (c.env as any)?.user
|
|
if (user) {
|
|
c.set('user', user)
|
|
}
|
|
await next()
|
|
})
|
|
|
|
app.route('/api/v1/stripe', routes)
|
|
return app
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
describe('formatPrice', () => {
|
|
it('formats USD cents correctly', () => {
|
|
expect(formatPrice(300, 'usd')).toBe('$3.00')
|
|
expect(formatPrice(1200, 'usd')).toBe('$12.00')
|
|
expect(formatPrice(2500, 'usd')).toBe('$25.00')
|
|
})
|
|
|
|
it('formats CNY cents correctly', () => {
|
|
expect(formatPrice(2100, 'cny')).toBe('CN¥21.00')
|
|
})
|
|
|
|
it('formats JPY (zero-decimal currency) correctly', () => {
|
|
expect(formatPrice(500, 'jpy')).toBe('¥500')
|
|
})
|
|
|
|
it('formats GBP correctly', () => {
|
|
expect(formatPrice(1599, 'gbp')).toBe('£15.99')
|
|
})
|
|
|
|
it('returns currency code for null amount', () => {
|
|
expect(formatPrice(null, 'usd')).toBe('USD')
|
|
})
|
|
|
|
it('handles zero amount', () => {
|
|
expect(formatPrice(0, 'usd')).toBe('$0.00')
|
|
})
|
|
})
|
|
|
|
describe('stripeRoutes', () => {
|
|
describe('gET /api/v1/stripe/packages', () => {
|
|
it('returns empty array when Stripe is not configured', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV({ STRIPE_FLUX_PRODUCT_ID: undefined }),
|
|
{ STRIPE_SECRET_KEY: '' },
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/packages')
|
|
expect(res.status).toBe(200)
|
|
expect(await res.json()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('pOST /api/v1/stripe/checkout', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/checkout', {
|
|
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
method: 'POST',
|
|
})
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 for empty stripePriceId', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/v1/stripe/checkout', {
|
|
body: JSON.stringify({ stripePriceId: '' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
method: 'POST',
|
|
}),
|
|
{ user: testUser } as any,
|
|
)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 400 for missing stripePriceId', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/v1/stripe/checkout', {
|
|
body: JSON.stringify({}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
method: 'POST',
|
|
}),
|
|
{ user: testUser } as any,
|
|
)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 503 when Stripe is not configured', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV({ STRIPE_FLUX_PRODUCT_ID: undefined }),
|
|
{ STRIPE_SECRET_KEY: '' },
|
|
)
|
|
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/v1/stripe/checkout', {
|
|
body: JSON.stringify({ stripePriceId: 'price_test_500' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
method: 'POST',
|
|
}),
|
|
{ user: testUser } as any,
|
|
)
|
|
expect(res.status).toBe(503)
|
|
})
|
|
|
|
it('stores browser PostHog identity in Stripe checkout metadata', async () => {
|
|
const createSession = vi.fn(async input => ({
|
|
amount_total: 500,
|
|
cancel_url: 'http://localhost/settings/flux?canceled=true',
|
|
currency: 'usd',
|
|
customer: null,
|
|
expires_at: null,
|
|
id: 'cs_1',
|
|
metadata: input.metadata,
|
|
mode: 'payment',
|
|
payment_intent: null,
|
|
payment_status: 'unpaid',
|
|
status: 'open',
|
|
subscription: null,
|
|
success_url: 'http://localhost/settings/flux?success=true',
|
|
url: 'https://checkout.stripe.com/cs_1',
|
|
}))
|
|
const productEventService = { track: vi.fn() }
|
|
const operation = createCheckoutOperation({
|
|
configKV: createMockConfigKV({ STRIPE_PAYMENT_METHODS: undefined }),
|
|
env: testEnv,
|
|
priceCatalog: {
|
|
findActivePrice: vi.fn(async () => ({
|
|
currency: 'usd',
|
|
currencyOptions: {},
|
|
id: 'price_test_500',
|
|
metadata: { fluxAmount: '500' },
|
|
unitAmount: 500,
|
|
})),
|
|
getActivePrices: vi.fn(),
|
|
} as any,
|
|
productEventService: productEventService as any,
|
|
stripe: {
|
|
checkout: {
|
|
sessions: {
|
|
create: createSession,
|
|
},
|
|
},
|
|
} as any,
|
|
stripeService: createMockStripeService(),
|
|
})
|
|
|
|
await operation({
|
|
body: { stripePriceId: 'price_test_500' },
|
|
request: new Request('http://localhost/api/v1/stripe/checkout', {
|
|
headers: {
|
|
'x-posthog-distinct-id': 'anon-browser-1',
|
|
'x-posthog-session-id': 'ph-session-1',
|
|
},
|
|
}),
|
|
user: testUser as any,
|
|
})
|
|
|
|
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
|
|
metadata: {
|
|
fluxAmount: '500',
|
|
posthogDistinctId: 'anon-browser-1',
|
|
posthogSessionId: 'ph-session-1',
|
|
userId: 'user-1',
|
|
},
|
|
}))
|
|
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
|
|
action: 'checkout_started',
|
|
metadata: expect.objectContaining({
|
|
posthog_distinct_id: 'anon-browser-1',
|
|
posthog_session_id: 'ph-session-1',
|
|
}),
|
|
userId: 'user-1',
|
|
}))
|
|
})
|
|
})
|
|
|
|
describe('gET /api/v1/stripe/orders', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/orders')
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns checkout sessions for the authenticated user', async () => {
|
|
const mockSessions = [
|
|
createCheckoutSession({ id: '1', status: 'complete', stripeSessionId: 'cs_1' }),
|
|
createCheckoutSession({ id: '2', status: 'open', stripeSessionId: 'cs_2' }),
|
|
]
|
|
const stripeService = createMockStripeService({
|
|
getCheckoutSessionsByUserId: vi.fn(async () => mockSessions),
|
|
})
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
stripeService,
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/v1/stripe/orders'),
|
|
{ user: testUser } as any,
|
|
)
|
|
expect(res.status).toBe(200)
|
|
|
|
const data = await res.json()
|
|
expect(data).toHaveLength(2)
|
|
expect(stripeService.getCheckoutSessionsByUserId).toHaveBeenCalledWith('user-1')
|
|
})
|
|
})
|
|
|
|
describe('gET /api/v1/stripe/invoices', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/invoices')
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns invoices for the authenticated user', async () => {
|
|
const mockInvoices = [createInvoice({ id: '1', status: 'paid', stripeInvoiceId: 'inv_1' })]
|
|
const stripeService = createMockStripeService({
|
|
getInvoicesByUserId: vi.fn(async () => mockInvoices),
|
|
})
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
stripeService,
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/v1/stripe/invoices'),
|
|
{ user: testUser } as any,
|
|
)
|
|
expect(res.status).toBe(200)
|
|
|
|
const data = await res.json()
|
|
expect(data).toHaveLength(1)
|
|
expect(stripeService.getInvoicesByUserId).toHaveBeenCalledWith('user-1')
|
|
})
|
|
})
|
|
|
|
describe('pOST /api/v1/stripe/portal', () => {
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/portal', { method: 'POST' })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when user has no billing account', async () => {
|
|
const stripeService = createMockStripeService({
|
|
getCustomerByUserId: vi.fn(async () => undefined),
|
|
})
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
stripeService,
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.fetch(
|
|
new Request('http://localhost/api/v1/stripe/portal', { method: 'POST' }),
|
|
{ user: testUser } as any,
|
|
)
|
|
expect(res.status).toBe(400)
|
|
|
|
const data = await res.json() as any
|
|
expect(data.error).toBe('NO_CUSTOMER')
|
|
})
|
|
})
|
|
|
|
describe('pOST /api/v1/stripe/webhook', () => {
|
|
it('returns 400 when signature is missing', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/webhook', {
|
|
body: '{}',
|
|
method: 'POST',
|
|
})
|
|
expect(res.status).toBe(400)
|
|
|
|
const data = await res.json() as any
|
|
expect(data.error).toBe('MISSING_SIGNATURE')
|
|
})
|
|
|
|
it('returns 400 when signature is invalid', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/webhook', {
|
|
body: '{}',
|
|
headers: { 'stripe-signature': 'invalid_sig' },
|
|
method: 'POST',
|
|
})
|
|
expect(res.status).toBe(400)
|
|
|
|
const data = await res.json() as any
|
|
expect(data.error).toBe('WEBHOOK_ERROR')
|
|
})
|
|
|
|
it('returns 503 when Stripe is not configured', async () => {
|
|
const app = createTestApp(
|
|
createMockFluxService(),
|
|
createMockStripeService(),
|
|
createMockBillingService(),
|
|
createMockConfigKV(),
|
|
{ STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '' },
|
|
)
|
|
|
|
const res = await app.request('/api/v1/stripe/webhook', {
|
|
body: '{}',
|
|
headers: { 'stripe-signature': 'test_sig' },
|
|
method: 'POST',
|
|
})
|
|
expect(res.status).toBe(503)
|
|
})
|
|
|
|
it('records payment completion with Stripe and PostHog identity from checkout metadata', async () => {
|
|
const checkoutEvent = {
|
|
data: {
|
|
object: {
|
|
amount_total: 500,
|
|
cancel_url: 'http://localhost/settings/flux?canceled=true',
|
|
currency: 'usd',
|
|
customer: 'cus_1',
|
|
customer_email: 'test@example.com',
|
|
expires_at: null,
|
|
id: 'cs_1',
|
|
metadata: {
|
|
fluxAmount: '500',
|
|
posthogDistinctId: 'anon-browser-1',
|
|
posthogSessionId: 'ph-session-1',
|
|
userId: 'user-1',
|
|
},
|
|
mode: 'payment',
|
|
payment_intent: 'pi_1',
|
|
payment_status: 'paid',
|
|
status: 'complete',
|
|
subscription: null,
|
|
success_url: 'http://localhost/settings/flux?success=true',
|
|
},
|
|
},
|
|
id: 'evt_checkout_completed',
|
|
type: 'checkout.session.completed',
|
|
}
|
|
const productEventService = { track: vi.fn() }
|
|
const billingService = createMockBillingService()
|
|
const webhook = createWebhookOperation({
|
|
billingService,
|
|
fluxService: createMockFluxService(),
|
|
productEventService: productEventService as any,
|
|
stripe: {
|
|
webhooks: {
|
|
constructEvent: vi.fn(() => checkoutEvent),
|
|
},
|
|
} as any,
|
|
stripeService: createMockStripeService(),
|
|
webhookSecret: 'whsec_test',
|
|
})
|
|
|
|
await webhook({ body: '{}', signature: 'test_sig' })
|
|
|
|
expect(billingService.creditFluxFromStripeCheckout).toHaveBeenCalledWith(expect.objectContaining({
|
|
fluxAmount: 500,
|
|
stripeEventId: 'evt_checkout_completed',
|
|
stripeSessionId: 'cs_1',
|
|
userId: 'user-1',
|
|
}))
|
|
expect(productEventService.track).toHaveBeenCalledWith({
|
|
action: 'payment_completed',
|
|
eventId: 'cs_1',
|
|
feature: 'billing',
|
|
metadata: {
|
|
amount_total: 500,
|
|
currency: 'usd',
|
|
flux_amount: 500,
|
|
posthog_distinct_id: 'anon-browser-1',
|
|
posthog_session_id: 'ph-session-1',
|
|
stripe_checkout_session_id: 'cs_1',
|
|
stripe_customer_id: 'cus_1',
|
|
},
|
|
source: 'stripe.webhook',
|
|
status: 'succeeded',
|
|
userId: 'user-1',
|
|
})
|
|
})
|
|
|
|
it('processes subscription lifecycle webhooks without product events', async () => {
|
|
const subscriptionEvent = {
|
|
data: {
|
|
object: {
|
|
cancel_at_period_end: false,
|
|
canceled_at: null,
|
|
customer: 'cus_1',
|
|
ended_at: null,
|
|
id: 'sub_1',
|
|
items: {
|
|
data: [{
|
|
current_period_end: 2_000,
|
|
current_period_start: 1_000,
|
|
price: { id: 'price_1' },
|
|
}],
|
|
},
|
|
metadata: {},
|
|
status: 'active',
|
|
},
|
|
},
|
|
id: 'evt_sub_created',
|
|
type: 'customer.subscription.created',
|
|
}
|
|
const stripeService = createMockStripeService({
|
|
getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()),
|
|
})
|
|
const productEventService = { track: vi.fn(async () => undefined) }
|
|
const webhook = createWebhookOperation({
|
|
billingService: createMockBillingService(),
|
|
fluxService: createMockFluxService(),
|
|
productEventService: productEventService as any,
|
|
stripe: {
|
|
webhooks: {
|
|
constructEvent: vi.fn(() => subscriptionEvent),
|
|
},
|
|
} as any,
|
|
stripeService,
|
|
webhookSecret: 'whsec_test',
|
|
})
|
|
|
|
await webhook({ body: '{}', signature: 'test_sig' })
|
|
|
|
expect(stripeService.upsertSubscription).toHaveBeenCalledWith(expect.objectContaining({
|
|
cancelAtPeriodEnd: false,
|
|
status: 'active',
|
|
stripeCustomerId: 'cus_1',
|
|
stripePriceId: 'price_1',
|
|
stripeSubscriptionId: 'sub_1',
|
|
userId: 'user-1',
|
|
}))
|
|
expect(productEventService.track).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('records subscription renewals only for subscription-cycle paid invoices', async () => {
|
|
const invoiceEvent = {
|
|
data: {
|
|
object: {
|
|
amount_due: 1_200,
|
|
amount_paid: 1_200,
|
|
billing_reason: 'subscription_cycle',
|
|
currency: 'usd',
|
|
customer: 'cus_1',
|
|
hosted_invoice_url: null,
|
|
id: 'inv_1',
|
|
invoice_pdf: null,
|
|
metadata: {},
|
|
parent: {
|
|
subscription_details: {
|
|
subscription: 'sub_1',
|
|
},
|
|
},
|
|
period_end: 2_000,
|
|
period_start: 1_000,
|
|
status: 'paid',
|
|
status_transitions: {
|
|
paid_at: 1_500,
|
|
},
|
|
},
|
|
},
|
|
id: 'evt_invoice_paid',
|
|
type: 'invoice.paid',
|
|
}
|
|
const stripeService = createMockStripeService({
|
|
getCustomerByStripeId: vi.fn(async () => createMockStripeCustomer()),
|
|
})
|
|
const productEventService = { track: vi.fn(async () => undefined) }
|
|
const webhook = createWebhookOperation({
|
|
billingService: createMockBillingService(),
|
|
fluxService: createMockFluxService(),
|
|
productEventService: productEventService as any,
|
|
stripe: {
|
|
webhooks: {
|
|
constructEvent: vi.fn(() => invoiceEvent),
|
|
},
|
|
} as any,
|
|
stripeService,
|
|
webhookSecret: 'whsec_test',
|
|
})
|
|
|
|
await webhook({ body: '{}', signature: 'test_sig' })
|
|
|
|
expect(stripeService.upsertInvoice).toHaveBeenCalledWith(expect.objectContaining({
|
|
amountDue: 1_200,
|
|
amountPaid: 1_200,
|
|
status: 'paid',
|
|
stripeCustomerId: 'cus_1',
|
|
stripeInvoiceId: 'inv_1',
|
|
stripeSubscriptionId: 'sub_1',
|
|
userId: 'user-1',
|
|
}))
|
|
expect(productEventService.track).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|