Skip to content

Commit 3360519

Browse files
committed
feat(kilo-pass): block user when duplicate card fingerprint gate triggers
When the card fingerprint gate detects a duplicate card across users, the offending user's account is now blocked (blocked_reason set to 'kilo_pass_duplicate_card') in addition to the existing cancel + refund. Does not overwrite an existing blocked_reason. Follows the same pattern as cancel-and-refund.ts.
1 parent 615f11b commit 3360519

2 files changed

Lines changed: 121 additions & 1 deletion

File tree

apps/web/src/lib/kilo-pass/card-fingerprint-gate.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, jest, test } from '@jest/globals';
33
import { db, cleanupDbForTest } from '@/lib/drizzle';
44
import {
55
credit_transactions,
6+
kilocode_users,
67
kilo_pass_audit_log,
78
kilo_pass_subscriptions,
89
payment_methods,
@@ -285,6 +286,13 @@ describe('card fingerprint gate', () => {
285286
.from(credit_transactions)
286287
.where(eq(credit_transactions.kilo_user_id, newUser.id));
287288
expect(creditRows).toHaveLength(0);
289+
290+
const blockedUser = await db.query.kilocode_users.findFirst({
291+
columns: { blocked_reason: true, blocked_at: true },
292+
where: eq(kilocode_users.id, newUser.id),
293+
});
294+
expect(blockedUser?.blocked_reason).toBe('kilo_pass_duplicate_card');
295+
expect(blockedUser?.blocked_at).not.toBeNull();
288296
});
289297

290298
test('does not block same user re-subscribing with the same card', async () => {
@@ -389,6 +397,103 @@ describe('card fingerprint gate', () => {
389397
)
390398
);
391399
expect(creditRows.length).toBeGreaterThanOrEqual(1);
400+
401+
const userRow = await db.query.kilocode_users.findFirst({
402+
columns: { blocked_reason: true },
403+
where: eq(kilocode_users.id, user.id),
404+
});
405+
expect(userRow?.blocked_reason).toBeNull();
406+
});
407+
408+
test('does not overwrite existing blocked_reason when user is already blocked', async () => {
409+
const { handleKiloPassInvoicePaid } =
410+
await import('@/lib/kilo-pass/stripe-handlers-invoice-paid');
411+
412+
const existingUser = await insertTestUser({
413+
total_microdollars_acquired: 0,
414+
microdollars_used: 0,
415+
});
416+
const newUser = await insertTestUser({
417+
total_microdollars_acquired: 0,
418+
microdollars_used: 0,
419+
blocked_reason: 'preexisting_block',
420+
blocked_at: new Date().toISOString(),
421+
});
422+
423+
const fingerprint = `fp_already_blocked_${Math.random()}`;
424+
const existingPmId = `pm_already_blocked_existing_${Math.random()}`;
425+
const newPmId = `pm_already_blocked_new_${Math.random()}`;
426+
427+
await insertPaymentMethod({ userId: existingUser.id, stripeId: existingPmId, fingerprint });
428+
await insertPaymentMethod({ userId: newUser.id, stripeId: newPmId, fingerprint });
429+
430+
const existingSubId = `sub_already_blocked_existing_${Math.random()}`;
431+
await insertKiloPassSubscription({
432+
kiloUserId: existingUser.id,
433+
stripeSubscriptionId: existingSubId,
434+
tier: KiloPassTier.Tier19,
435+
cadence: KiloPassCadence.Monthly,
436+
});
437+
438+
const newSubId = `sub_already_blocked_new_${Math.random()}`;
439+
const meta = kiloPassMetadata({
440+
kiloUserId: newUser.id,
441+
tier: KiloPassTier.Tier19,
442+
cadence: KiloPassCadence.Monthly,
443+
});
444+
const subscription = makeStripeSubscription({
445+
id: newSubId,
446+
start_date_seconds: 1_735_689_600,
447+
metadata: meta,
448+
});
449+
450+
const priceId = await getKiloPassPriceId({
451+
tier: KiloPassTier.Tier19,
452+
cadence: KiloPassCadence.Monthly,
453+
});
454+
455+
const paymentIntentId = `pi_already_blocked_${Math.random()}`;
456+
const invoice = makeStripeInvoice({
457+
id: `inv_already_blocked_${Math.random()}`,
458+
amount_paid_cents: 1900,
459+
created_seconds: 1_735_689_600,
460+
priceId,
461+
subscriptionIdOrExpanded: newSubId,
462+
metadata: meta,
463+
paymentIntentId,
464+
});
465+
466+
const mockCancel = jest.fn(async () => ({
467+
id: newSubId,
468+
status: 'canceled',
469+
}));
470+
const mockRefund = jest.fn(async () => ({ id: `re_${Math.random()}` }));
471+
const mockRetrievePm = jest.fn(async () => ({
472+
id: newPmId,
473+
card: { fingerprint },
474+
}));
475+
const mockRetrieveSub = jest.fn(async () => subscription);
476+
477+
const stripe = {
478+
subscriptions: { retrieve: mockRetrieveSub, cancel: mockCancel },
479+
refunds: { create: mockRefund },
480+
paymentMethods: { retrieve: mockRetrievePm },
481+
paymentIntents: {
482+
retrieve: jest.fn(async () => ({ id: paymentIntentId, payment_method: newPmId })),
483+
},
484+
};
485+
486+
await handleKiloPassInvoicePaid({
487+
eventId: 'evt_already_blocked_test',
488+
invoice,
489+
stripe: stripe as unknown as Stripe,
490+
});
491+
492+
const blockedUser = await db.query.kilocode_users.findFirst({
493+
columns: { blocked_reason: true },
494+
where: eq(kilocode_users.id, newUser.id),
495+
});
496+
expect(blockedUser?.blocked_reason).toBe('preexisting_block');
392497
});
393498

394499
test('does not block when other user has ended Kilo Pass with same fingerprint', async () => {

apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ async function resolveCardFingerprint(params: {
138138

139139
// Card fingerprint gate for Kilo Pass subscriptions. Ensures a single card
140140
// fingerprint can be attached to at most one active (non-canceled, non-ended)
141-
// Kilo Pass subscription across all Kilo users at any time.
141+
// Kilo Pass subscription across all Kilo users at any time. When a duplicate
142+
// is detected, the new subscription is canceled, the invoice refunded, and
143+
// the offending user's account is blocked (if not already blocked).
142144
//
143145
// This gate is only applied to Stripe subscriptions (invoice.paid webhook path).
144146
// App Store and Google Play purchases are not gated here because:
@@ -237,6 +239,19 @@ export async function checkDuplicateCardFingerprintGate(params: {
237239
},
238240
});
239241

242+
await dbOrTx
243+
.update(kilocode_users)
244+
.set({
245+
blocked_reason: 'kilo_pass_duplicate_card',
246+
blocked_at: new Date().toISOString(),
247+
})
248+
.where(
249+
and(
250+
eq(kilocode_users.id, kiloUserId),
251+
isNull(kilocode_users.blocked_reason),
252+
)
253+
);
254+
240255
return {
241256
blocked: true,
242257
otherKiloUserId: existingActiveKiloPass.kiloUserId,

0 commit comments

Comments
 (0)