Skip to content

Commit 9da4270

Browse files
committed
fix(billing): complete the chargeback + overage financial trail
Address Cursor review: - handleDisputeClosed now records CHARGE_DISPUTE_CLOSED for every closed dispute (won/lost/warning_closed), unblocking only on favorable outcomes. dispute.status in the metadata distinguishes the outcome, so lost chargebacks are no longer missing from the trail. - Threshold overage now emits OVERAGE_BILLED + overage_billed even when credits fully cover the overage (settledVia: 'credits' vs 'stripe'), so credit-settled overages are audited instead of silently returning null.
1 parent b19912f commit 9da4270

3 files changed

Lines changed: 61 additions & 33 deletions

File tree

apps/sim/lib/billing/threshold-billing.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,13 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
121121
const totalOverageCents = Math.round(currentOverage * 100)
122122

123123
const billedResult = await db.transaction(
124-
async (tx): Promise<{ amount: number; creditsApplied: number } | null> => {
124+
async (
125+
tx
126+
): Promise<{
127+
amount: number
128+
creditsApplied: number
129+
settledVia: 'stripe' | 'credits'
130+
} | null> => {
125131
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
126132

127133
const statsRecords = await tx
@@ -200,7 +206,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
200206
creditsApplied,
201207
unbilledOverage,
202208
})
203-
return null
209+
return { amount: unbilledOverage, creditsApplied, settledVia: 'credits' }
204210
}
205211

206212
const amountCents = Math.round(amountToBill * 100)
@@ -240,25 +246,27 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
240246
newBilledTotal: billedOverageThisPeriod + unbilledOverage,
241247
})
242248

243-
return { amount: amountToBill, creditsApplied }
249+
return { amount: amountToBill, creditsApplied, settledVia: 'stripe' }
244250
}
245251
)
246252

247253
if (billedResult) {
248-
const { amount, creditsApplied } = billedResult
254+
const { amount, creditsApplied, settledVia } = billedResult
255+
const settledLabel = settledVia === 'credits' ? 'covered by credits' : 'billed'
249256
recordAudit({
250257
actorId: userId,
251258
action: AuditAction.OVERAGE_BILLED,
252259
resourceType: AuditResourceType.BILLING,
253260
resourceId: userSubscription.id,
254-
description: `Overage of $${amount.toFixed(2)} billed for user ${userId}`,
261+
description: `Overage of $${amount.toFixed(2)} ${settledLabel} for user ${userId}`,
255262
metadata: {
256263
entityType: 'user',
257264
referenceId: userId,
258265
plan: userSubscription.plan,
259266
amount,
260267
currency: 'usd',
261268
creditsApplied,
269+
settledVia,
262270
billingPeriod,
263271
},
264272
})
@@ -267,6 +275,7 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
267275
currency: 'usd',
268276
entity_type: 'user',
269277
reference_id: userId,
278+
settled_via: settledVia,
270279
})
271280
}
272281
} catch (error) {
@@ -405,7 +414,14 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string):
405414
const totalOverageCents = Math.round(currentOverage * 100)
406415

407416
const orgBilledResult = await db.transaction(
408-
async (tx): Promise<{ amount: number; creditsApplied: number; ownerId: string } | null> => {
417+
async (
418+
tx
419+
): Promise<{
420+
amount: number
421+
creditsApplied: number
422+
ownerId: string
423+
settledVia: 'stripe' | 'credits'
424+
} | null> => {
409425
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
410426

411427
const lockedOwnerRows = await tx
@@ -529,7 +545,12 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string):
529545
creditsApplied,
530546
unbilledOverage,
531547
})
532-
return null
548+
return {
549+
amount: unbilledOverage,
550+
creditsApplied,
551+
ownerId: lockedOwnerId,
552+
settledVia: 'credits',
553+
}
533554
}
534555

535556
const amountCents = Math.round(amountToBill * 100)
@@ -570,25 +591,32 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string):
570591
billingPeriod,
571592
})
572593

573-
return { amount: amountToBill, creditsApplied, ownerId: lockedOwnerId }
594+
return {
595+
amount: amountToBill,
596+
creditsApplied,
597+
ownerId: lockedOwnerId,
598+
settledVia: 'stripe',
599+
}
574600
}
575601
)
576602

