Skip to content

Commit 866f753

Browse files
committed
fix(stripe): synchronize EFW owner linking with deletion
1 parent 33eb8ec commit 866f753

2 files changed

Lines changed: 96 additions & 42 deletions

File tree

apps/web/src/lib/stripe/early-fraud-warning.ts

Lines changed: 48 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { and, eq, isNull, like, not, or } from 'drizzle-orm';
1515
import type Stripe from 'stripe';
1616

17-
import { db } from '@/lib/drizzle';
17+
import { db, type DrizzleTransaction } from '@/lib/drizzle';
1818
import { client } from '@/lib/stripe-client';
1919

2020
type StripeReference = string | { id: string } | null | undefined;
@@ -60,7 +60,10 @@ function stripeReferenceId(reference: StripeReference): string | null {
6060
return reference?.id || null;
6161
}
6262

63-
async function resolveOwner(customerId: string | null): Promise<OwnerResolution> {
63+
async function resolveOwner(
64+
database: DrizzleTransaction,
65+
customerId: string | null
66+
): Promise<OwnerResolution> {
6467
if (!customerId) {
6568
return {
6669
classification: StripeEarlyFraudWarningOwnerClassification.Unmatched,
@@ -70,28 +73,26 @@ async function resolveOwner(customerId: string | null): Promise<OwnerResolution>
7073
};
7174
}
7275

73-
const [personalOwners, organizationOwners] = await Promise.all([
74-
db
75-
.select({ id: kilocode_users.id })
76-
.from(kilocode_users)
77-
.where(
78-
and(
79-
eq(kilocode_users.stripe_customer_id, customerId),
80-
or(
81-
isNull(kilocode_users.blocked_reason),
82-
not(like(kilocode_users.blocked_reason, 'soft-deleted at %'))
83-
)
76+
// Keep the case insert ordered with softDeleteUser's link scrubbing for matched user rows.
77+
const personalOwners = await database
78+
.select({ id: kilocode_users.id })
79+
.from(kilocode_users)
80+
.where(
81+
and(
82+
eq(kilocode_users.stripe_customer_id, customerId),
83+
or(
84+
isNull(kilocode_users.blocked_reason),
85+
not(like(kilocode_users.blocked_reason, 'soft-deleted at %'))
8486
)
8587
)
86-
.limit(2),
87-
db
88-
.select({ id: organizations.id })
89-
.from(organizations)
90-
.where(
91-
and(eq(organizations.stripe_customer_id, customerId), isNull(organizations.deleted_at))
92-
)
93-
.limit(2),
94-
]);
88+
)
89+
.limit(2)
90+
.for('update');
91+
const organizationOwners = await database
92+
.select({ id: organizations.id })
93+
.from(organizations)
94+
.where(and(eq(organizations.stripe_customer_id, customerId), isNull(organizations.deleted_at)))
95+
.limit(2);
9596

9697
if (personalOwners.length === 1 && organizationOwners.length === 0) {
9798
return {
@@ -128,8 +129,11 @@ async function resolveOwner(customerId: string | null): Promise<OwnerResolution>
128129
};
129130
}
130131

131-
async function persistReviewCase(values: ReviewCaseValues): Promise<void> {
132-
await db
132+
async function persistReviewCase(
133+
database: typeof db | DrizzleTransaction,
134+
values: ReviewCaseValues
135+
): Promise<void> {
136+
await database
133137
.insert(stripe_early_fraud_warning_cases)
134138
.values({
135139
stripe_early_fraud_warning_id: values.earlyFraudWarningId,
@@ -165,7 +169,7 @@ export async function observeStripeEarlyFraudWarningCreated({
165169
const paymentIntentId = stripeReferenceId(earlyFraudWarning.payment_intent);
166170

167171
if (!chargeId) {
168-
await persistReviewCase({
172+
await persistReviewCase(db, {
169173
eventId,
170174
earlyFraudWarningId: earlyFraudWarning.id,
171175
chargeId: null,
@@ -196,7 +200,7 @@ export async function observeStripeEarlyFraudWarningCreated({
196200
stripe_charge_id: chargeId,
197201
},
198202
});
199-
await persistReviewCase({
203+
await persistReviewCase(db, {
200204
eventId,
201205
earlyFraudWarningId: earlyFraudWarning.id,
202206
chargeId,
@@ -216,22 +220,24 @@ export async function observeStripeEarlyFraudWarningCreated({
216220
return null;
217221
}
218222

219-
const owner = await resolveOwner(stripeReferenceId(charge.customer));
220-
await persistReviewCase({
221-
eventId,
222-
earlyFraudWarningId: earlyFraudWarning.id,
223-
chargeId,
224-
paymentIntentId: paymentIntentId ?? stripeReferenceId(charge.payment_intent),
225-
customerId: stripeReferenceId(charge.customer),
226-
amountMinorUnits: charge.amount,
227-
currency: charge.currency,
228-
owner: charge.disputed
229-
? {
230-
...owner,
231-
reason: 'Warned charge is already disputed; manual review required',
232-
}
233-
: owner,
234-
warningCreatedAt,
223+
await db.transaction(async tx => {
224+
const owner = await resolveOwner(tx, stripeReferenceId(charge.customer));
225+
await persistReviewCase(tx, {
226+
eventId,
227+
earlyFraudWarningId: earlyFraudWarning.id,
228+
chargeId,
229+
paymentIntentId: paymentIntentId ?? stripeReferenceId(charge.payment_intent),
230+
customerId: stripeReferenceId(charge.customer),
231+
amountMinorUnits: charge.amount,
232+
currency: charge.currency,
233+
owner: charge.disputed
234+
? {
235+
...owner,
236+
reason: 'Warned charge is already disputed; manual review required',
237+
}
238+
: owner,
239+
warningCreatedAt,
240+
});
235241
});
236242

237243
return charge;

apps/web/src/lib/stripe/index.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,54 @@ describe('processStripePaymentEventHook', () => {
920920
retrieveSpy.mockRestore();
921921
});
922922

923+
test('radar.early_fraud_warning.created does not link a case during concurrent soft deletion', async () => {
924+
await cleanupDbForTest();
925+
testUser = await insertTestUser();
926+
const { client } = await import('@/lib/stripe-client');
927+
const retrieveSpy = jest.spyOn(client.charges, 'retrieve').mockResolvedValue(
928+
sampleStripeChargeResponse(
929+
sampleStripeCharge({
930+
id: 'ch_deleting_customer',
931+
customer: testUser.stripe_customer_id,
932+
})
933+
)
934+
);
935+
let observationPromise: Promise<void> | null = null;
936+
937+
await db.transaction(async tx => {
938+
await tx
939+
.update(kilocode_users)
940+
.set({ blocked_reason: 'soft-deleted at 2026-05-28T12:00:00.000Z' })
941+
.where(eq(kilocode_users.id, testUser.id));
942+
observationPromise = processStripePaymentEventHook(
943+
sampleEarlyFraudWarningEvent({
944+
eventId: 'evt_deleting_customer',
945+
warningId: 'issfr_deleting_customer',
946+
charge: 'ch_deleting_customer',
947+
})
948+
);
949+
await new Promise(resolve => setImmediate(resolve));
950+
});
951+
952+
if (!observationPromise) {
953+
throw new Error('Observation did not start during deletion transaction');
954+
}
955+
await observationPromise;
956+
957+
const [fraudCase] = await db.select().from(stripe_early_fraud_warning_cases);
958+
expect(fraudCase).toEqual(
959+
expect.objectContaining({
960+
stripe_customer_id: testUser.stripe_customer_id,
961+
owner_classification: 'unmatched',
962+
kilo_user_id: null,
963+
status: 'review_required',
964+
})
965+
);
966+
expect(await db.select().from(stripe_early_fraud_warning_actions)).toHaveLength(0);
967+
968+
retrieveSpy.mockRestore();
969+
});
970+
923971
test('radar.early_fraud_warning.created deduplicates repeated delivery without creating actions', async () => {
924972
await cleanupDbForTest();
925973
testUser = await insertTestUser();

0 commit comments

Comments
 (0)