Stripe Payments Integration

SkillCommerce & finance

A skill for commerce & finance by thelobbi.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Stripe Payments Integration skill

What this skill tells your AI

The instructions your AI receives, as published by thelobbi/claude in .claude/skills/stripe-payments/SKILL.md and read by ahel’s review.

Stripe payments and subscription management skill for Lobbi member management system. Activates when working with payment processing, subscription management, or billing workflows.

Triggers: stripe, payment, subscription, billing, invoice, checkout, webhook, customer, payment-method, card

Use this skill when:

  • Implementing Stripe checkout flows
  • Managing subscriptions and billing cycles
  • Handling Stripe webhooks
  • Processing payments and refunds
  • Managing customer payment methods
  • Implementing usage-based billing

Allowed Tools

  • stripe (Stripe Node.js SDK)
  • express (webhook endpoints)
  • prisma (payment data persistence)
  • typescript (type safety)
  • jest (payment flow testing)

Instructions

Core Principles

  1. Idempotency

    • Use Stripe idempotency keys for all mutations
    • Store operation results in database
    • Handle duplicate webhook events gracefully
    • Never charge customers twice
  2. Webhook Security

    • Always verify webhook signatures
    • Use raw body for signature verification
    • Handle events asynchronously
    • Implement retry logic with exponential backoff
  3. Error Handling

    • Gracefully handle payment failures
    • Log all Stripe API errors
    • Provide user-friendly error messages
    • Implement fallback for failed webhooks
  4. Testing Strategy

    • Use Stripe test mode for development
    • Test webhook events with Stripe CLI
    • Mock Stripe API in unit tests
    • Test payment flows end-to-end

Architecture Layers

Client → Checkout Session → Stripe → Webhook → Service → Repository → Database
         (create intent)     (process) (notify)  (update)  (persist)

Implementation Checklist

  • Configure Stripe API keys (test + production)
  • Set up webhook endpoint with signature verification
  • Create Stripe customer on organization signup
  • Implement subscription creation/update/cancel flows
  • Handle payment method updates
  • Implement invoice generation and payment
  • Add webhook event processing (async)
  • Test all payment scenarios
  • Add monitoring and alerting for failed payments

Code Examples

1. Stripe Configuration

// backend/src/config/stripe.config.ts

import Stripe from 'stripe';

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('STRIPE_SECRET_KEY is required');
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-11-20.acacia',
  typescript: true,
  appInfo: {
    name: 'Lobbi Member Management',
    version: '1.0.0',
  },
});

export const STRIPE_CONFIG = {
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  successUrl: process.env.STRIPE_SUCCESS_URL || 'http://localhost:3000/success',
  cancelUrl: process.env.STRIPE_CANCEL_URL || 'http://localhost:3000/cancel',
};

// Price IDs from Stripe Dashboard
export const STRIPE_PRICES = {
  BASIC_MONTHLY: process.env.STRIPE_PRICE_BASIC_MONTHLY!,
  PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY!,
  ENTERPRISE_MONTHLY: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!,
};

2. Stripe Customer Management

// backend/src/services/stripe-customer.service.ts

import { stripe } from '../config/stripe.config';
import { OrganizationRepository } from '../repositories/organization.repository';
import Stripe from 'stripe';

export class StripeCustomerService {
  constructor(private organizationRepository: OrganizationRepository) {}

  /**
   * Create Stripe customer for organization
   */
  async createCustomer(
    organizationId: string,
    email: string,
    name: string,
    metadata?: Record<string, string>
  ): Promise<string> {
    const customer = await stripe.customers.create({
      email,
      name,
      metadata: {
        organizationId,
        ...metadata,
      },
    });

    // Store customer ID in database
    await this.organizationRepository.update(organizationId, {
      stripeCustomerId: customer.id,
    });

    return customer.id;
  }

  /**
   * Get or create Stripe customer
   */
  async getOrCreateCustomer(organizationId: string): Promise<string> {
    const organization = await this.organizationRepository.findById(organizationId);
    if (!organization) {
      throw new Error('Organization not found');
    }

    // Return existing customer ID if present
    if (organization.stripeCustomerId) {
      return organization.stripeCustomerId;
    }

    // Create new customer
    return this.createCustomer(
      organizationId,
      organization.email,
      organization.name
    );
  }

