Alt description missing in image
Beta: Plugins coming soon!
@payments/driverREADME
Alt description missing in image

Accepting Payments 💸

import { startCheckout, syncPaymentData, startPortalSession } from '@payments/driver'

The @payments/driver workspace driver provides:

  • An abstraction layer for different payment providers.
  • A shared API surface for different payment drivers to follow.
  • A way to somewhat easily switch payment providers without a huge refactor.

Set up a payment provider

Choose a supported payment provider (like Stripe) and install the related plugin branch, e.g.

npx @fullproduct/universal-app install with/payments-stripe

In your payment providers dashboard and settings:

  • Retrieve the required secrets / keys → Add to env vars + env.local files
  • Add products and price variants → Add price variant ids to env vars as well
  • Assign a webhook URL for your payment provider to send events to

Optional, but recommended:

  • Limit customers to 1 subscription per customer, if possible (trust me, this avoids many potential issues)
  • Apply your branding to your checkout and customer portal pages

Finally, assign your installed payment provider as the main choice for payments in @app/config, e.g:

/features/@app-core/appConfig.ts
    drivers: createDriverConfig({
        payments: DRIVER_OPTIONS.payments.stripe,
    }),

Creating checkouts

Once you’ve set up a payment provider and added the related secrets + sku’s / price / product ids to env vars, you’re ready to start accepting payments.

There are two ways to go about this:

  1. Use the paymentRoutes.checkout URL with ?items=sku:quantity params → Simple ✦
import { paymentRoutes } from '@payments/driver'

This can work well for simple one-time-purchases, e.g.

<Button href={`${paymentRoutes.checkout}?items=price_XXXX:5&items=price_XXXX:1`} target="_blank">
    Buy now
</Button>

However, you do give up a bit of control this way…

  1. Create a custom checkout link using startCheckout() underneath → Recommended ✔︎

We recommend custom checkouts because you’ll typically have way more control this way:

import { startCheckout, ensureCustomer } from '@payments/driver'
@app-core/routes/api/customers/checkout/[plan]/route.ts
 
/* --- /api/customers/checkout/[plan] ---------------------- */
 
/* -i- http://localhost:3000/api/customers/checkout/solo */
/* -i- http://localhost:3000/api/customers/checkout/startup */
/* -i- http://localhost:3000/api/customers/checkout/studio */
 
export const GET = async (req: Request, ctx: any$Todo) => {
 
    // Args
    const { plan } = await ctx.params
 
    // Auth (double-checked `userId` from middleware)
    const { user } = await Users.findOne({ userId: ctx.userId })
 
    // Make sure there's a customer for this user
    const { customer } = await ensureCustomer({ user })
    const customerId = customer.customerId
 
    // ...
 
    // - Make sure you can actually create a new plan?
    // - e.g. Do they not already have a licence / sub?
 
    // ...
 
    // Build lineItems linking params <-> priceVarianId's from env
    const lineItems = []
    if (plan === 'solo') lineItems.push({
        priceVariantId: process.env.STRIPE_OTP_SOLO_SEAT_PRICE_ID,
        quantity: 1,
    })
    if (plan === 'startup') lineItems.push({
        priceVarianId: process.env.STRIPE_OTP_STARTUP_PRICE_ID,
        quantity: 3,
    })
    if (plan === 'studio') lineItems.push({
        priceVarianId: process.env.STRIPE_SUB_STUDIO_PRICE_ID,
        quantity: 5,
    })
 
    // Pass lineItems to checkout
    const checkoutData = await startCheckout({
        mode: plan === 'studio' ? 'subscription' : 'checkout',
        lineItems,
        // ...
        successUrl: new URL(`/api/checkouts/success`, reqUrl).toString(),
        successUrl: new URL(`/are-you-sure`, reqUrl).toString(),
    })
 
    // Redirect to checkout url
    return NextResponse.redirect(checkoutSession.checkoutUrl)
}

