Master Stripe Connect for marketplace payments in RidenDine. Use when: (1) onboarding chefs to Stripe Connect, (2) implementing payment flows with platform fees, (3) handling webhooks, (4) managing payouts, (5) debugging payment issues. Key insight: RidenDine uses Standard Connect accounts with 15% platform fee deducted automatically via application_fee_amount.
RidenDine is a marketplace connecting customers with chefs. Stripe Connect enables:
Use this skill when:
Account Types:
Payment Flow:
Customer → Stripe Checkout → Chef's Connected Account (85%) + Platform Fee (15%)
Key Components:
create_connect_account - Onboards chefscreate_checkout_session - Creates payment sessionswebhook_stripe - Handles payment eventschefs.stripe_account_id stores connected account IDsLocation: backend/supabase/functions/create_connect_account/index.ts
Flow:
profile_idstripe_account_idExample Implementation:
import Stripe from 'https://esm.sh/[email protected]';
import { createClient } from 'https://esm.sh/@supabase/[email protected]';
Deno.serve(async (req) => {
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2023-10-16',
});
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
const { profile_id, email, refresh_url, return_url } = await req.json();
// Create Standard Connect account
const account = await stripe.accounts.create({
type: 'standard',
email,
metadata: { profile_id },
});
// Create Account Link for onboarding
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: refresh_url || 'https://ridendine.com/chef/onboarding',
return_url: return_url || 'https://ridendine.com/chef/dashboard',
type: 'account_onboarding',
});
// Update database with stripe_account_id
const { error } = await supabase
.from('chefs')
.update({ stripe_account_id: account.id })
.eq('profile_id', profile_id);
if (error) throw error;
return new Response(
JSON.stringify({ url: accountLink.url }),
{ headers: { 'Content-Type': 'application/json' } }
);
});
Database Update:
-- chefs table already has stripe_account_id column
-- Migration: backend/supabase/migrations/20240101000000_init.sql
ALTER TABLE chefs ADD COLUMN stripe_account_id TEXT UNIQUE;
Frontend Integration (Next.js):
// apps/web/app/chef/onboarding/page.tsx
async function connectStripe() {
const response = await fetch('/api/connect-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_id: user.id,
email: user.email,
refresh_url: window.location.href,
return_url: `${window.location.origin}/chef/dashboard`,
}),
});
const { url } = await response.json();
window.location.href = url; // Redirect to Stripe onboarding
}
Location: backend/supabase/functions/create_checkout_session/index.ts
Flow:
stripe_account_idapplication_fee_amountExample Implementation:
import Stripe from 'https://esm.sh/[email protected]';
import { createClient } from 'https://esm.sh/@supabase/[email protected]';
Deno.serve(async (req) => {
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2023-10-16',
});
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
const { order_id } = await req.json();
// Fetch order details from database
const { data: order, error: orderError } = await supabase
.from('orders')
.select('*, chefs!inner(stripe_account_id), order_items(*)')
.eq('id', order_id)
.single();
if (orderError) throw orderError;
if (!order.chefs.stripe_account_id) {
throw new Error('Chef has not completed Stripe onboarding');
}
// Calculate total (server-side validation)
const totalCents = order.order_items.reduce(
(sum: number, item: any) => sum + item.price_cents * item.quantity,
0
);
// Platform fee: 15% of total
const platformFeeCents = Math.floor(totalCents * 0.15);
// Create Stripe Checkout Session
const session = await stripe.checkout.sessions.create(
{
mode: 'payment',
line_items: order.order_items.map((item: any) => ({
price_data: {
currency: 'usd',
product_data: { name: item.dish_name || 'Dish' },
unit_amount: item.price_cents,
},
quantity: item.quantity,
})),
payment_intent_data: {
application_fee_amount: platformFeeCents,
metadata: { order_id: order.id },
},
success_url: `${req.headers.get('origin')}/order/${order.id}?success=true`,
cancel_url: `${req.headers.get('origin')}/checkout?canceled=true`,
metadata: { order_id: order.id },
},
{
stripeAccount: order.chefs.stripe_account_id, // Payment goes to chef's account
}
);
return new Response(
JSON.stringify({ url: session.url }),
{ headers: { 'Content-Type': 'application/json' } }
);
});
Frontend Integration:
// apps/web/app/checkout/page.tsx
async function handleCheckout() {
// Create draft order in database first
const { data: order } = await supabase
.from('orders')
.insert({
customer_id: user.id,
chef_id: cart.chefId,
total_cents: cart.total,
status: 'draft',
})
.select()
.single();
// Create order items
await supabase.from('order_items').insert(
cart.items.map((item) => ({
order_id: order.id,
dish_id: item.id,
quantity: item.quantity,
price_cents: item.price * 100,
}))
);
// Create Stripe Checkout Session
const response = await fetch('/api/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: order.id }),
});
const { url } = await response.json();
window.location.href = url; // Redirect to Stripe Checkout
}
Location: backend/supabase/functions/webhook_stripe/index.ts
Critical: Stripe webhook signature verification prevents unauthorized events.
Events to Handle:
checkout.session.completed → Update order status to "placed"payment_intent.succeeded → Confirm payment successaccount.updated → Chef completed onboarding or updated detailspayout.paid → Chef received payoutExample Implementation:
import Stripe from 'https://esm.sh/[email protected]';
import { createClient } from 'https://esm.sh/@supabase/[email protected]';
Deno.serve(async (req) => {
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2023-10-16',
});
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
const signature = req.headers.get('stripe-signature');
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
if (!signature || !webhookSecret) {
return new Response('Missing signature or secret', { status: 400 });
}
const body = await req.text();
// ⚠️ CRITICAL: Verify webhook signature
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// Handle different event types
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.order_id;
if (orderId) {
// Update order status to "placed" (payment confirmed)
await supabase
.from('orders')
.update({ status: 'placed', payment_status: 'paid' })
.eq('id', orderId);
}
break;
}
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const orderId = paymentIntent.metadata?.order_id;
if (orderId) {
await supabase
.from('orders')
.update({ payment_status: 'paid' })
.eq('id', orderId);
}
break;
}
case 'account.updated': {
const account = event.data.object as Stripe.Account;
const profileId = account.metadata?.profile_id;
if (profileId && account.charges_enabled) {
// Chef completed onboarding and can accept payments
await supabase
.from('chefs')
.update({
stripe_account_id: account.id,
stripe_onboarding_complete: true,
})
.eq('profile_id', profileId);
}
break;
}
case 'payout.paid': {
const payout = event.data.object as Stripe.Payout;
// Log payout for chef's records (optional)
console.log(`Payout ${payout.id} paid to account ${payout.destination}`);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
});
});
Webhook Configuration:
Development: Use Stripe CLI to forward webhooks
stripe listen --forward-to http://localhost:54321/functions/v1/webhook_stripe
Production: Configure webhook endpoint in Stripe Dashboard
https://<project-id>.supabase.co/functions/v1/webhook_stripecheckout.session.completed, payment_intent.succeeded, account.updated, payout.paidSTRIPE_WEBHOOK_SECRETTest Cards:
4242 4242 4242 42424000 0000 0000 00024000 0027 6000 3184Test Environment Variables:
# .env.local (Development)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # From Stripe CLI or Dashboard
Supabase Edge Function Secrets:
# Set via Supabase Dashboard → Settings → Secrets
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Testing Checklist:
chefs.stripe_account_id populated after onboardingcheckout.session.completed eventSymptom: Checkout fails with error about missing stripe_account_id
Cause: Chef's connected account not created or onboarding incomplete
Fix:
SELECT stripe_account_id FROM chefs WHERE id = '<chef-id>'create_connect_account Edge FunctionSymptom: Order status stays "draft" after payment
Cause: Webhook endpoint not configured or signature verification failing
Fix:
stripe listen --forward-to ...supabase functions logs webhook_stripeSTRIPE_WEBHOOK_SECRET matches Stripe Dashboardstripe trigger checkout.session.completed
Symptom: Chef receives full payment amount (100%), not 85%
Cause: application_fee_amount not set correctly
Fix:
create_checkout_session/index.tsplatformFeeCents = Math.floor(totalCents * 0.15)payment_intent_data.application_fee_amount is passedSymptom: Stripe API returns 404 for connected account
Cause: stripe_account_id in database doesn't exist in Stripe
Fix:
stripe_account_id from databasecreate_connect_accountstripe_account_id from Stripe Dashboardstripe.webhooks.constructEvent()stripe_account_id exists before checkoutbackend/supabase/functions/