  /**
   * Update customer information
   */
  async updateCustomer(
    customerId: string,
    data: {
      email?: string;
      name?: string;
      address?: Stripe.AddressParam;
      metadata?: Record<string, string>;
    }
  ): Promise<Stripe.Customer> {
    return stripe.customers.update(customerId, data);
  }

  /**
   * Attach payment method to customer
   */
  async attachPaymentMethod(
    customerId: string,
    paymentMethodId: string,
    setAsDefault = true
  ): Promise<void> {
    await stripe.paymentMethods.attach(paymentMethodId, {
      customer: customerId,
    });

    if (setAsDefault) {
      await stripe.customers.update(customerId, {
        invoice_settings: {
          default_payment_method: paymentMethodId,
        },
      });
    }
  }

  /**
   * List customer payment methods
   */
  async listPaymentMethods(customerId: string): Promise<Stripe.PaymentMethod[]> {
    const paymentMethods = await stripe.paymentMethods.list({
      customer: customerId,
      type: 'card',
    });

    return paymentMethods.data;
  }

  /**
   * Delete customer (careful!)
   */
  async deleteCustomer(customerId: string): Promise<void> {
    await stripe.customers.del(customerId);
  }
}

3. Subscription Management Service

// backend/src/services/stripe-subscription.service.ts

import { stripe } from '../config/stripe.config';
import { SubscriptionRepository } from '../repositories/subscription.repository';
import Stripe from 'stripe';

export interface CreateSubscriptionDto {
  organizationId: string;
  customerId: string;
  priceId: string;
  quantity?: number;
  trialDays?: number;
  metadata?: Record<string, string>;
}

export class StripeSubscriptionService {
  constructor(private subscriptionRepository: SubscriptionRepository) {}

  /**
   * Create subscription
   */
  async createSubscription(data: CreateSubscriptionDto): Promise<Stripe.Subscription> {
    const subscriptionParams: Stripe.SubscriptionCreateParams = {
      customer: data.customerId,
      items: [
        {
          price: data.priceId,
          quantity: data.quantity || 1,
        },
      ],
      metadata: {
        organizationId: data.organizationId,
        ...data.metadata,
      },
      payment_behavior: 'default_incomplete',
      payment_settings: { save_default_payment_method: 'on_subscription' },
      expand: ['latest_invoice.payment_intent'],
    };

    if (data.trialDays) {
      subscriptionParams.trial_period_days = data.trialDays;
    }

    const subscription = await stripe.subscriptions.create(subscriptionParams);

    // Store in database
    await this.subscriptionRepository.create({
      organizationId: data.organizationId,
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: data.customerId,
      stripePriceId: data.priceId,
      status: subscription.status,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    });

    return subscription;
  }

  /**
   * Update subscription (change plan, quantity, etc.)
   */
  async updateSubscription(
    subscriptionId: string,
    data: {
      priceId?: string;
      quantity?: number;
      metadata?: Record<string, string>;
    }
  ): Promise<Stripe.Subscription> {
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);

    const updateParams: Stripe.SubscriptionUpdateParams = {
      metadata: data.metadata,
    };

    // Update subscription items if price or quantity changed
    if (data.priceId || data.quantity) {
      updateParams.items = [
        {
          id: subscription.items.data[0].id,
          price: data.priceId || subscription.items.data[0].price.id,
          quantity: data.quantity,
        },
      ];
      updateParams.proration_behavior = 'always_invoice';
    }

    const updatedSubscription = await stripe.subscriptions.update(
      subscriptionId,
      updateParams
    );

    // Update in database
    await this.subscriptionRepository.updateByStripeId(subscriptionId, {
      stripePriceId: data.priceId,
      status: updatedSubscription.status,
      currentPeriodStart: new Date(updatedSubscription.current_period_start * 1000),
      currentPeriodEnd: new Date(updatedSubscription.current_period_end * 1000),
    });