We recommend option no. 2 because the custom success / cancel urls you can set will be a great in-between point to sync state from your payment provider to your system before showing any UI to your user. This way, you’ll ensure all the right flags and operations post-payment have synced and are ready to use, regardless of whether your payment provider has hit up your webhook yet or not.

Syncing data to your DB

import { syncPaymentData } from '@payments/driver'

The ultimate goal of any payment driver is to get provider data (checkouts / invoices / subscriptions / etc.) abstracted into your own system’s entitlements collections / records.

All our payment drivers are set up to get records into the following collections in your own Database:

syncPaymentData() will transform relevant payment provider data for a specific user to update your Customer, Subscriptions and Purchases entitlement collections:

Customers

Collection to keep track of which payment provider customers are linked to which user.

e.g. If you ever switched from Stripe -> Polar, or have both Stripe (web) + RevenueCat (mobile) set up, you should keep track of multiple customer records per user:

{
    // Driver info
    provider,
 
    // Links user <-> payment provider
    userId,
    customerId,
    subscriptionId,
 
    // Essential customer info
    email,
    name,
 
    // Raw stringified objects used to populate these fields
    raw,
}

Subscriptions

As the name suggests, this will come in handy for keeping track of auto-renewing plans with monthly billing:

{
    // Driver info
    provider,
 
    // Links user <-> payment provider
    userId,
    customerId,
    subscriptionId,
    orderCheckoutId,
 
    // Links subscription <-> catalogue <-> plan / entitlements
    productId,
    priceVariantId,
    sku,
    productName,
    priceVariantName,
 
    // Pricing breakdown <-> billing plan <-> number of seats?
    unitPriceAmount,
    unitPriceCurrency,
    quantity,
 
    // Plan status + lifecycle <-> entitlement expiration
    status,
    startDate,
    currentPeriodStart,
    currentPeriodEnd,
    trialStartDate,
    trialEndDate,
    purchaseDate,
    canceledDate,
    refundedDate,
 
    // Raw stringified objects used to populate these fields
    raw,
}

Purchases

If you prefer lifetime licenses through e.g. one-time purchases, or a credits / upsell based monetisation strategy:

{
    // Driver info
    provider,
 
    // Links user <-> pyment provider
    userId,
    customerId,
    orderCheckoutId,
 
    // Links purchase <-> catalogue <-> credits / iap / entitlements
    productId,
    priceVariantId,
    sku,
    productName,
    priceVariantName,
 
    // Pricing breakdown <-> number of entitlements / credits / content
    unitPriceAmount,
    unitPriceCurrency,
    quantity,
 
    // Purchase status <-> still entitled?
    status,
    purchaseDate,
    canceledDate,
    refundedDate,
 
    // Raw stringified objects used to populate these fields
    raw,
}

Webhook events → sync

Don’t forget to set up a webhook endpoint in your payment provider so you can trigger a re-sync on relevant payment provider events, e.g.

  • Subscription updates from the portal
  • Endings of trial versions
  • Refunded / Failed / Completed charges, etc.

Each of these should trigger another syncPaymentData() to update your Customers + Subscriptions + Purchases entitlements collections.

It’s recommended to also call it manually on all checkout / customer portal success or return urls.

Build a Plan from entitlements

💡

We don’t provide a Plan collection / model / entitlement derivation method for you.
You’ll still need to build this part yourself:

Now that we have our Subscriptions and Purchases saved to our DB, and all relevant events will keep them up to date, you’re ready to:

  • Link entitlements through sku / priceVariantId fields to user flags or credits
  • Build a verbose Plans collection with the current plan data from those entitlements
  • Save some of these flags on the Users collection for convenience / easy auth checks

Your Plan can then serve as your main way to check if a user is authorized to do something in your app. For example, with the following Plan schema:

