Fragno
Stripe

Subscriptions

Create, manage, and cancel subscriptions

Data Model

The fragment maintains a local database table (subscription_stripe) that tracks subscription state, with a subscription linked back to an entity in your application through the referenceId.

In Stripe's model:

  • A Subscription connects a Customer (identified by your referenceId) to a Price
  • A Product can have multiple Prices (e.g., monthly vs annual)

Stripe also has a concept of "plans" but this API is superseded by the more flexible Prices API. This fragment uses the latter.

Mutations

Stripe is the source of truth for subscription state. Never modify subscription data in the database tables. All subscription changes (create, update, cancel) should be performed through either this fragments /subscription/upgrade or /subscription/cancel hooks or directly through the Stripe API. The database table is updated automatically via webhooks or manually through the syncStripeSubscription service.

Security

To prevent exposing sensitive information make sure to only expose Stripe Price IDs to end-users, Stripe Customer IDs, Product IDs, and Subscription IDs should remain private.

Payment Collection

Collecting payments from users happens through a redirect to a Stripe Checkout page. Updating or cancelling a subscription happens through a redirect to a Stripe Customer Portal.

Database Schema

The Stripe fragment maintains a subscription_stripe table in your database to track subscription state. This allows you to query subscription data without making API calls to Stripe and ensures your application remains responsive even if Stripe is temporarily unavailable.

The subscription_stripe table has a reference_id column that is used to link a subscription to an application-specific identifier like a user ID or organization ID.

For setting up Fragno DB schemas, see the Fragno DB Quickstart.

Creating Subscriptions

To create a new subscription, use the upgradeSubscription mutator. This mutator handles both creating new subscriptions and upgrading existing ones.

components/PricingCard.tsx
import { stripeClient } from "@/lib/stripe-client";

export function PricingCard({ priceId, planName }: { priceId: string; planName: string }) {
  const { mutate, loading, error } = stripeClient.upgradeSubscription();

  const handleSubscribe = async () => {
    const { url, redirect } = await mutate({
      body: {
        priceId: priceId, // Stripe price ID (e.g., "price_1234567890")
        successUrl: `${window.location.origin}/success`,
        cancelUrl: window.location.href,
      },
    });

    if (redirect) {
      window.location.href = url; // Redirect to Stripe Checkout
    }
  };

  return (
    <div className="pricing-card">
      <h3>{planName}</h3>
      <button onClick={handleSubscribe} disabled={loading}>
        {loading ? "Loading..." : "Subscribe"}
      </button>
    </div>
  );
}

Showing a Success Page

The Stripe Checkout portal will redirect the user back to successUrl after a successful payment, at this point the webhook event that registers this payment may not (yet) have arrived. Therefore you can create a dedicated success page which fetches the latest subscription status from using the syncStripeSubscription service to ensure the end-user sees the right result. See Manual Synchronization for details.

Configuration Requirements

When creating the Stripe fragment, you must provide two callbacks:

onStripeCustomerCreated

Called when a new Stripe Customer is created for an entity in your system. Use this to store the Stripe Customer ID in your database so you can link it back to your user/organization.

resolveEntityFromRequest

Called on subscription management routes (/subscription/upgrade and /subscription/cancel) to identify which entity (user or organization) is making the request. This callback should:

  1. Extract authentication information from the request context (session cookies, JWT tokens in headers, etc.)
  2. Return the entity's Stripe-related data needed for subscription operations

Prop

Type

See this example:

lib/stripe.ts
import { createStripeFragment } from "@fragno-dev/stripe";
import { updateUser } from "@/db/user-repo";
import { getSession } from "@/lib/auth";

export const stripeFragment = createStripeFragment({
  // ... other config

  onStripeCustomerCreated: async (stripeCustomerId, referenceId) => {
    // Store the Stripe Customer ID in your database
    await updateUser(referenceId, { stripeCustomerId });
  },

  resolveEntityFromRequest: async (context) => {
    // Extract authentication from your session/auth system
    const session = getSession(context.headers);

    return {
      referenceId: session.user.id,
      customerEmail: session.user.email,
      stripeCustomerId: session.user.stripeCustomerId || undefined,
      subscriptionId: session.user.subscriptionId || undefined,
      stripeMetadata: {
        // Additional metadata you want to attach
      },
    };
  },
});

Retrieving Subscriptions

The fragment exposes a couple of service functions that can be used server-side to query data from the subscription table:

import { stripeFragment } from "@/lib/stripe";

const subscription = await stripeFragment.services.getSubscriptionByReferenceId(user.id);
// or
const subscription = await stripeFragment.services.getSubscriptionById(subscriptionId);
// or
const subscription = await stripeFragment.services.getSubscriptionByStripeCustomer(customerId);

Canceling Subscriptions

Users can cancel their subscriptions using the cancelSubscription mutator. Cancellations are handled through Stripe's Billing Portal, where users confirm their cancellation.

components/CancelSubscriptionButton.tsx
import { stripeClient } from "@/lib/stripe-client";

export function CancelSubscriptionButton({ subscriptionId }: { subscriptionId: string }) {
  const { mutate, loading, error } = stripeClient.cancelSubscription();

  const handleCancel = async () => {
    const { url, redirect } = await mutate({
      body: {
        returnUrl: `${window.location.origin}/account`,
      },
    });

    if (redirect) {
      window.location.href = url; // Redirect to Stripe Billing Portal
    }
  };

  return (
    <button onClick={handleCancel} disabled={loading}>
      {loading ? "Processing..." : "Cancel Subscription"}
    </button>
  );
}

Changing Subscription Plans

To change the user from one plan to another, use the same upgradeSubscription mutator with a different price ID. If the user already has an active subscription, the fragment will create a Stripe Billing Portal confirming the change.

const { mutate } = stripeClient.upgradeSubscription();

await mutate({
  body: {
    priceId: "price_new_plan", // The new price ID
    successUrl: `${window.location.origin}/success`,
    cancelUrl: window.location.href,
  },
});

Manual Synchronization

The subscription table is automatically kept in sync with Stripe through webhook events. However, there are cases where you cannot depend on webhook events because you need a guarantee that you have the most recent data.

For example a "checkout success" page, directly after a user completes payment through Stripe Checkout. They should see their subscription status reflected in your UI immediately. Since webhooks could arrive a few seconds later, it would be better to get the latest info from Stripe directly on the checkout success page.

Using syncStripeSubscription

The fragment provides the syncStripeSubscription method as a fragment service. This method will fetch the latest subscription from Stripe and write it to your database. If no subscriptions are found for a customer, then the subscription will be dropped from the database.

import { stripeFragment } from "@/lib/stripe";
import { getSession } from "@/lib/auth";

/* ... */

const session = await getSession();

// Fetch latest subscription data from Stripe and sync to database
await stripeFragment.services.syncStripeSubscription(
  session.user.id, // referenceId
  session.user.stripeCustomerId, // stripeCustomerId
);

// Now you can assume the db state is recent
const subscription = await stripeFragment.services.getSubscriptionById(session.subscriptionId);

// do things
console.log(subscription.status);