577603
if (orgBilledResult) {
578-
const { amount, creditsApplied, ownerId } = orgBilledResult
604+
const { amount, creditsApplied, ownerId, settledVia } = orgBilledResult
605+
const settledLabel = settledVia === 'credits' ? 'covered by credits' : 'billed'
579606
recordAudit({
580607
actorId: ownerId,
581608
action: AuditAction.OVERAGE_BILLED,
582609
resourceType: AuditResourceType.BILLING,
583610
resourceId: orgSubscription.id,
584-
description: `Overage of $${amount.toFixed(2)} billed for organization ${organizationId}`,
611+
description: `Overage of $${amount.toFixed(2)} ${settledLabel} for organization ${organizationId}`,
585612
metadata: {
586613
entityType: 'organization',
587614
referenceId: organizationId,
588615
plan: orgSubscription.plan,
589616
amount,
590617
currency: 'usd',
591618
creditsApplied,
619+
settledVia,
592620
billingPeriod,
593621
},
594622
})
@@ -597,6 +625,7 @@ async function checkAndBillOrganizationOverageThreshold(organizationId: string):
597625
currency: 'usd',
598626
entity_type: 'organization',
599627
reference_id: organizationId,
628+
settled_via: settledVia,
600629
})
601630
}
602631
} catch (error) {

apps/sim/lib/billing/webhooks/disputes.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -147,39 +147,37 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
147147
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
148148
const dispute = event.data.object as Stripe.Dispute
149149

150-
// Only unblock if we won or the warning was closed without a full dispute
151-
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
152-
153-
if (!shouldUnblock) {
154-
logger.info('Dispute resolved against us, user remains blocked', {
155-
disputeId: dispute.id,
156-
status: dispute.status,
157-
})
158-
return
159-
}
160-
161150
const customerId = await getCustomerIdFromDispute(dispute)
162151
if (!customerId) {
163152
return
164153
}
165154

166-
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
155+
// Unblock only when we won or the warning closed without a full dispute; a
156+
// 'lost' dispute keeps the customer blocked (they owe us). The close is
157+
// audited in every case so the chargeback trail is complete — `dispute.status`
158+
// in the metadata distinguishes the outcome.
159+
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
160+
167161
const users = await db
168162
.select({ id: user.id })
169163
.from(user)
170164
.where(eq(user.stripeCustomerId, customerId))
171165
.limit(1)
172166

173167
if (users.length > 0) {
174-
await db
175-
.update(userStats)
176-
.set({ billingBlocked: false, billingBlockedReason: null })
177-
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
178-
179-
logger.info('Unblocked user after dispute resolved in our favor', {
168+
if (shouldUnblock) {
169+
await db
170+
.update(userStats)
171+
.set({ billingBlocked: false, billingBlockedReason: null })
172+
.where(
173+
and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute'))
174+
)
175+
}
176+
logger.info('Dispute closed for user', {
180177
disputeId: dispute.id,
181178
userId: users[0].id,
182179
status: dispute.status,
180+
unblocked: shouldUnblock,
183181
})
184182

185183
recordDisputeInstrumentation('closed', dispute, customerId, users[0].id, {
@@ -189,7 +187,6 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
189187
return
190188
}
191189

192-
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
193190
const subs = await db
194191
.select({ referenceId: subscription.referenceId })
195192
.from(subscription)
@@ -198,13 +195,14 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
198195

199196
if (subs.length > 0) {
200197
const orgId = subs[0].referenceId
201-
const memberCount = await unblockOrgMembers(orgId, 'dispute')
202-
203-
logger.info('Unblocked all org members after dispute resolved in our favor', {
198+
if (shouldUnblock) {
199+
await unblockOrgMembers(orgId, 'dispute')
200+
}
201+
logger.info('Dispute closed for organization', {
204202
disputeId: dispute.id,
205203
organizationId: orgId,
206-
memberCount,
207204
status: dispute.status,
205+
unblocked: shouldUnblock,
208206
})
209207

210208
const actorId = (await getOrganizationOwnerId(orgId)) ?? orgId

apps/sim/lib/posthog/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@ export interface PostHogEventMap {
743743
currency: string
744744
entity_type: 'user' | 'organization'
745745
reference_id: string
746+
settled_via: 'stripe' | 'credits'
746747
}
747748

748749
credits_purchased: {

0 commit comments

Comments
 (0)