refactor(stripe): extract + unit-test webhook decision logic (R2)
The webhook's money/status mapping (Basil field relocations, period extraction, customer-id unwrap, status patch, invoice->payment row, minor->major) lived inline in the route with zero test coverage - the biggest untested critical path in the codebase. Extracted to a pure, SDK-free lib/stripe/webhook-logic.ts (structural input types) and refactored the route to only do signature verify + network + DB writes. 12 new tests cover the Basil edges and link/unlink + defaults. Behavior unchanged; suite 514.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
isoFromUnix,
|
||||
customerIdOf,
|
||||
subscriptionPeriodEnd,
|
||||
invoiceMetadataMemberId,
|
||||
invoiceLinePeriodEnd,
|
||||
memberStatusPatch,
|
||||
stripeInvoiceToPaymentRow,
|
||||
minorToMajor,
|
||||
} from '@/lib/stripe/webhook-logic'
|
||||
|
||||
describe('isoFromUnix', () => {
|
||||
it('converts a positive unix seconds value to ISO', () => {
|
||||
expect(isoFromUnix(1_700_000_000)).toBe(new Date(1_700_000_000_000).toISOString())
|
||||
})
|
||||
it('returns null for 0, negative, null, undefined, NaN', () => {
|
||||
for (const v of [0, -1, null, undefined, NaN]) {
|
||||
expect(isoFromUnix(v as number)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('customerIdOf', () => {
|
||||
it('handles string id, expanded object, empty, and nullish', () => {
|
||||
expect(customerIdOf('cus_1')).toBe('cus_1')
|
||||
expect(customerIdOf({ id: 'cus_2' })).toBe('cus_2')
|
||||
expect(customerIdOf('')).toBeNull()
|
||||
expect(customerIdOf(null)).toBeNull()
|
||||
expect(customerIdOf(undefined)).toBeNull()
|
||||
expect(customerIdOf({ id: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscriptionPeriodEnd (Basil: period on item, not subscription)', () => {
|
||||
it('reads item 0 current_period_end', () => {
|
||||
expect(subscriptionPeriodEnd({ items: { data: [{ current_period_end: 1_700_000_000 }] } })).toBe(
|
||||
new Date(1_700_000_000_000).toISOString(),
|
||||
)
|
||||
})
|
||||
it('null when no items / no field', () => {
|
||||
expect(subscriptionPeriodEnd({ items: { data: [] } })).toBeNull()
|
||||
expect(subscriptionPeriodEnd({})).toBeNull()
|
||||
expect(subscriptionPeriodEnd(null)).toBeNull()
|
||||
expect(subscriptionPeriodEnd({ items: { data: [null] } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('invoiceMetadataMemberId (Basil: under parent.subscription_details)', () => {
|
||||
it('reads the relocated metadata', () => {
|
||||
expect(
|
||||
invoiceMetadataMemberId({ parent: { subscription_details: { metadata: { member_id: 'm1' } } } }),
|
||||
).toBe('m1')
|
||||
})
|
||||
it('undefined when any level is missing or empty', () => {
|
||||
expect(invoiceMetadataMemberId({})).toBeUndefined()
|
||||
expect(invoiceMetadataMemberId({ parent: null })).toBeUndefined()
|
||||
expect(invoiceMetadataMemberId({ parent: { subscription_details: { metadata: { member_id: '' } } } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('invoiceLinePeriodEnd', () => {
|
||||
it('reads line 0 period end, null when absent', () => {
|
||||
expect(invoiceLinePeriodEnd({ lines: { data: [{ period: { end: 1_700_000_000 } }] } })).toBe(
|
||||
new Date(1_700_000_000_000).toISOString(),
|
||||
)
|
||||
expect(invoiceLinePeriodEnd({ lines: { data: [] } })).toBeNull()
|
||||
expect(invoiceLinePeriodEnd({ lines: null })).toBeNull()
|
||||
expect(invoiceLinePeriodEnd({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memberStatusPatch (only current<->late)', () => {
|
||||
it('current adds last_paid_at; late does not; null/undefined -> no patch', () => {
|
||||
expect(memberStatusPatch('current', '2026-01-01T00:00:00.000Z')).toEqual({
|
||||
status: 'current',
|
||||
last_paid_at: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
expect(memberStatusPatch('late', '2026-01-01T00:00:00.000Z')).toEqual({ status: 'late' })
|
||||
expect(memberStatusPatch(null, 'x')).toBeNull()
|
||||
expect(memberStatusPatch(undefined, 'x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripeInvoiceToPaymentRow', () => {
|
||||
it('maps minor->major, upcases currency, links by member presence', () => {
|
||||
const row = stripeInvoiceToPaymentRow({
|
||||
inv: { id: 'in_1', amount_paid: 2500, currency: 'usd', customer_email: '[email protected]' },
|
||||
spaceId: 's1',
|
||||
memberId: 'm1',
|
||||
eventId: 'evt_1',
|
||||
nowIso: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
expect(row).toMatchObject({
|
||||
space_id: 's1',
|
||||
member_id: 'm1',
|
||||
platform: 'stripe',
|
||||
amount: 25,
|
||||
currency: 'USD',
|
||||
status: 'linked',
|
||||
link_status: 'linked',
|
||||
external_id: 'in_1',
|
||||
payer_email: '[email protected]',
|
||||
from_identifier: '[email protected]',
|
||||
transaction_date: '2026-01-01T00:00:00.000Z',
|
||||
raw_data: { event_id: 'evt_1', invoice: 'in_1' },
|
||||
})
|
||||
})
|
||||
it('unlinked + safe defaults when member/amount/currency/email missing', () => {
|
||||
const row = stripeInvoiceToPaymentRow({
|
||||
inv: { id: 'in_2' },
|
||||
spaceId: 's1',
|
||||
memberId: null,
|
||||
eventId: 'evt_2',
|
||||
nowIso: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
expect(row.status).toBe('unlinked')
|
||||
expect(row.link_status).toBe('unlinked')
|
||||
expect(row.amount).toBe(0)
|
||||
expect(row.currency).toBe('USD')
|
||||
expect(row.description).toBe('Stripe membership dues')
|
||||
expect(row.payer_email).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('minorToMajor', () => {
|
||||
it('divides by 100, treats nullish as 0', () => {
|
||||
expect(minorToMajor(2500)).toBe(25)
|
||||
expect(minorToMajor(null)).toBe(0)
|
||||
expect(minorToMajor(undefined)).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user