diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts index 80fb7fc4..ec513f47 100644 --- a/packages/api/src/routers/stripe.ts +++ b/packages/api/src/routers/stripe.ts @@ -27,6 +27,34 @@ let stripeClientKey: string | undefined; const isMockMode = (key: string | undefined): key is string => !!key && key.startsWith("mk_") && process.env.NODE_ENV !== "production"; +/** + * The secret and publishable keys must be the same Stripe mode. + * + * A PaymentIntent minted with a test secret cannot be confirmed by a live + * publishable key, and vice versa. Stripe.js reports that as a vague + * client-side error with no hint that the two keys disagree, so it is caught + * here where the cause is obvious — this pairing is easy to get wrong when + * only one of the two is swapped. + */ +const assertKeyModesMatch = (secretKey: string) => { + const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ""; + if (!publishable) return; + + // Restricted keys (rk_live_/rk_test_) are the recommended kind, so matching + // only on `sk_` would read a live restricted key as test mode and refuse to + // take payments. + const secretIsLive = /^[sr]k_live/.test(secretKey); + const publishableIsLive = publishable.startsWith("pk_live"); + + if (secretIsLive !== publishableIsLive) { + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: + "Payment service is misconfigured: the Stripe secret and publishable keys are for different modes (live vs test).", + }); + } +}; + async function getStripe(): Promise { const key = process.env.STRIPE_SECRET_KEY; @@ -121,6 +149,8 @@ export const stripeRouter = createTRPCRouter({ return { url: mockUrl }; } + assertKeyModesMatch(stripeKey); + const stripe = await getStripe(); if (!stripe) { throw new TRPCError({ @@ -132,7 +162,9 @@ export const stripeRouter = createTRPCRouter({ try { const session = await stripe.checkout.sessions.create({ - payment_method_types: ["card"], + // No payment_method_types on purpose: pinning it to card disables + // dynamic payment methods, so anything enabled in the Dashboard + // (Link, Cash App, wallets) never appears at checkout. line_items: [ { price_data: { @@ -214,6 +246,8 @@ export const stripeRouter = createTRPCRouter({ }; } + assertKeyModesMatch(stripeKey); + const stripe = await getStripe(); if (!stripe) { throw new TRPCError({ @@ -261,6 +295,8 @@ export const stripeRouter = createTRPCRouter({ confirmMembershipAfterPayment: protectedProcedure .input(z.object({ paymentIntentId: z.string() })) .mutation(async ({ ctx, input }) => { + // No key-mode check here: this path hands no publishable key to the + // client, so the two cannot disagree. const stripe = await getStripe(); if (!stripe) { throw new TRPCError({ @@ -414,6 +450,156 @@ export const stripeRouter = createTRPCRouter({ * * that matches their email (auto-link scenario) */ + /** + * Recovers membership payments Stripe took but this app never recorded. + * + * The portal flow records a charge by having the browser call + * confirmMembershipAfterPayment once the card clears. If that call never + * lands — tab closed, connection dropped, instance restarted — the money is + * gone and nothing here knows about it. The webhook is the usual backstop, + * but it only fires if the endpoint is subscribed to + * payment_intent.succeeded, which is Dashboard configuration rather than + * code and therefore not something this repo can guarantee. + * + * So the portal asks Stripe directly: any succeeded membership intent + * carrying this user's id that has no row here is recorded and granted now. + * Safe to call on every load — it is keyed on the PaymentIntent id and does + * nothing when everything is already reconciled. + */ + reconcileMyPayments: protectedProcedure.mutation(async ({ ctx }) => { + const stripe = await getStripe(); + if (!stripe) return { recovered: 0 }; + + const user = await ctx.db!.query.users.findFirst({ + where: eq(users.id, ctx.userId!), + }); + + let found; + try { + // Scoped by metadata to this user, so it can only ever recover their own + // payments. Stripe's search index lags writes by up to a minute, which is + // fine for a backstop — the direct confirm call is the fast path. + found = await stripe.paymentIntents.search({ + query: `metadata['userId']:'${ctx.userId}' AND status:'succeeded'`, + limit: 20, + }); + } catch { + // Search is unavailable on some accounts/versions; a failed backstop + // must not break the page that called it. + return { recovered: 0 }; + } + + let recovered = 0; + + for (const pi of found.data) { + if (pi.metadata?.type !== "membership") continue; + if (pi.metadata?.userId !== ctx.userId) continue; + // Same ceiling the webhook applies, so the two paths cannot disagree + // about which charges are memberships. + if (pi.amount > 10000) continue; + + const existing = await ctx.db!.query.stripePayments.findFirst({ + where: eq(stripePayments.stripePaymentIntentId, pi.id), + }); + + /** + * A row that exists but was never linked is exactly the half-finished + * state this is here to repair — treating "row exists" as "done" would + * strand it. Claim it and grant the membership instead. + */ + if (existing) { + if (existing.linkedUserId) continue; + + await ctx.db!.transaction(async (tx) => { + const claimed = await tx + .update(stripePayments) + .set({ + linkedUserId: ctx.userId!, + linkedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(stripePayments.id, existing.id), + isNull(stripePayments.linkedUserId), + ), + ); + + if (claimed.rowCount === 0) return; + + const parts = (user?.name || "Member").trim().split(/\s+/); + await createOrUpdateMembership( + tx as unknown as DrizzleDB, + ctx.userId!, + parts[0] || "Member", + parts.slice(1).join(" ") || "Member", + ); + recovered += 1; + }); + continue; + } + + const { firstName, lastName } = (() => { + const parts = (user?.name || "Member").trim().split(/\s+/); + return { + firstName: parts[0] || "Member", + lastName: parts.slice(1).join(" ") || "Member", + }; + })(); + + try { + await ctx.db!.transaction(async (tx) => { + // Same synthetic session id the confirm path and the webhook use, so + // the unique on stripeSessionId settles any race between the three. + const inserted = await tx + .insert(stripePayments) + .values({ + stripeSessionId: `pi_${pi.id}`, + stripeCustomerId: + typeof pi.customer === "string" + ? pi.customer + : (pi.customer?.id ?? ""), + stripePaymentIntentId: pi.id, + customerEmail: ( + pi.receipt_email ?? + user?.email ?? + "" + ).toLowerCase(), + customerName: user?.name ?? "Member", + amountTotal: pi.amount, + currency: pi.currency, + paymentStatus: "paid", + linkedUserId: ctx.userId!, + linkedAt: new Date(), + metadata: JSON.stringify(pi.metadata ?? {}), + }) + .onConflictDoNothing({ target: stripePayments.stripeSessionId }) + .returning({ id: stripePayments.id }); + + if (inserted.length === 0) return; + + await createOrUpdateMembership( + tx as unknown as DrizzleDB, + ctx.userId!, + firstName, + lastName, + ); + recovered += 1; + }); + } catch (error) { + logSecurityEvent({ + type: "validation_error", + identifier: ctx.userId ?? "unknown", + details: `Payment reconcile failed: ${error}`, + }); + } + } + + if (recovered > 0) clearMembershipCaches(ctx.cache, ctx.userId!); + + return { recovered }; + }), + checkPendingPayment: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.db!.query.users.findFirst({ where: eq((await import("@query/db")).users.id, ctx.userId!), diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index c1bbad45..8ce081c4 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -76,7 +76,21 @@ export async function POST(req: NextRequest) { } } - if (event.type === "checkout.session.completed") { + /** + * `async_payment_succeeded` matters because dynamic payment methods are + * enabled: a bank debit completes the Checkout Session while still `unpaid` + * and only settles minutes or days later. Handling the completion event + * alone would record that unpaid row and never come back for it, leaving a + * customer charged with no membership. + * + * Both events carry a Checkout Session, so they share one handler; the + * membership is granted only on `payment_status: "paid"`, which is false on + * the first event and true on the second. + */ + if ( + event.type === "checkout.session.completed" || + event.type === "checkout.session.async_payment_succeeded" + ) { const session = event.data.object as Stripe.Checkout.Session; try { @@ -107,7 +121,34 @@ export async function POST(req: NextRequest) { }); if (existingPayment) { - // If payment exists, verify membership was created too + /** + * An async payment method recorded this row as unpaid on + * checkout.session.completed and has now settled. Upgrade it and grant + * the membership it paid for — returning early here is what would + * leave that customer charged with nothing. + */ + if ( + existingPayment.paymentStatus !== "paid" && + session.payment_status === "paid" + ) { + await db.transaction(async (tx) => { + await tx + .update(stripePayments) + .set({ paymentStatus: "paid", updatedAt: new Date() }) + .where(eq(stripePayments.id, existingPayment.id)); + + if (existingPayment.linkedUserId) { + await createOrUpdateMembership( + tx, + existingPayment.linkedUserId, + customerName, + customerEmail, + phoneNumber, + ); + } + }); + } + if (existingPayment.linkedUserId) { try { // Invalidate cache just in case diff --git a/sites/mainweb/components/portal/LinkStripeAccount.tsx b/sites/mainweb/components/portal/LinkStripeAccount.tsx index 7a14d04f..e8898da0 100644 --- a/sites/mainweb/components/portal/LinkStripeAccount.tsx +++ b/sites/mainweb/components/portal/LinkStripeAccount.tsx @@ -57,13 +57,31 @@ export default function LinkStripeAccount({ onError: () => setIsChecking(false), }); + /** + * Recovers a charge Stripe took that never got recorded here — the case + * where the browser died between the card clearing and the confirm call. + * Runs on load so the money reappears as a membership without anyone + * having to contact support. + */ + const reconcileMutation = trpc.stripe.reconcileMyPayments.useMutation({ + onSuccess: (data) => { + if (data.recovered > 0) { + setSuccess(true); + utils.member.checkStatus.invalidate(); + invalidatePortalContext(); + onSuccess?.(); + } + }, + }); + // Guard: only fire once even in React StrictMode double-invoke const autoLinkFired = useRef(false); useEffect(() => { if (autoLinkFired.current) return; autoLinkFired.current = true; autoLinkMutation.mutate(); - // autoLinkMutation ref is stable — intentionally omitted from deps + reconcileMutation.mutate(); + // mutation refs are stable — intentionally omitted from deps // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -112,6 +130,15 @@ export default function LinkStripeAccount({ onSuccess?.(); }; + /** + * The modal calls this when the card cleared but recording it did not. Money + * has moved, so reconcile immediately rather than waiting for a remount — + * the message shown to the user promises the membership will just appear. + */ + const handlePaymentUnconfirmed = () => { + reconcileMutation.mutate(); + }; + const handleLinkSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -347,6 +374,7 @@ export default function LinkStripeAccount({ onConfirmPayment={async (paymentIntentId) => { await confirmMutation.mutateAsync({ paymentIntentId }); }} + onUnconfirmed={handlePaymentUnconfirmed} onClose={() => { setShowModal(false); setPaymentData(null); diff --git a/sites/mainweb/components/portal/StripePaymentModal.tsx b/sites/mainweb/components/portal/StripePaymentModal.tsx index 3e2b1268..7d27ae43 100644 --- a/sites/mainweb/components/portal/StripePaymentModal.tsx +++ b/sites/mainweb/components/portal/StripePaymentModal.tsx @@ -18,10 +18,12 @@ function CheckoutForm({ onSuccess, onCancel, onConfirmPayment, + onUnconfirmed, }: { onSuccess: () => void; onCancel: () => void; onConfirmPayment: (paymentIntentId: string) => Promise; + onUnconfirmed: () => void; }) { const stripe = useStripe(); const elements = useElements(); @@ -55,18 +57,41 @@ function CheckoutForm({ setError(confirmError.message ?? "Payment failed. Please try again."); setProcessing(false); } else if (paymentIntent?.status === "succeeded") { - // Server-side confirmation: record payment + activate membership + /** + * The card has cleared by this point, so the money is already gone. The + * server call that records it is therefore retried rather than failed on + * the first error — it is idempotent (keyed on the PaymentIntent id) and + * a transient blip here is the difference between a membership and a + * charge with nothing to show for it. + */ + const confirmWithRetry = async () => { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + await onConfirmPayment(paymentIntent.id); + return true; + } catch (err) { + lastError = err; + await new Promise((r) => setTimeout(r, 800 * (attempt + 1))); + } + } + throw lastError; + }; + try { - await onConfirmPayment(paymentIntent.id); + await confirmWithRetry(); setSucceeded(true); setProcessing(false); setTimeout(() => onSuccess(), 1200); - } catch (err: unknown) { - const msg = - err instanceof Error - ? err.message - : "Confirmation failed. Contact support."; - setError(msg); + } catch { + onUnconfirmed(); + // Deliberately reassuring: the payment succeeded, and both the webhook + // and the reconcile-on-load path will pick it up. Telling someone + // "contact support" about money they have already paid, when it will + // resolve itself, generates a ticket for nothing. + setError( + "Your payment went through, but activating the membership is taking a moment. It will appear automatically — reload the portal shortly.", + ); setProcessing(false); } } else { @@ -166,6 +191,7 @@ interface StripePaymentModalProps { onSuccess: () => void; onClose: () => void; onConfirmPayment: (paymentIntentId: string) => Promise; + onUnconfirmed?: () => void; } export function StripePaymentModal({ @@ -175,6 +201,7 @@ export function StripePaymentModal({ onSuccess, onClose, onConfirmPayment, + onUnconfirmed, }: StripePaymentModalProps) { const [stripePromise, setStripePromise] = useState | null>(null); @@ -288,6 +315,7 @@ export function StripePaymentModal({ onSuccess={onSuccess} onCancel={onClose} onConfirmPayment={onConfirmPayment} + onUnconfirmed={onUnconfirmed ?? (() => {})} /> diff --git a/sites/mainweb/postcss.config.js b/sites/mainweb/postcss.config.js deleted file mode 100644 index 967a9140..00000000 --- a/sites/mainweb/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -// Import the local tooling PostCSS config directly to avoid module -// resolution issues with Turbopack's static analysis. -import { postcssConfig } from "../../tooling/tailwind/postcss.config.js"; - -export default postcssConfig; diff --git a/sites/mainweb/postcss.config.mjs b/sites/mainweb/postcss.config.mjs new file mode 100644 index 00000000..9e19a5da --- /dev/null +++ b/sites/mainweb/postcss.config.mjs @@ -0,0 +1,19 @@ +/** + * Self-contained on purpose. + * + * This used to re-export tooling/tailwind/postcss.config.js. Turbopack + * evaluates the PostCSS config in its own Node sandbox, and that cross-package + * import failed there with "__turbopack_context__.a is not a function", + * breaking every CSS import in the app — globals.css, liquid-glass.css and the + * geist font modules. It reproduced on a clean CI runner with no cache. + * + * The config is four lines, so it is inlined rather than shared, matching + * sites/hacklytics2027/postcss.config.mjs, which never had the problem. + */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config;