@app-core/schemas/Plan.schema.ts
export const Plan = schema('Plan', {
 
    userId: z
        .string()
        .uuid()
        .describe('Auth provider usedId'),
 
    // -- e.g. Lifetime licenses / one-time purchases --
    
    paidForAccess: z
        .boolean()
        .describe('Whether the user has paid for the right entitlements'),
 
    // -- e.g. Seats based plans --
 
    seats: z
        .number()
        .describe('Amount of seats paid for in the current billing cycle'),
 
    // -- e.g. Credits based usage or monetization --
 
    creditsPaidFor: z
        .number()
        .describe('Total amount of purchased credits from entitlements')
 
    creditsUsed: z
        .number()
        .describe('Credits already spent in the app'),
 
    creditsBalance: z
        .number()
        .describe('Difference between creditsPaidFor - creditsUsed'),
 
    // -- e.g. Lock certain features behind individual purchases --
 
    featuresUnlocked: z
        .array(z.enum(['feature-a', 'feature-b', ...]))
        .default([])
        .describe('Which features the user unlocked so far')
 
})

The idea being that you would check and derive the entitlements for each of these fields in your Plan from the quantity and related priceVariantId fields in your Subscription and Purchase records for that user.

Manage in a Customer Portal

import { createPortalSession } from '@payments/driver'

Ultimately, you’ll want to give your customer the option to:

  • Update / cancel / expand their subscription (if any)
  • Check their invoices or manage their preferred payment method

You could build your own UI for this, but most payment providers have hosted customer portals for this.

Again, two ways you can open these portals, depending on how much control you want:

  1. Use the paymentRoutes.portal URL → Simple ✦
import { paymentRoutes } from '@payments/driver'
<Button href={paymentRoutes.portal} target="_blank">
    Manage Plan
</Button>
  1. Create a custom checkout link using startCheckout() underneath → Recommended ✔︎

We recommend custom checkouts because you’ll typically have way more control this way:

import { startCheckout, ensureCustomer } from '@payments/driver'
@app-core/routes/api/customers/checkout/[plan]/route.ts
 
/* --- /api/customers/portal ----------------------------- */
 
/* -i- http://localhost:3000/api/customers/portal */
 
export const GET = async (req: Request, ctx: any$Todo) => {
 
    // Args
    const { plan } = await ctx.params
 
    // Auth (double-checked `userId` from middleware)
    const { user } = await Users.findOne({ userId: ctx.userId })
 
    // Make sure there's a customer for this user?
    const { customer } = await ensureCustomer({ user })
 
    // Start portal session
    const portalSession = await startPortalSession({
 
        // Used to retrieve the customerId for the active provider
        user,
        
        // Re-trigger syncPaymentData() after each portal visit...?
        returnUrl: new URL(`/api/checkouts/success`, reqUrl).toString(),
    })
 
    // Redirect to checkout url
    return NextResponse.redirect(portalSession.portalUrl)
}

Where, again, we typically recommend option no. 2 due to the ability add an in-between redirect that triggers syncPaymentData() for any potential subscription updates.

Choosing a Payment Provider

Merge a payment plugin like with/payments-stripe through git or our CLI:

npx @fullproduct/universal-app install plugins

You can then choose the active / main payments provider in @app/config using DRIVER_OPTIONS:

features/@app-core/appConfig.ts
import { DRIVER_OPTIONS, createDriverConfig } from '@app/registries/drivers.config'
 
export const appConfig = {
    // ...
    drivers: createDriverConfig({
        db: DRIVER_OPTIONS.db.mongoose,
        payments: DRIVER_OPTIONS.payments.stripe,
    }),
} as const

The @payments/driver entrypoint will just re-export from the chosen implementation:

packages/@payments-driver/index.ts
import { paymentsDrivers } from '@app/registries/drivers/payments.drivers.generated'
import { appConfig } from '@app/config'
import { syncCustomerData } from './resolvers/syncCustomerData.resolver'
 
// We'll warn if no driver is chosen
const paymentsDriver = paymentsDrivers[appConfig.drivers.payments]
if (!paymentsDriver) throw new Error(
    `No Payments driver found for key "${appConfig.drivers.payments}". ` +
    `Make sure the driver is installed and the driver key is correct.`,
)
 
