Skip to content

Stripe Payments & Subscriptions

ClawPlate includes a complete subscription system with Stripe integration. Users can subscribe to plans, manage billing, and payments are automatically synchronized with your database.

How it works

1. Database Structure

Two main tables handle subscriptions:

sql
-- Available plans (Free, Pro, Enterprise)
CREATE TABLE plans (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  price INTEGER NOT NULL,        -- in cents (0, 2900, 9900)
  interval TEXT NOT NULL,        -- 'month'
  features JSONB,
  stripe_product_id TEXT,
  stripe_price_id TEXT,
  is_active BOOLEAN DEFAULT true
);

-- User subscriptions
CREATE TABLE user_subscriptions (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES user_profiles(id),
  plan_id TEXT REFERENCES plans(id),
  stripe_subscription_id TEXT UNIQUE,
  stripe_customer_id TEXT,
  status TEXT CHECK (status IN ('active', 'canceled', 'trialing', 'past_due')),
  current_period_start TIMESTAMP,
  current_period_end TIMESTAMP,
  cancel_at_period_end BOOLEAN DEFAULT false
);

2. Subscription Flow

  1. User selects a plan on pricing page
  2. Stripe Checkout opens with payment form
  3. Payment processed by Stripe
  4. Webhook fired to sync with database
  5. User gets access to subscribed features

Core Implementation

Plans API

typescript
// server/api/plans.get.ts
export default defineEventHandler(async (event) => {
  const { data: plans } = await supabaseAdmin
    .from('plans')
    .select('*')
    .eq('is_active', true)
    .order('price', { ascending: true })

  return { success: true, plans: plans || [] }
})

Subscription Composable

typescript
// composables/useSubscription.ts
export const useSubscription = () => {
  const supabase = useSupabaseClient()
  const user = useSupabaseUser()

  const getCurrentSubscription = async () => {
    const { data } = await supabase
      .from('user_subscriptions')
      .select(`
        *,
        plans:plan_id (name, price, interval, features)
      `)
      .eq('user_id', user.value.id)
      .in('status', ['active', 'trialing'])
      .single()

    return data
  }

  const createCheckoutSession = async (priceId: string) => {
    const response = await $fetch('/api/stripe/create-checkout-session', {
      method: 'POST',
      body: { 
        priceId, 
        userId: user.value.id, 
        userEmail: user.value.email 
      }
    })
    return response.data
  }

  return { getCurrentSubscription, createCheckoutSession }
}

Checkout Session Creation

typescript
// server/api/stripe/create-checkout-session.post.ts
export default defineEventHandler(async (event) => {
  const { priceId, userId, userEmail } = await readBody(event)
  
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [{
      price: priceId,
      quantity: 1,
    }],
    mode: 'subscription',
    success_url: `${process.env.SITE_URL}/dashboard?success=true`,
    cancel_url: `${process.env.SITE_URL}/pricing`,
    customer_email: userEmail,
    metadata: {
      userId: userId
    }
  })

  return { success: true, data: { url: session.url } }
})

Webhook Handler

typescript
// server/api/stripe/webhooks.post.ts
export default defineEventHandler(async (event) => {
  const body = await readRawBody(event)
  const signature = getHeader(event, 'stripe-signature')
  
  const stripeEvent = stripe.webhooks.constructEvent(
    body, signature, process.env.STRIPE_WEBHOOK_SECRET
  )
  
  switch (stripeEvent.type) {
    case 'customer.subscription.created':
      await handleSubscriptionCreated(stripeEvent.data.object)
      break
    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(stripeEvent.data.object)
      break
    case 'customer.subscription.deleted':
      await handleSubscriptionDeleted(stripeEvent.data.object)
      break
  }

  return { received: true }
})

async function handleSubscriptionCreated(subscription) {
  // Get user by email
  const customer = await stripe.customers.retrieve(subscription.customer)
  const { data: userProfile } = await supabaseAdmin
    .from('user_profiles')
    .select('id')
    .eq('email', customer.email)
    .single()

  // Get plan by price ID
  const { data: plan } = await supabaseAdmin
    .from('plans')
    .select('id')
    .eq('stripe_price_id', subscription.items.data[0]?.price.id)
    .single()

  // Create subscription in database
  await supabaseAdmin
    .from('user_subscriptions')
    .upsert({
      stripe_subscription_id: subscription.id,
      stripe_customer_id: subscription.customer,
      user_id: userProfile.id,
      plan_id: plan.id,
      status: subscription.status,
      current_period_start: new Date(subscription.current_period_start * 1000),
      current_period_end: new Date(subscription.current_period_end * 1000)
    })
}