    return updatedSubscription;
  }

  /**
   * Cancel subscription
   */
  async cancelSubscription(
    subscriptionId: string,
    cancelImmediately = false
  ): Promise<Stripe.Subscription> {
    let subscription: Stripe.Subscription;

    if (cancelImmediately) {
      subscription = await stripe.subscriptions.cancel(subscriptionId);
    } else {
      // Cancel at period end
      subscription = await stripe.subscriptions.update(subscriptionId, {
        cancel_at_period_end: true,
      });
    }

    // Update in database
    await this.subscriptionRepository.updateByStripeId(subscriptionId, {
      status: subscription.status,
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      canceledAt: subscription.canceled_at
        ? new Date(subscription.canceled_at * 1000)
        : null,
    });

    return subscription;
  }

  /**
   * Reactivate canceled subscription
   */
  async reactivateSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
    const subscription = await stripe.subscriptions.update(subscriptionId, {
      cancel_at_period_end: false,
    });

    await this.subscriptionRepository.updateByStripeId(subscriptionId, {
      status: subscription.status,
      cancelAtPeriodEnd: false,
      canceledAt: null,
    });

    return subscription;
  }

  /**
   * Get subscription with details
   */
  async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
    return stripe.subscriptions.retrieve(subscriptionId, {
      expand: ['latest_invoice', 'customer', 'default_payment_method'],
    });
  }

  /**
   * List subscriptions for customer
   */
  async listSubscriptions(customerId: string): Promise<Stripe.Subscription[]> {
    const subscriptions = await stripe.subscriptions.list({
      customer: customerId,
      status: 'all',
      expand: ['data.latest_invoice'],
    });

    return subscriptions.data;
  }
}

4. Checkout Session Service

// backend/src/services/stripe-checkout.service.ts

import { stripe, STRIPE_CONFIG } from '../config/stripe.config';
import Stripe from 'stripe';

export interface CreateCheckoutSessionDto {
  organizationId: string;
  customerId: string;
  priceId: string;
  quantity?: number;
  successUrl?: string;
  cancelUrl?: string;
  metadata?: Record<string, string>;
}

export class StripeCheckoutService {
  /**
   * Create checkout session for new subscription
   */
  async createCheckoutSession(
    data: CreateCheckoutSessionDto
  ): Promise<Stripe.Checkout.Session> {
    const session = await stripe.checkout.sessions.create({
      customer: data.customerId,
      mode: 'subscription',
      line_items: [
        {
          price: data.priceId,
          quantity: data.quantity || 1,
        },
      ],
      success_url: data.successUrl || STRIPE_CONFIG.successUrl,
      cancel_url: data.cancelUrl || STRIPE_CONFIG.cancelUrl,
      metadata: {
        organizationId: data.organizationId,
        ...data.metadata,
      },
      subscription_data: {
        metadata: {
          organizationId: data.organizationId,
        },
      },
    });

    return session;
  }

  /**
   * Create checkout session for payment method update
   */
  async createPaymentMethodUpdateSession(
    customerId: string,
    returnUrl: string
  ): Promise<Stripe.Checkout.Session> {
    return stripe.checkout.sessions.create({
      customer: customerId,
      mode: 'setup',
      success_url: returnUrl,
      cancel_url: returnUrl,
    });
  }

  /**
   * Create portal session for customer self-service
   */
  async createPortalSession(
    customerId: string,
    returnUrl: string
  ): Promise<Stripe.BillingPortal.Session> {
    return stripe.billingPortal.sessions.create({
      customer: customerId,
      return_url: returnUrl,
    });
  }
}

5. Webhook Handler

// backend/src/services/stripe-webhook.service.ts

import { stripe, STRIPE_CONFIG } from '../config/stripe.config';
import { SubscriptionRepository } from '../repositories/subscription.repository';
import { InvoiceRepository } from '../repositories/invoice.repository';
import Stripe from 'stripe';

export class StripeWebhookService {
  constructor(
    private subscriptionRepository: SubscriptionRepository,
    private invoiceRepository: InvoiceRepository
  ) {}

  /**
   * Verify webhook signature
   */
  verifyWebhookSignature(payload: string, signature: string): Stripe.Event {
    try {
      return stripe.webhooks.constructEvent(
        payload,
        signature,
        STRIPE_CONFIG.webhookSecret
      );
    } catch (err) {
      throw new Error(`Webhook signature verification failed: ${err.message}`);
    }
  }

