Skip to content

feat(kilo-pass): disallow duplicate card fingerprints across Kilo Pass subscriptions#3309

Merged
jrf0110 merged 23 commits into
mainfrom
gt/toast/d0f06aec
May 29, 2026
Merged

feat(kilo-pass): disallow duplicate card fingerprints across Kilo Pass subscriptions#3309
jrf0110 merged 23 commits into
mainfrom
gt/toast/d0f06aec

Conversation

@jrf0110

@jrf0110 jrf0110 commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Enforce that a single credit card fingerprint can be attached to at most one active Kilo Pass subscription across all Kilo users at any time. This blocks a fraud vector where multiple accounts purchase Kilo Pass using the same physical card.

What's new

  • findActiveKiloPassByCardFingerprint helper in apps/web/src/lib/stripe.ts — queries payment methods for a matching fingerprint on a different user, then checks whether that user has an active Kilo Pass subscription.
  • checkDuplicateCardFingerprintGate in apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts — runs in the invoice.paid webhook handler after the Stripe charge succeeds. If a duplicate is found: cancels the subscription on Stripe, refunds the invoice, writes an audit log, and sends a customer-facing email notification.
  • DuplicateCardSubscriptionCanceled audit log action — added to the KiloPassAuditLogAction enum for tracking duplicate-card cancellations.
  • Email notification — new kiloPassDuplicateCardCanceled template and sendKiloPassDuplicateCardCanceledEmail sender function, using transactional_email_log for idempotent dedup.
  • App Store / Google Play path skipped — the gate only runs in the Stripe invoice.paid path. Store purchases have their own subscription model (appAccountToken) and don't go through Stripe invoices. A code comment documents this.

Edge cases handled

  • Same user re-subscribing with the same card → NOT blocked (excluded by excludingUserId).
  • Other user has ended Kilo Pass with same fingerprint → NOT blocked (query pivots off active subscription status).
  • No fingerprint available → gate passes open (fails open rather than blocking legitimate purchases).
  • Race condition / webhook replay → email dedup via transactional_email_log unique index; audit logs are idempotent via the existing Stripe event ID tracking.

Verification

Manually ran through the flow and made sure that duplicate card users got their sub canceled and account blocked:

image

Visual Changes

N/A

Reviewer Notes

  • The gate fires in stripe-handlers-invoice-paid.ts after the subscription upsert but before credit issuance — if blocked, the subscription row is marked canceled with ended_at set, and no credits are issued.
  • The findActiveKiloPassByCardFingerprint query joins payment_methods to kilo_pass_subscriptions, filtering for active subscriptions (not in canceled/unpaid/incomplete_expired, and ended_at IS NULL).
  • Migration 0135_duplicate_card_gate.sql adds the DuplicateCardSubscriptionCanceled enum value to the check constraint.

Comment thread apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts Outdated
Comment thread apps/web/src/lib/stripe.ts Outdated
Comment thread apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts
@kilo-code-bot

kilo-code-bot Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental commit adds only migration 0150_typical_scorpion.sql, which extends the kilo_pass_audit_log.action CHECK constraint to include 'duplicate_card_subscription_canceled' — following the exact same drop-and-re-add pattern already established in migration 0125. All previously identified issues remain resolved.

Resolved Issues
File Line Issue Status
apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts 273 transactional_email_log marker inserted but never cleaned up when user?.google_user_email is falsy ✅ Fixed in d4ebe65
apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts 520 Blocked subscription still triggers an affiliate sale event ✅ Fixed in d8fc024
apps/web/src/lib/stripe.ts N/A Raw SQL NOT IN string instead of Drizzle's notInArray ✅ Fixed in d46cf5f
apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts N/A dbOrTx not threaded through checkDuplicateCardFingerprint ✅ Fixed
apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts N/A FK violation: audit log and user-blocking run inside gate with dbOrTx param ✅ Fixed in a6e71caf5 — moved into handler tx
Files Reviewed (14 files)
  • apps/web/src/emails/kiloPassDuplicateCardCanceled.html — clean
  • apps/web/src/lib/email.ts — clean
  • apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts — clean; gate now purely handles Stripe API calls; DB writes moved to handler
  • apps/web/src/lib/kilo-pass/card-fingerprint-gate.test.ts — clean; covers block, no-overwrite, re-sub same user, ended-sub, no-fingerprint, email dedup, and race-condition cases
  • apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts — clean; audit log, user-blocking, and subscription cancellation all within tx; failure audit wrapped in try-catch; blockedEmailParams correctly gated before email send
  • packages/db/src/schema-types.ts — clean
  • packages/db/src/schema.test.ts — clean
  • packages/db/src/migrations/0148_worthless_aaron_stack.sql — clean
  • packages/db/src/migrations/meta/0148_snapshot.json — generated, skipped
  • packages/db/src/migrations/meta/_journal.json — generated, skipped
  • packages/db/src/schema.ts (via grep) — blocked_at uses mode: 'string'; new Date().toISOString() matches convention
  • apps/web/src/lib/user/index.ts (via grep) — softDeleteUser sets blocked_reason = 'soft-deleted at ...'; no GDPR concern
  • apps/web/src/lib/stripe.ts (via grep) — notInArray used correctly; no raw SQL
  • packages/db/src/migrations/0150_typical_scorpion.sql — clean; extends kilo_pass_audit_log.action CHECK constraint to include 'duplicate_card_subscription_canceled'; follows the same drop-and-re-add pattern established in migration 0125; generated snapshot/journal skipped

