Skip to content

Commit 10341ae

Browse files
fix(billing): unblock on payment success (#4121)
1 parent 6ef40c5 commit 10341ae

File tree

2 files changed

+398
-90
lines changed

2 files changed

+398
-90
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type Stripe from 'stripe'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockBlockOrgMembers, mockDbSelect, mockLogger, mockUnblockOrgMembers, selectResponses } =
8+
vi.hoisted(() => {
9+
const selectResponses: Array<{ limitResult?: unknown; whereResult?: unknown }> = []
10+
const mockDbSelect = vi.fn(() => {
11+
const nextResponse = selectResponses.shift()
12+
13+
if (!nextResponse) {
14+
throw new Error('No queued db.select response')
15+
}
16+
17+
const builder = {
18+
from: vi.fn(() => builder),
19+
where: vi.fn(() => builder),
20+
limit: vi.fn(async () => nextResponse.limitResult ?? nextResponse.whereResult ?? []),
21+
then: (resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) =>
22+
Promise.resolve(nextResponse.whereResult ?? nextResponse.limitResult ?? []).then(
23+
resolve,
24+
reject
25+
),
26+
}
27+
28+
return builder
29+
})
30+
31+
return {
32+
mockBlockOrgMembers: vi.fn(),
33+
mockDbSelect,
34+
mockLogger: {
35+
debug: vi.fn(),
36+
error: vi.fn(),
37+
info: vi.fn(),
38+
warn: vi.fn(),
39+
},
40+
mockUnblockOrgMembers: vi.fn(),
41+
selectResponses,
42+
}
43+
})
44+
45+
vi.mock('@sim/db', () => ({
46+
db: {
47+
select: mockDbSelect,
48+
},
49+
}))
50+
51+
vi.mock('@sim/db/schema', () => ({
52+
member: {
53+
organizationId: 'member.organizationId',
54+
role: 'member.role',
55+
userId: 'member.userId',
56+
},
57+
organization: {},
58+
subscription: {
59+
referenceId: 'subscription.referenceId',
60+
stripeSubscriptionId: 'subscription.stripeSubscriptionId',
61+
},
62+
user: {
63+
email: 'user.email',
64+
id: 'user.id',
65+
name: 'user.name',
66+
},
67+
userStats: {
68+
billingBlocked: 'userStats.billingBlocked',
69+
billingBlockedReason: 'userStats.billingBlockedReason',
70+
userId: 'userStats.userId',
71+
},
72+
}))
73+
74+
vi.mock('@sim/logger', () => ({
75+
createLogger: vi.fn(() => mockLogger),
76+
}))
77+
78+
vi.mock('drizzle-orm', () => ({
79+
and: vi.fn(() => 'and'),
80+
eq: vi.fn(() => 'eq'),
81+
inArray: vi.fn(() => 'inArray'),
82+
isNull: vi.fn(() => 'isNull'),
83+
ne: vi.fn(() => 'ne'),
84+
or: vi.fn(() => 'or'),
85+
}))
86+
87+
vi.mock('@/components/emails', () => ({
88+
PaymentFailedEmail: vi.fn(),
89+
getEmailSubject: vi.fn(),
90+
renderCreditPurchaseEmail: vi.fn(),
91+
}))
92+
93+
vi.mock('@/lib/billing/core/billing', () => ({
94+
calculateSubscriptionOverage: vi.fn(),
95+
}))
96+
97+
vi.mock('@/lib/billing/credits/balance', () => ({
98+
addCredits: vi.fn(),
99+
getCreditBalance: vi.fn(),
100+
removeCredits: vi.fn(),
101+
}))
102+
103+
vi.mock('@/lib/billing/credits/purchase', () => ({
104+
setUsageLimitForCredits: vi.fn(),
105+
}))
106+
107+
vi.mock('@/lib/billing/organizations/membership', () => ({
108+
blockOrgMembers: mockBlockOrgMembers,
109+
unblockOrgMembers: mockUnblockOrgMembers,
110+
}))
111+
112+
vi.mock('@/lib/billing/plan-helpers', () => ({
113+
isEnterprise: vi.fn(() => false),
114+
isOrgPlan: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
115+
isTeam: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
116+
}))
117+
118+
vi.mock('@/lib/billing/stripe-client', () => ({
119+
requireStripeClient: vi.fn(),
120+
}))
121+
122+
vi.mock('@/lib/core/utils/urls', () => ({
123+
getBaseUrl: vi.fn(() => 'https://sim.test'),
124+
}))
125+
126+
vi.mock('@/lib/messaging/email/mailer', () => ({
127+
sendEmail: vi.fn(),
128+
}))
129+
130+
vi.mock('@/lib/messaging/email/utils', () => ({
131+
getPersonalEmailFrom: vi.fn(() => ({
132+
from: 'billing@sim.test',
133+
replyTo: 'support@sim.test',
134+
})),
135+
}))
136+
137+
vi.mock('@/lib/messaging/email/validation', () => ({
138+
quickValidateEmail: vi.fn(() => ({ isValid: true })),
139+
}))
140+
141+
vi.mock('@react-email/render', () => ({
142+
render: vi.fn(),
143+
}))
144+
145+
import { handleInvoicePaymentFailed, handleInvoicePaymentSucceeded } from './invoices'
146+
147+
function queueSelectResponse(response: { limitResult?: unknown; whereResult?: unknown }) {
148+
selectResponses.push(response)
149+
}
150+
151+
function createInvoiceEvent(
152+
type: 'invoice.payment_failed' | 'invoice.payment_succeeded',
153+
invoice: Partial<Stripe.Invoice>
154+
): Stripe.Event {
155+
return {
156+
data: {
157+
object: invoice as Stripe.Invoice,
158+
},
159+
id: `evt_${type}`,
160+
type,
161+
} as Stripe.Event
162+
}
163+
164+
describe('invoice billing recovery', () => {
165+
beforeEach(() => {
166+
vi.clearAllMocks()
167+
selectResponses.length = 0
168+
mockBlockOrgMembers.mockResolvedValue(2)
169+
mockUnblockOrgMembers.mockResolvedValue(2)
170+
})
171+
172+
it('blocks org members when a metadata-backed invoice payment fails', async () => {
173+
queueSelectResponse({
174+
limitResult: [
175+
{
176+
id: 'sub-db-1',
177+
plan: 'team_8000',
178+
referenceId: 'org-1',
179+
stripeSubscriptionId: 'sub_stripe_1',
180+
},
181+
],
182+
})
183+
184+
await handleInvoicePaymentFailed(
185+
createInvoiceEvent('invoice.payment_failed', {
186+
amount_due: 3582,
187+
attempt_count: 2,
188+
customer: 'cus_123',
189+
customer_email: 'owner@sim.test',
190+
hosted_invoice_url: 'https://stripe.test/invoices/in_123',
191+
id: 'in_123',
192+
metadata: {
193+
billingPeriod: '2026-04',
194+
subscriptionId: 'sub_stripe_1',
195+
type: 'overage_threshold_billing_org',
196+
},
197+
})
198+
)
199+
200+
expect(mockBlockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
201+
expect(mockUnblockOrgMembers).not.toHaveBeenCalled()
202+
})
203+
204+
it('unblocks org members when the matching metadata-backed invoice payment succeeds', async () => {
205+
queueSelectResponse({
206+
limitResult: [
207+
{
208+
id: 'sub-db-1',
209+
plan: 'team_8000',
210+
referenceId: 'org-1',
211+
stripeSubscriptionId: 'sub_stripe_1',
212+
},
213+
],
214+
})
215+
queueSelectResponse({
216+
whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }],
217+
})
218+
queueSelectResponse({
219+
whereResult: [{ blocked: false }, { blocked: false }],
220+
})
221+
222+
await handleInvoicePaymentSucceeded(
223+
createInvoiceEvent('invoice.payment_succeeded', {
224+
amount_paid: 3582,
225+
billing_reason: 'manual',
226+
customer: 'cus_123',
227+
id: 'in_123',
228+
metadata: {
229+
billingPeriod: '2026-04',
230+
subscriptionId: 'sub_stripe_1',
231+
type: 'overage_threshold_billing_org',
232+
},
233+
})
234+
)
235+
236+
expect(mockUnblockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
237+
expect(mockBlockOrgMembers).not.toHaveBeenCalled()
238+
})
239+
})

0 commit comments

Comments
 (0)