  /**
   * Handle webhook event
   */
  async handleWebhookEvent(event: Stripe.Event): Promise<void> {
    console.log(`Processing webhook event: ${event.type}`);

    switch (event.type) {
      // Subscription events
      case 'customer.subscription.created':
        await this.handleSubscriptionCreated(event.data.object as Stripe.Subscription);
        break;
      case 'customer.subscription.updated':
        await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
        break;
      case 'customer.subscription.deleted':
        await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;

      // Invoice events
      case 'invoice.paid':
        await this.handleInvoicePaid(event.data.object as Stripe.Invoice);
        break;
      case 'invoice.payment_failed':
        await this.handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
        break;
      case 'invoice.finalized':
        await this.handleInvoiceFinalized(event.data.object as Stripe.Invoice);
        break;

      // Payment events
      case 'payment_intent.succeeded':
        await this.handlePaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent);
        break;
      case 'payment_intent.payment_failed':
        await this.handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }
  }

  private async handleSubscriptionCreated(subscription: Stripe.Subscription): Promise<void> {
    const organizationId = subscription.metadata.organizationId;

    await this.subscriptionRepository.upsert({
      organizationId,
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: subscription.customer as string,
      stripePriceId: subscription.items.data[0].price.id,
      status: subscription.status,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      canceledAt: subscription.canceled_at
        ? new Date(subscription.canceled_at * 1000)
        : null,
    });
  }

  private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
    await this.subscriptionRepository.updateByStripeId(subscription.id, {
      status: subscription.status,
      stripePriceId: subscription.items.data[0].price.id,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      canceledAt: subscription.canceled_at
        ? new Date(subscription.canceled_at * 1000)
        : null,
    });
  }

  private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
    await this.subscriptionRepository.updateByStripeId(subscription.id, {
      status: 'canceled',
      canceledAt: new Date(),
    });
  }

  private async handleInvoicePaid(invoice: Stripe.Invoice): Promise<void> {
    await this.invoiceRepository.upsert({
      stripeInvoiceId: invoice.id,
      stripeCustomerId: invoice.customer as string,
      stripeSubscriptionId: invoice.subscription as string,
      amount: invoice.amount_paid,
      currency: invoice.currency,
      status: 'paid',
      paidAt: invoice.status_transitions.paid_at
        ? new Date(invoice.status_transitions.paid_at * 1000)
        : null,
      invoiceUrl: invoice.hosted_invoice_url,
      invoicePdf: invoice.invoice_pdf,
    });
  }

  private async handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
    await this.invoiceRepository.updateByStripeId(invoice.id, {
      status: 'payment_failed',
    });

    // TODO: Send notification to customer about failed payment
  }

  private async handleInvoiceFinalized(invoice: Stripe.Invoice): Promise<void> {
    await this.invoiceRepository.upsert({
      stripeInvoiceId: invoice.id,
      stripeCustomerId: invoice.customer as string,
      stripeSubscriptionId: invoice.subscription as string,
      amount: invoice.amount_due,
      currency: invoice.currency,
      status: invoice.status,
      invoiceUrl: invoice.hosted_invoice_url,
      invoicePdf: invoice.invoice_pdf,
    });
  }

  private async handlePaymentIntentSucceeded(paymentIntent: Stripe.PaymentIntent): Promise<void> {
    console.log(`Payment succeeded: ${paymentIntent.id}`);
    // Handle one-time payments if needed
  }

  private async handlePaymentIntentFailed(paymentIntent: Stripe.PaymentIntent): Promise<void> {
    console.log(`Payment failed: ${paymentIntent.id}`);
    // Handle failed payments
  }
}

6. Webhook Controller

// backend/src/controllers/stripe-webhook.controller.ts

import { Request, Response } from 'express';
import { StripeWebhookService } from '../services/stripe-webhook.service';

export class StripeWebhookController {
  constructor(private webhookService: StripeWebhookService) {}

  async handleWebhook(req: Request, res: Response): Promise<void> {
    const signature = req.headers['stripe-signature'] as string;

    if (!signature) {
      res.status(400).send('Missing stripe-signature header');
      return;
    }

    try {
      // Verify webhook signature
      const event = this.webhookService.verifyWebhookSignature(
        req.body,
        signature
      );

      // Process event asynchronously
      this.webhookService.handleWebhookEvent(event).catch(error => {
        console.error('Error processing webhook event:', error);
      });

      // Acknowledge receipt immediately
      res.json({ received: true });
    } catch (err) {
      console.error('Webhook error:', err.message);
      res.status(400).send(`Webhook Error: ${err.message}`);
    }
  }
}