Reviewed by claude-sonnet-4.6 · 607,768 tokens

Review guidance: REVIEW.md from base branch main

Comment thread apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts Outdated
@jrf0110 jrf0110 force-pushed the gt/toast/d0f06aec branch 7 times, most recently from 5de2b4e to 1321cfb Compare May 22, 2026 13:40
Comment thread apps/web/src/lib/kilo-pass/stripe-handlers-invoice-paid.ts
@jrf0110 jrf0110 force-pushed the gt/toast/d0f06aec branch 7 times, most recently from c239e62 to a9590f5 Compare May 28, 2026 15:27
Comment thread apps/web/src/lib/kilo-pass/card-fingerprint-gate.ts Outdated
@jrf0110

jrf0110 commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Alright new requirement - we're also going to block the user that tried to buy a kilo pass with a duplicate credit card

@jrf0110

jrf0110 commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Also, now that #3526 is merged, we need to make sure this PR properly supersedes that work

@jrf0110 jrf0110 force-pushed the gt/toast/d0f06aec branch from 24d6ed3 to 3360519 Compare May 28, 2026 21:02
@jrf0110

jrf0110 commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Implemented in commit 3360519. When the card fingerprint gate detects a duplicate card, the offending user's account is now blocked (blocked_reason = 'kilo_pass_duplicate_card', blocked_at set to current timestamp). This follows the same pattern as cancel-and-refund.ts — if the user is already blocked (blocked_reason IS NOT NULL), the existing block is preserved. The auth middleware already enforces blocked users at sign-in and API access points, so no additional enforcement code is needed.

@jrf0110

jrf0110 commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (which now includes PR #3526). The rebase was clean — no conflicts. The two features are complementary and work correctly together:

  • PR feat(kilo-pass): guard welcome promo by payment fingerprint #3526 (welcome-promo fingerprint guard): prevents the introductory 50% promo from being claimed more than once per payment fingerprint. Operates at the promo/bonus level.
  • This PR (card fingerprint gate): blocks duplicate card usage entirely — cancels the subscription, refunds the invoice, blocks the user, sends notification email.

When this PR's gate triggers (duplicate card detected), the subscription is canceled before the issuance/promo code runs, so #3526's welcome-promo logic is never reached for that invoice. When the gate does not trigger, #3526's code correctly applies the welcome-promo guard as before. No additional integration work was needed.

@jrf0110 jrf0110 force-pushed the gt/toast/d0f06aec branch from 6d7d4a5 to 76a3274 Compare May 29, 2026 11:07
jrf0110 added 15 commits May 29, 2026 19:12
…otInArray

- Return blockedEmailParams from db.transaction instead of mutating
  outer variable, fixing tsgo narrowing the type to 'never' because
  it doesn't track mutations across async closures.
- Replace raw sql`NOT IN` with Drizzle's notInArray for type safety.
…erge conflict markers

The 0141_snapshot.json had unresolved merge conflict markers from a
previous rebase. Deleted branch-local migration files and regenerated
a clean migration (0142) using pnpm drizzle generate.
Rename branch migration from 0142 to 0143 since main added 0142_dashing_blue_marvel.
Restore main's 0142 snapshot and journal, regenerate clean migration.
When user.google_user_email is falsy in maybeSendDuplicateCardCanceledEmail,
the transactional_email_log marker was left in the DB permanently. This
suppressed future email sends even if the user later acquired an email.
Now the marker is deleted before returning, consistent with the error-path
cleanup logic.
…gers

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.
@jrf0110 jrf0110 force-pushed the gt/toast/d0f06aec branch from 76a3274 to b861118 Compare May 29, 2026 19:14
jrf0110 added 3 commits May 29, 2026 19:48
… to fix FK violation

The checkDuplicateCardFingerprintGate function was passing dbOrTx through
a parameter to appendKiloPassAuditLog, but the FK constraint on
kilo_pass_audit_log.kilo_pass_subscription_id was being violated inside
the transaction. Move the audit log insert and user-blocking update into
the handler where tx is used directly. Also fix the catch block to omit
kiloPassSubscriptionId when the transaction may have rolled back, and
wrap the failure audit log in a try-catch so it doesn't mask the original
error.
@jrf0110 jrf0110 merged commit 3703027 into main May 29, 2026
49 checks passed
@jrf0110 jrf0110 deleted the gt/toast/d0f06aec branch May 29, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants