feat(subscription): enhance sync functionality to include email searc… - #180
Conversation
…h and broader subscription status checks
There was a problem hiding this comment.
Sorry @Apexone11, you have reached your weekly rate limit of 1500000 diff characters.
Please try again later or upgrade to continue using Sourcery
There was a problem hiding this comment.
Pull request overview
Enhances the payments self-service subscription sync endpoint to find a user’s Stripe customer(s) more reliably (metadata + email) and to detect additional subscription statuses when repairing the local Subscription record.
Changes:
- Fetch the authenticated user’s email from the DB and use it as an additional Stripe customer lookup path.
- Aggregate candidate Stripe customer IDs from both metadata search and email list results.
- Expand subscription status checks during sync to include
active,trialing, andpast_due, and return more descriptive failure messaging.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Search by email | ||
| if (dbUser?.email) { | ||
| try { | ||
| const byEmail = await stripe.customers.list({ | ||
| email: dbUser.email, | ||
| limit: 10, | ||
| }) | ||
| if (trialingSubs.data.length > 0) subs.data.push(...trialingSubs.data) | ||
| byEmail.data.forEach((c) => customerIds.add(c.id)) | ||
| } catch { |
There was a problem hiding this comment.
Customer lookup by email makes it possible to sync a subscription that doesn’t actually belong to the logged-in user (e.g., if an account can be created/updated with an email they don’t control, or if the Stripe customer was created outside StudyHub but shares the same email). Consider only using the email-based customer matches as candidates and then requiring a positive match on customer.metadata.studyhub_user_id or subscription.metadata.studyhub_user_id before syncing; also skip any customer whose metadata points at a different StudyHub user.
| for (const sub of subs.data) { | ||
| const priceId = sub.items?.data?.[0]?.price?.id || '' | ||
| const resolved = planFromPriceId(priceId) | ||
| const plan = resolved || 'pro_monthly' | ||
|
|
||
| await prisma.subscription.upsert({ | ||
| where: { userId: req.user.userId }, | ||
| create: { | ||
| userId: req.user.userId, | ||
| stripeCustomerId: customerId, | ||
| stripeSubscriptionId: sub.id, | ||
| stripePriceId: priceId, | ||
| plan, | ||
| status: sub.status, |
There was a problem hiding this comment.
Before upserting the subscription for req.user.userId, the code should validate that the Stripe object being used actually belongs to that StudyHub user (e.g., sub.metadata.studyhub_user_id or the customer’s metadata.studyhub_user_id). Without this, any matching customerId (especially from the email search) can result in attaching the wrong Stripe subscription/customer to the current user.
| for (const status of statusesToCheck) { | ||
| if (synced) break | ||
| try { | ||
| const subs = await stripe.subscriptions.list({ | ||
| customer: customerId, | ||
| status, | ||
| limit: 1, | ||
| }) | ||
|
|
||
| for (const sub of subs.data) { | ||
| const priceId = sub.items?.data?.[0]?.price?.id || '' | ||
| const resolved = planFromPriceId(priceId) | ||
| const plan = resolved || 'pro_monthly' | ||
|
|
||
| await prisma.subscription.upsert({ | ||
| where: { userId: req.user.userId }, | ||
| create: { | ||
| userId: req.user.userId, | ||
| stripeCustomerId: customerId, | ||
| stripeSubscriptionId: sub.id, | ||
| stripePriceId: priceId, | ||
| plan, | ||
| status: sub.status, | ||
| currentPeriodStart: new Date(sub.current_period_start * 1000), | ||
| currentPeriodEnd: new Date(sub.current_period_end * 1000), | ||
| cancelAtPeriodEnd: sub.cancel_at_period_end, | ||
| }, | ||
| update: { | ||
| stripeCustomerId: customerId, | ||
| stripeSubscriptionId: sub.id, | ||
| stripePriceId: priceId, | ||
| plan, | ||
| status: sub.status, | ||
| currentPeriodStart: new Date(sub.current_period_start * 1000), | ||
| currentPeriodEnd: new Date(sub.current_period_end * 1000), | ||
| cancelAtPeriodEnd: sub.cancel_at_period_end, | ||
| canceledAt: null, | ||
| }, | ||
| }) | ||
|
|
||
| synced = true | ||
| log.info( | ||
| { userId: req.user.userId, plan, subId: sub.id, customerId }, | ||
| 'User self-synced subscription', | ||
| ) | ||
| break | ||
| } | ||
| } catch { | ||
| // Continue with next status | ||
| } | ||
| } | ||
| if (synced) break |
There was a problem hiding this comment.
The nested loops can trigger up to ~3 Stripe API calls per matched customer (and up to 20 customerIds), which risks slow responses/timeouts and unnecessary Stripe load. Consider listing subscriptions once per customer (e.g., request all statuses in one call if supported) and then filtering locally, rather than calling subscriptions.list separately for each status.
| for (const status of statusesToCheck) { | |
| if (synced) break | |
| try { | |
| const subs = await stripe.subscriptions.list({ | |
| customer: customerId, | |
| status, | |
| limit: 1, | |
| }) | |
| for (const sub of subs.data) { | |
| const priceId = sub.items?.data?.[0]?.price?.id || '' | |
| const resolved = planFromPriceId(priceId) | |
| const plan = resolved || 'pro_monthly' | |
| await prisma.subscription.upsert({ | |
| where: { userId: req.user.userId }, | |
| create: { | |
| userId: req.user.userId, | |
| stripeCustomerId: customerId, | |
| stripeSubscriptionId: sub.id, | |
| stripePriceId: priceId, | |
| plan, | |
| status: sub.status, | |
| currentPeriodStart: new Date(sub.current_period_start * 1000), | |
| currentPeriodEnd: new Date(sub.current_period_end * 1000), | |
| cancelAtPeriodEnd: sub.cancel_at_period_end, | |
| }, | |
| update: { | |
| stripeCustomerId: customerId, | |
| stripeSubscriptionId: sub.id, | |
| stripePriceId: priceId, | |
| plan, | |
| status: sub.status, | |
| currentPeriodStart: new Date(sub.current_period_start * 1000), | |
| currentPeriodEnd: new Date(sub.current_period_end * 1000), | |
| cancelAtPeriodEnd: sub.cancel_at_period_end, | |
| canceledAt: null, | |
| }, | |
| }) | |
| synced = true | |
| log.info( | |
| { userId: req.user.userId, plan, subId: sub.id, customerId }, | |
| 'User self-synced subscription', | |
| ) | |
| break | |
| } | |
| } catch { | |
| // Continue with next status | |
| } | |
| } | |
| if (synced) break | |
| if (synced) break | |
| try { | |
| const subs = await stripe.subscriptions.list({ | |
| customer: customerId, | |
| status: 'all', | |
| limit: 100, | |
| }) | |
| const sub = statusesToCheck | |
| .map((status) => subs.data.find((candidate) => candidate.status === status)) | |
| .find(Boolean) | |
| if (!sub) continue | |
| const priceId = sub.items?.data?.[0]?.price?.id || '' | |
| const resolved = planFromPriceId(priceId) | |
| const plan = resolved || 'pro_monthly' | |
| await prisma.subscription.upsert({ | |
| where: { userId: req.user.userId }, | |
| create: { | |
| userId: req.user.userId, | |
| stripeCustomerId: customerId, | |
| stripeSubscriptionId: sub.id, | |
| stripePriceId: priceId, | |
| plan, | |
| status: sub.status, | |
| currentPeriodStart: new Date(sub.current_period_start * 1000), | |
| currentPeriodEnd: new Date(sub.current_period_end * 1000), | |
| cancelAtPeriodEnd: sub.cancel_at_period_end, | |
| }, | |
| update: { | |
| stripeCustomerId: customerId, | |
| stripeSubscriptionId: sub.id, | |
| stripePriceId: priceId, | |
| plan, | |
| status: sub.status, | |
| currentPeriodStart: new Date(sub.current_period_start * 1000), | |
| currentPeriodEnd: new Date(sub.current_period_end * 1000), | |
| cancelAtPeriodEnd: sub.cancel_at_period_end, | |
| canceledAt: null, | |
| }, | |
| }) | |
| synced = true | |
| log.info( | |
| { userId: req.user.userId, plan, subId: sub.id, customerId }, | |
| 'User self-synced subscription', | |
| ) | |
| } catch { | |
| // Continue with next customer | |
| } |
| // Search by metadata | ||
| try { | ||
| const byMeta = await stripe.customers.search({ | ||
| query: `metadata["studyhub_user_id"]:"${req.user.userId}"`, | ||
| limit: 10, | ||
| }) | ||
| byMeta.data.forEach((c) => customerIds.add(c.id)) | ||
| } catch { | ||
| // Search API may not be available in test mode | ||
| } |
There was a problem hiding this comment.
These empty catch {} blocks swallow Stripe errors silently, which makes it hard to debug real production failures (e.g., bad credentials, network issues, malformed query). Consider logging at least a warning/debug with enough context (userId + which search path failed) and/or capturing the error, while still gracefully degrading.
| } catch { | ||
| // Continue with next status | ||
| } |
There was a problem hiding this comment.
The catch {} here drops the Stripe error and continues, which can mask systemic issues (e.g., Stripe outage or auth failure) and lead to confusing "No active subscription" responses. Consider logging (include customerId and status) and distinguishing between "not found" vs "Stripe call failed" so support can diagnose sync failures.
Clears 3 advisories (2 high) that landed after round 3. **Lockfile-only** — no manifest changes. | Alert | Package | Advisory floor | Now at | |---|---|---|---| | #179, #182 (high) | postcss | 8.5.18 | **8.5.25** (all 3 lockfiles) | | #180 (medium) | tar | 7.5.21 | **7.5.22** (root + frontend) | ## Not included: react-router (deliberate) The 2 remaining high alerts (#176, #177) are `react-router`, and the fix is **8.3.0 — a major bump from 7.18.1**. CLAUDE.md lists React Router among the majors that require an explicit founder approval, so it is held out of this PR. It touches every route in the app and deserves its own PR with a full route smoke pass. Dependabot has it open as #450/#451. Also open and awaiting the same call: #446 recharts 2→3, #447 @vitejs/plugin-react 5→6, #448 @testing-library/jest-dom 6→7, #449 rollup-plugin-visualizer 6→7 — all majors. ## Validation - Backend: lint ✅ · build ✅ · tests ✅ **3541 passed** - Frontend: lint ✅ 0 errors · build ✅ · **906 passed** - Release-log entry added (CI gate) - `playwright-smoke` remains the known-red baseline (red on main since 2026-06-02) 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by Sourcery Update dependency lockfiles to address recent security advisories for postcss and tar, and document the changes in the release log. Enhancements: - Record security round 4 dependency updates and remaining react-router advisories in the v2.3.0 release log entry. Chores: - Upgrade postcss to 8.5.25 and tar to 7.5.22 across all lockfiles to clear three new security advisories.
…h and broader subscription status checks