export const provider = paymentsDriver['provider'] // e.g. 'stripe' | 'polar' | 'revenuecat'
export const ensureCustomer = paymentsDriver['ensureCustomer'] // Find or create customer for the user
export const startCheckout = paymentsDriver['startCheckout'] // Start a hosted checkout session
export const syncPaymentData = syncCustomerData // Calls 'syncPaymentData()' on all payment providers
export const startPortalSession = paymentsDriver['startPortalSession'] // Hosted customer portal
 
export const paymentEventWebhook = paymentsDriver['paymentEventWebhook'] // Calls `syncPaymentData()`
export const wekhookRouteHandler = paymentsDriver['wekhookRouteHandler'] // Calls `syncPaymentData()`
export const checkoutRouteHandler = paymentsDriver['checkoutRouteHandler'] // Calls `startCheckout()`
 
export const paymentRoutes = paymentsDriver['paymentRoutes'] // Routes to redirect to?
 
export { validatePaymentDriver } from './utils/validatePaymentDriver'
export { getUserByCustomerId } from './resolvers/getUserByCustomerId.resolver'
export { syncCustomerData }

Example of how implementations like @payments/stripe plug into this driver:

        • stripe.payments.ts ← driver implementation for Stripe
      • drivers.config.ts ← auto updated options + types
        • payments.drivers.generated.ts ← auto updated registry
      • driver.signature.ts ← shared Zod signatures
      • index.ts ← re-exports the active implementation, e.g. 'stripe'
      • appConfig.ts ← select e.g. 'stripe' as active 'payments' driver

Payment Driver Signature

The core driver defines the shared function contracts and IO schemas in driver.signature.ts. Implementations must conform to these to be considered valid.

ensureCustomer()

  • Purpose: Ensure a provider‑side customer exists for a given user.
  • Input: EnsureCustomerInput - { user, customerOverrides?, metadata? }
  • Output: EnsureCustomerOutput - { provider, user, customer }

startCheckout()

  • Purpose: Initialize a checkout session (does not redirect).
  • Input: StartCheckoutInput - { user, lineItems[], mode, successUrl, cancelUrl, coupons?, promoCodes?, trialDays?, locale?, autoCalculateTax?, customerOverrides?, metadata? }
  • Output: StartCheckoutOutput - { provider, user, customer, checkoutUrl }

syncPaymentData()

  • Purpose: Synchronize provider data (Customers, Subscriptions, Purchases) into your DB driver.
  • Input: SyncPaymentDataInput - { customerId, user? }
  • Output: SyncPaymentDataOutput - { provider, user?, customer?, subscriptions?, purchases? }

startPortalSession()

  • Purpose: Create a customer portal session for managing subscriptions and payment methods.
  • Input: StartPortalSessionInput - { user, returnUrl }
  • Output: StartPortalSessionOutput - { provider, customer, portalUrl }

Webhook and route handlers

  • paymentEventWebhook({ rawBody, headers, eventType? }) → Promise<{ received: boolean }>
  • wekhookRouteHandler(req, { params }) - API route to receive webhooks (typically calls paymentEventWebhook).
  • checkoutRouteHandler(req, { params }) - API route to start a checkout session (typically calls startCheckout).

paymentRoutes object

Static route paths for checkout, portal, and webhook as strings. Used by apps to link to the driver’s API.

Building a new Payment Driver

  1. Create a workspace, e.g. @payments/yourprovider/drivers/yourprovider.payments.ts
  2. Implement required functions → export export const driver = validatePaymentDriver({...})
  3. Regenerate registries → @app/registries/drivers/payments.drivers.generated.ts + drivers.config.ts
  4. Select your payments provider as the main driver in appConfig.ts
npx turbo run @green-stack/core#collect-drivers
💡

(Payment) Drivers are optional but recommended.
They maximize portability between projects and future‑proof provider changes.

However, it’s perfectly fine, and sometimes recommended, to occasionally use the underlying provider packages directly: import Stripe from 'stripe' or similar.