7. Webhook Route Setup

// backend/src/routes/stripe.routes.ts

import { Router } from 'express';
import express from 'express';
import { StripeWebhookController } from '../controllers/stripe-webhook.controller';

export const createStripeRouter = (
  webhookController: StripeWebhookController
): Router => {
  const router = Router();

  // Webhook endpoint - MUST use raw body for signature verification
  router.post(
    '/webhook',
    express.raw({ type: 'application/json' }),
    webhookController.handleWebhook.bind(webhookController)
  );

  return router;
};

// In main app.ts, configure BEFORE other middleware:
// app.use('/api/stripe', createStripeRouter(stripeWebhookController));
// app.use(express.json()); // JSON parsing for other routes

8. Subscription Controller

// backend/src/controllers/subscription.controller.ts

import { Response, NextFunction } from 'express';
import { TenantRequest } from '../types/tenant.types';
import { StripeSubscriptionService } from '../services/stripe-subscription.service';
import { StripeCheckoutService } from '../services/stripe-checkout.service';
import { StripeCustomerService } from '../services/stripe-customer.service';

export class SubscriptionController {
  constructor(
    private subscriptionService: StripeSubscriptionService,
    private checkoutService: StripeCheckoutService,
    private customerService: StripeCustomerService
  ) {}

  async createCheckoutSession(req: TenantRequest, res: Response, next: NextFunction) {
    try {
      const { priceId, quantity } = req.body;
      const organizationId = req.tenant.organizationId;

      // Get or create Stripe customer
      const customerId = await this.customerService.getOrCreateCustomer(organizationId);

      // Create checkout session
      const session = await this.checkoutService.createCheckoutSession({
        organizationId,
        customerId,
        priceId,
        quantity,
        successUrl: `${req.headers.origin}/subscription/success`,
        cancelUrl: `${req.headers.origin}/subscription/cancel`,
      });

      res.json({ sessionUrl: session.url });
    } catch (error) {
      next(error);
    }
  }

  async getCurrentSubscription(req: TenantRequest, res: Response, next: NextFunction) {
    try {
      const organizationId = req.tenant.organizationId;
      const customerId = await this.customerService.getOrCreateCustomer(organizationId);

      const subscriptions = await this.subscriptionService.listSubscriptions(customerId);
      const activeSubscription = subscriptions.find(s =>
        ['active', 'trialing', 'past_due'].includes(s.status)
      );

      res.json({ data: activeSubscription || null });
    } catch (error) {
      next(error);
    }
  }

  async cancelSubscription(req: TenantRequest, res: Response, next: NextFunction) {
    try {
      const { subscriptionId } = req.params;
      const { immediate } = req.body;

      const subscription = await this.subscriptionService.cancelSubscription(
        subscriptionId,
        immediate
      );

      res.json({ data: subscription });
    } catch (error) {
      next(error);
    }
  }

  async createPortalSession(req: TenantRequest, res: Response, next: NextFunction) {
    try {
      const organizationId = req.tenant.organizationId;
      const customerId = await this.customerService.getOrCreateCustomer(organizationId);

      const session = await this.checkoutService.createPortalSession(
        customerId,
        `${req.headers.origin}/settings/billing`
      );

      res.json({ portalUrl: session.url });
    } catch (error) {
      next(error);
    }
  }
}

9. Testing Stripe Integration

// backend/src/services/__tests__/stripe-subscription.service.test.ts

import Stripe from 'stripe';
import { StripeSubscriptionService } from '../stripe-subscription.service';
import { stripe } from '../../config/stripe.config';

// Mock Stripe
jest.mock('../../config/stripe.config', () => ({
  stripe: {
    subscriptions: {
      create: jest.fn(),
      update: jest.fn(),
      cancel: jest.fn(),
      retrieve: jest.fn(),
    },
  },
}));

describe('StripeSubscriptionService', () => {
  let service: StripeSubscriptionService;
  let mockSubscriptionRepository: any;

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
21
Forks
2
Last commit
Sep 2026

ahel review

  • S4info
    community integration, published by thelobbi, not stripe
  • K1binfo
    installs-packages

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Catalog kind
skill
Gateway key
stripe-payments-thelobbi
Source
github.com/thelobbi/claude