Implement Stripe Connect payments for PhotoVault marketplace using destination charges...
When this skill activates, you MUST follow the expert workflow before writing any code:
Spawn Domain Expert using the Task tool with this prompt:
Read the expert prompt at: C:\Users\natha\Stone-Fence-Brain\VENTURES\PhotoVault\claude\experts\stripe-expert.md
Then research the codebase and write an implementation plan to: docs/claude/plans/stripe-[task-name]-plan.md
Task: [describe the user's request]
Spawn QA Critic after expert returns, using Task tool:
Read the QA critic prompt at: C:\Users\natha\Stone-Fence-Brain\VENTURES\PhotoVault\claude\experts\qa-critic-expert.md
Review the plan at: docs/claude/plans/stripe-[task-name]-plan.md
Write critique to: docs/claude/plans/stripe-[task-name]-critique.md
Present BOTH plan and critique to user - wait for approval before implementing
DO NOT read files and start coding. DO NOT rationalize that "this is simple." Follow the workflow.
Webhooks can fire multiple times. API calls can timeout and retry. Your code must handle duplicates gracefully.
async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
const existing = await db.payments.findOne({
stripe_payment_intent_id: paymentIntent.id
})
if (existing) {
console.log(`Payment ${paymentIntent.id} already processed, skipping`)
return
}
await db.payments.create({
stripe_payment_intent_id: paymentIntent.id,
amount: paymentIntent.amount,
status: 'completed'
})
}
PhotoVault uses Stripe Connect with Express accounts. Destination charges:
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
payment_intent_data: {
application_fee_amount: platformFeeCents, // 50% to PhotoVault
transfer_data: {
destination: photographer.stripe_connect_account_id,
},
},
})
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
webhookSecret
)
Not verifying webhook signatures
// WRONG
const event = JSON.parse(req.body)
// RIGHT
const event = stripe.webhooks.constructEvent(body, sig, secret)
Not handling duplicate events
// WRONG
case 'checkout.session.completed':
await createCommission(session) // Duplicates on retry!
// RIGHT
case 'checkout.session.completed':
const exists = await db.commissions.findOne({
stripe_session_id: session.id
})
if (!exists) {
await createCommission(session)
}
Processing webhooks synchronously
// WRONG: Slow response, timeout risk
app.post('/webhook', async (req, res) => {
await sendEmails()
await updateDatabase()
res.json({ received: true })
})
// RIGHT: Acknowledge fast
app.post('/webhook', async (req, res) => {
await queueForProcessing(event)
res.json({ received: true })
})
Using transfers when you should use destination charges
// WRONG: Two API calls, race condition risk
const charge = await stripe.paymentIntents.create({ amount: 10000 })
const transfer = await stripe.transfers.create({ amount: 5000, destination: accountId })
// RIGHT: Atomic destination charge
const session = await stripe.checkout.sessions.create({
payment_intent_data: {
application_fee_amount: 5000,
transfer_data: { destination: accountId }
}
})
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session)
break
case 'invoice.paid':
await handleInvoicePaid(event.data.object as Stripe.Invoice)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice)
break
}
} catch (err) {
console.error(`Error processing ${event.type}:`, err)
}
return NextResponse.json({ received: true })
}
| Product | ID | Price |
|---|---|---|
| Year Package | prod_TV5f6EOT5K3wKt |
$100 + $8/mo |
| 6-Month Package | prod_TV5f1eAehZIlA2 |
$50 + $8/mo |
| 6-Month Trial | prod_TV5fYvY8l0WaaV |
$20 one-time |
| Client Monthly | prod_TV5gXyg5nNn635 |
$8/month |
| Direct Monthly | prod_TV6BkuQUCil1ZD |
$8/month (0% commission) |
| Platform Fee | prod_TV5evkNAa2Ezo5 |
$22/month |
src/
├── lib/stripe.ts # Stripe config & helpers
├── app/api/
│ ├── stripe/
│ │ ├── create-checkout/ # Checkout session creation
│ │ ├── connect/ # Connect onboarding
│ │ └── platform-subscription/
│ └── webhooks/stripe/ # Webhook handler
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_PLATFORM_MONTHLY=price_...
PHOTOGRAPHER_COMMISSION_RATE = 0.50)2025-09-30.clover# Forward webhooks to local server
stripe listen --forward-to localhost:3002/api/webhooks/stripe
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
| Card | Number | Result |
|---|---|---|
| Success | 4242 4242 4242 4242 |
Payment succeeds |
| Decline | 4000 0000 0000 0002 |
Card declined |
| 3D Secure | 4000 0025 0000 3155 |
Requires auth |
| Insufficient | 4000 0000 0000 9995 |
Insufficient funds |