app.post("/api/payments/create-tip-intent", async (c) => { try { const { amount, creatorId, tipperId, recipeId, currency = "gbp" } = await c.req.json(); if (!amount || !creatorId) return c.json({ error: "Missing required fields" }, 400); if (amount < 100 || amount > 99999) return c.json({ error: "Invalid amount" }, 400); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2025-07-30.basil", // Basil is valid }); // 1) Look up creator’s Connect account & eligibility const creator = await c.env.runQuery(internal.payments.getCreatorPaymentsByUserId, { userId: creatorId }); if (!creator?.stripeConnectId || !creator?.payoutsEnabled) { return c.json({ error: "Creator not eligible for payouts" }, 400); } // 2) Compute 50/50 split (minor units) const platformFee = Math.floor(amount * 0.5); const creatorPayout = amount - platformFee; // 3) Create a destination-charge PaymentIntent const intent = await stripe.paymentIntents.create({ amount, currency, transfer_data: { destination: creator.stripeConnectId }, application_fee_amount: platformFee, // Optional but helpful for cross-border/receipts: on_behalf_of: creator.stripeConnectId, automatic_payment_methods: { enabled: true }, metadata: { type: "tip", creatorId, tipperId: tipperId ?? "", recipeId: recipeId ?? "", }, }, { idempotencyKey: `tip:${tipperId || "anon"}:${creatorId}:${amount}:${Date.now()}`, }); // 4) Record provisional tip await c.env.runMutation(internal.payments.recordTipProvisional, { paymentIntentId: intent.id, tipperId, creatorId, recipeId, amount, currency, platformFee, creatorPayout, status: "processing", createdAt: Date.now(), }); return c.json({ success: true, clientSecret: intent.client_secret }); } catch (error) { console.error("create-tip-intent error", error); return c.json({ error: "Payment intent creation failed" }, 500); } }); app.post("/api/payments/stripe-webhook", async (c) => { try { const body = await c.req.text(); const sig = c.req.header("stripe-signature"); if (!sig) return c.json({ error: "Missing stripe signature" }, 400); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2025-07-30.basil", }); const event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); switch (event.type) { case "payment_intent.succeeded": { const pi = event.data.object as Stripe.PaymentIntent; const full = await stripe.paymentIntents.retrieve(pi.id, { expand: ["latest_charge"] }); const latest = full.latest_charge; const charge = typeof latest === "string" ? null : latest; // if expanded, it's a Charge await c.env.runMutation(internal.payments.finalizeTip, { paymentIntentId: pi.id, chargeId: charge?.id ?? undefined, applicationFeeId: (charge?.application_fee as string) ?? undefined, status: "succeeded", updatedAt: Date.now(), }); break; } case "payment_intent.payment_failed": { const pi = event.data.object as Stripe.PaymentIntent; await c.env.runMutation(internal.payments.updateTipStatus, { paymentIntentId: pi.id, status: "failed", updatedAt: Date.now(), }); break; } case "charge.refunded": { const ch = event.data.object as Stripe.Charge; await c.env.runMutation(internal.payments.updateTipStatus, { paymentIntentId: ch.payment_intent as string, status: "refunded", updatedAt: Date.now(), }); break; } case "charge.dispute.created": { const dispute = event.data.object as any; await c.env.runMutation(internal.payments.updateTipStatus, { paymentIntentId: dispute.payment_intent as string, status: "disputed", updatedAt: Date.now(), }); break; } case "account.updated": { const acct = event.data.object as Stripe.Account; const payoutsEnabled = !!acct.payouts_enabled; const status = payoutsEnabled ? "active" : (acct.requirements?.disabled_reason ? "restricted" : "pending_verification"); await c.env.runMutation(internal.payments.syncConnectStatus, { stripeConnectId: acct.id, payoutsEnabled, stripeOnboardingComplete: !!acct.details_submitted, stripeConnectStatus: status, detailsSubmittedAt: acct.details_submitted ? Date.now() : undefined, }); // Keep fast UI flag in sync (optional mutation if you cache on users) await c.env.runMutation(internal.payments.updateTippingEnabledFromPayments, { stripeConnectId: acct.id, }); break; } default: // ignore others } return c.json({ received: true }); } catch (error) { console.error("Stripe webhook error:", error); return c.json({ error: "Webhook processing failed" }, 500); } });