Pricing Page

vue
<template>
  <div class="pricing-grid">
    <div 
      v-for="plan in pricingPlans" 
      :key="plan.id"
      class="pricing-card"
    >
      <h3>{{ plan.title }}</h3>
      <p class="price">{{ plan.price }}/{{ plan.period }}</p>
      
      <ul class="features">
        <li v-for="feature in plan.features" :key="feature">
          {{ feature }}
        </li>
      </ul>
      
      <button @click="handleSubscribe(plan)">
        Subscribe
      </button>
    </div>
  </div>
</template>

<script setup>
const { createCheckoutSession } = useSubscription()
const { data: plansData } = await useFetch('/api/plans')

const pricingPlans = computed(() => {
  return plansData.value.plans.map(plan => ({
    id: plan.id,
    title: plan.name,
    price: plan.price === 0 ? 'Free' : `$${plan.price / 100}`,
    period: plan.interval,
    features: plan.features,
    priceId: plan.stripe_price_id
  }))
})

const handleSubscribe = async (plan) => {
  if (plan.price === 'Free') return
  
  const result = await createCheckoutSession(plan.priceId)
  if (result?.url) {
    window.location.href = result.url
  }
}
</script>

Advanced Features

Billing Portal

Let users manage their billing:

typescript
// server/api/stripe/create-portal-session.post.ts
export default defineEventHandler(async (event) => {
  const { userId } = await readBody(event)
  
  // Get user's Stripe customer ID
  const { data: subscription } = await supabaseAdmin
    .from('user_subscriptions')
    .select('stripe_customer_id')
    .eq('user_id', userId)
    .single()

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: subscription.stripe_customer_id,
    return_url: `${process.env.SITE_URL}/dashboard`,
  })

  return { success: true, data: { url: portalSession.url } }
})

Subscription Management

typescript
// Cancel subscription
const cancelSubscription = async (subscriptionId: string) => {
  await $fetch('/api/subscription/cancel', {
    method: 'POST',
    body: { subscriptionId }
  })
}

// Resume subscription
const resumeSubscription = async (subscriptionId: string) => {
  await $fetch('/api/subscription/resume', {
    method: 'POST',
    body: { subscriptionId }
  })
}

Configuration

Environment Variables

bash
# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Supabase
SUPABASE_URL=https://...
SUPABASE_SERVICE_ROLE_KEY=eyJ...

# Site
SITE_URL=https://yoursite.com

Stripe Setup

  1. Create Products in Stripe Dashboard
  2. Set up Webhooks pointing to /api/stripe/webhooks
  3. Configure Plans in your database with Stripe IDs

Webhook Events

Enable these webhook events in Stripe:

  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed

Security

Webhook Verification

Always verify webhook signatures:

typescript
const stripeEvent = stripe.webhooks.constructEvent(
  body, signature, webhookSecret
)

Database Security

Use Row Level Security (RLS) in Supabase:

sql
-- Users can only see their own subscriptions
CREATE POLICY "Users can view own subscriptions" ON user_subscriptions
FOR SELECT USING (auth.uid() = user_id);

Best Practices

Error Handling

typescript
try {
  const session = await createCheckoutSession(priceId)
  window.location.href = session.url
} catch (error) {
  toast.error('Payment failed. Please try again.')
}

Status Management

Handle all subscription statuses:

typescript
const getSubscriptionStatus = (status: string) => {
  switch (status) {
    case 'active': return 'Active'
    case 'trialing': return 'Trial'
    case 'past_due': return 'Payment Due'
    case 'canceled': return 'Canceled'
    default: return 'Unknown'
  }
}

Testing

Use Stripe's test mode and test cards:

javascript
// Test card numbers
const testCards = {
  success: '4242424242424242',
  declined: '4000000000000002',
  requiresAuth: '4000002500003155'
}

Monitoring

Track these metrics:

  • Subscription creation/cancellation rates
  • Failed payment attempts
  • Webhook delivery success
  • Revenue trends

The payment system handles everything automatically once configured. Users can subscribe, manage billing, and you get real-time synchronization between Stripe and your database.

Built with love by mhdevfr