Skip to main content

Payment Integration Overview

This guide provides a hands-on walkthrough of the Ever Works payment system. It covers the provider abstraction layer, how to configure each provider, the checkout and subscription lifecycle, feature gating, and webhook handling.

Provider Architecture at a Glance

The payment system is built on a provider-agnostic abstraction. Every payment provider implements the same PaymentProviderInterface, and a factory pattern lets you switch providers without changing application code.

lib/payment/
index.ts # Public API exports
config/
provider-configs.ts # Provider configuration factory
payment-provider-manager.ts # Singleton manager + ConfigManager
validation.ts # Input validation utilities
guards/
feature.guard.tsx # Plan-based feature gating
hooks/
use-payment.tsx # React context + usePayment hook
lib/
payment-provider-factory.ts # Factory for creating providers
payment-service.ts # Service wrapping the active provider
payment-service-manager.ts # Singleton for service lifecycle
providers/
stripe-provider.ts
lemonsqueezy-provider.ts
polar-provider.ts
solidgate-provider.ts
client/
payment-account-client.ts # Client-side account API
utils/
prices.ts # Price formatting utilities
polar-subscription-helpers.ts
services/
payment-email.service.ts # Email notifications on payment events
types/
payment-types.ts # Core type definitions
payment.ts # Payment flow and submission types
ui/
stripe/stripe-elements.tsx
lemonsqueezy/lemonsqueezy-elements.tsx
polar/polar-elements.tsx
solidgate/solidgate-elements.tsx

Supported Providers

ProviderOne-Time PaymentsSubscriptionsTrialsWebhooksMerchant of Record
StripeYesYesYesYesNo
LemonSqueezyYesYesYesYesYes
PolarYesYesYesYesNo
SolidgateYesYesNoYesNo

Core Interfaces

PaymentProviderInterface

Every provider implements this interface, defined in lib/payment/types/payment-types.ts:

// lib/payment/types/payment-types.ts
export interface PaymentProviderInterface {
// Payment operations
createPaymentIntent(params: CreatePaymentParams): Promise<PaymentIntent>;
confirmPayment(paymentId: string, paymentMethodId: string): Promise<PaymentIntent>;
verifyPayment(paymentId: string): Promise<PaymentVerificationResult>;
createSetupIntent(user: User | null): Promise<SetupIntent>;

// Subscription management
createCustomer(params: CreateCustomerParams): Promise<CustomerResult>;
createSubscription(params: CreateSubscriptionParams): Promise<SubscriptionInfo>;
cancelSubscription(subscriptionId: string, cancelAtPeriodEnd?: boolean): Promise<SubscriptionInfo>;
updateSubscription(params: UpdateSubscriptionParams): Promise<SubscriptionInfo>;
hasCustomerId(user: User | null): boolean;
getCustomerId(user: User | null): Promise<string | null>;

// Webhooks
handleWebhook(payload: any, signature: string, rawBody?: string,
timestamp?: string, webhookId?: string): Promise<WebhookResult>;

// Refunds
refundPayment(paymentId: string, amount?: number): Promise<any>;

// Client-side configuration
getClientConfig(): ClientConfig;
getUIComponents(): UIComponents;
}

Key Data Types

// Subscription statuses
export enum SubscriptionStatus {
INCOMPLETE = 'incomplete',
INCOMPLETE_EXPIRED = 'incomplete_expired',
TRIALING = 'trialing',
ACTIVE = 'active',
PAST_DUE = 'past_due',
CANCELED = 'canceled',
UNPAID = 'unpaid',
}

// Payment types
export enum PaymentType {
ONE_TIME = 'one_time',
SUBSCRIPTION = 'subscription',
FREE = 'free',
}

// Supported providers
export type SupportedProvider = 'stripe' | 'solidgate' | 'lemonsqueezy' | 'polar';

Quick Setup

Step 1: Set Environment Variables

Each provider requires API keys and webhook secrets. Add them to .env.local:

# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# LemonSqueezy
LEMONSQUEEZY_API_KEY=...
LEMONSQUEEZY_WEBHOOK_SECRET=...
LEMONSQUEEZY_STORE_ID=...

# Polar
POLAR_ACCESS_TOKEN=...
POLAR_WEBHOOK_SECRET=...
POLAR_ORGANIZATION_ID=...

# Solidgate
SOLIDGATE_API_KEY=...
SOLIDGATE_SECRET_KEY=...
SOLIDGATE_WEBHOOK_SECRET=...
SOLIDGATE_MERCHANT_ID=...
NEXT_PUBLIC_SOLIDGATE_PUBLISHABLE_KEY=...

Step 2: Configure Pricing Plans

Pricing plans are defined in your .content/.works/works.yml:

pricing:
provider: stripe # Default provider
currency: USD
plans:
FREE:
id: free
name: Free
description: Basic access
price: 0
features:
- "List your product"
- "Basic analytics"
STANDARD:
id: standard
name: Standard
description: Enhanced features
price: 9
stripePriceId: price_xxx
annualDiscount: 20
features:
- "Everything in Free"
- "Priority listing"
- "Advanced analytics"
PREMIUM:
id: premium
name: Premium
description: Full access
price: 29
stripePriceId: price_yyy
annualDiscount: 25
isPremium: true
features:
- "Everything in Standard"
- "Featured placement"
- "API access"

Step 3: Set Up Webhooks

Each provider has a dedicated webhook endpoint:

ProviderWebhook URL
Stripe/api/stripe/webhook
LemonSqueezy/api/lemonsqueezy/webhook
Polar/api/polar/webhook
Solidgate/api/solidgate/webhook

Configure these URLs in each provider's dashboard, pointing to your deployed domain.

The PaymentProviderFactory

The factory creates provider instances based on a type string:

// lib/payment/lib/payment-provider-factory.ts
export type SupportedProvider = 'stripe' | 'solidgate' | 'lemonsqueezy' | 'polar';

export class PaymentProviderFactory {
static createProvider(
providerType: SupportedProvider,
config: PaymentProviderConfig
): PaymentProviderInterface {
switch (providerType) {
case 'stripe':
return new StripeProvider(config);
case 'solidgate':
return new SolidgateProvider(config);
case 'lemonsqueezy':
return new LemonSqueezyProvider(config as unknown as LemonSqueezyConfig);
case 'polar':
return new PolarProvider(config as unknown as PolarConfig);
default:
throw new Error(`Unsupported payment provider: ${providerType}`);
}
}
}

PaymentService and ServiceManager

PaymentService

The PaymentService wraps the active provider instance and exposes a uniform API:

// lib/payment/lib/payment-service.ts
export class PaymentService {
private provider: PaymentProviderInterface;

constructor(config: PaymentServiceConfig) {
this.provider = PaymentProviderFactory.createProvider(
config.provider,
config.config
);
}

async createPaymentIntent(params: CreatePaymentParams): Promise<PaymentIntent> {
return this.provider.createPaymentIntent(params);
}

async createSubscription(params: CreateSubscriptionParams): Promise<SubscriptionInfo> {
return this.provider.createSubscription(params);
}

async cancelSubscription(
subscriptionId: string,
cancelAtPeriodEnd = true
): Promise<SubscriptionInfo> {
return this.provider.cancelSubscription(subscriptionId, cancelAtPeriodEnd);
}

getClientConfig(): ClientConfig {
return this.provider.getClientConfig();
}

getUIComponents(): UIComponents {
return this.provider.getUIComponents();
}
}

PaymentServiceManager (Singleton)

The manager handles provider switching at runtime and persists the user's choice in localStorage:

// lib/payment/lib/payment-service-manager.ts
export class PaymentServiceManager {
private static instance: PaymentServiceManager;
private currentService: PaymentService | null = null;
private readonly STORAGE_KEY = 'everworks_template.payment_provider.selected';

static getInstance(
providerConfigs: Record<SupportedProvider, PaymentProviderConfig>,
defaultProvider?: SupportedProvider
): PaymentServiceManager {
if (!PaymentServiceManager.instance) {
PaymentServiceManager.instance = new PaymentServiceManager(
providerConfigs, defaultProvider
);
}
return PaymentServiceManager.instance;
}

async switchProvider(newProvider: SupportedProvider): Promise<void> {
this.setStoredProvider(newProvider);
this.currentService = new PaymentService({
provider: newProvider,
config: this.providerConfigs[newProvider],
});
}

getAvailableProviders(): SupportedProvider[] {
return Object.keys(this.providerConfigs) as SupportedProvider[];
}
}

React Integration

PaymentProvider Context

Wrap your application (or payment-related pages) with the PaymentProvider:

// Example: wrapping a layout
import { PaymentProvider } from '@/lib/payment';
import { createProviderConfigs } from '@/lib/payment/config/provider-configs';

const configs = createProviderConfigs(
{ apiKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, webhookSecret: '' },
undefined, // solidgate
undefined, // lemonsqueezy
undefined // polar
);

export default function PricingLayout({ children }) {
return (
<PaymentProvider providerConfigs={configs} defaultProvider="stripe">
{children}
</PaymentProvider>
);
}

usePayment Hook

Components access the payment service through the usePayment hook:

// lib/payment/hooks/use-payment.tsx
export function usePayment() {
const context = useContext(PaymentContext);
if (context === undefined) {
throw new Error('usePayment must be used within a PaymentProvider');
}
return context;
}

// Returns:
// {
// service: PaymentService | null;
// switchProvider: (provider: SupportedProvider) => Promise<void>;
// currentProvider: SupportedProvider;
// availableProviders: SupportedProvider[];
// }

Usage example:

function CheckoutButton({ priceId }: { priceId: string }) {
const { service, currentProvider } = usePayment();

const handleCheckout = async () => {
const intent = await service?.createPaymentIntent({
amount: 2900,
currency: 'usd',
metadata: { priceId },
});
// Redirect to checkout or show payment form
};

return <button onClick={handleCheckout}>Pay with {currentProvider}</button>;
}

Feature Gating

The FeatureGuard component restricts UI elements based on the user's subscription plan:

// lib/payment/guards/feature.guard.tsx
export type PlanType = "TRIAL" | "FREE" | "STANDARD" | "PREMIUM" | "EXPIRED" | "CANCELLED";

const PLAN_LEVEL: Record<PlanType, number> = {
CANCELLED: 0,
EXPIRED: 1,
TRIAL: 2,
FREE: 3,
STANDARD: 4,
PREMIUM: 5,
};

Usage:

import FeatureGuard from '@/lib/payment/guards/feature.guard';

<FeatureGuard
user={currentUser}
requiredPlan="STANDARD"
fallback={<UpgradePrompt />}
onAccessDenied={(userPlan, required, reason) => {
console.log(`Access denied: ${reason}`);
}}
>
<PremiumFeature />
</FeatureGuard>

Grace Period Support

Expired plans receive a 7-day grace period with degraded access:

export const GRACE_PERIOD_CONFIG = {
EXPIRED_GRACE_DAYS: 7,
TRIAL_DURATION_DAYS: 14,
EXPIRED_ACCESS_LEVEL: "FREE" as PlanType,
};

export const isInGracePeriod = (user: User): boolean => {
if (!user.planExpiresAt) return false;
const graceEnd = new Date(user.planExpiresAt);
graceEnd.setDate(graceEnd.getDate() + GRACE_PERIOD_CONFIG.EXPIRED_GRACE_DAYS);
return new Date() <= graceEnd && user.plan === "EXPIRED";
};

Webhook Event Types

All webhook events are normalized into a common enum:

// lib/payment/types/payment-types.ts
export enum WebhookEventType {
PAYMENT_SUCCEEDED = 'payment_succeeded',
PAYMENT_FAILED = 'payment_failed',
SUBSCRIPTION_CREATED = 'subscription_created',
SUBSCRIPTION_UPDATED = 'subscription_updated',
SUBSCRIPTION_CANCELLED = 'subscription_cancelled',
SUBSCRIPTION_TRIAL_ENDING = 'subscription_trial_ending',
INVOICE_PAID = 'invoice_paid',
INVOICE_PAYMENT_FAILED = 'invoice_payment_failed',
REFUND_CREATED = 'refund_created',
// ... and more
}

Payment Flows

The template supports two payment flows for content submissions:

// lib/payment/types/payment.ts
export enum PaymentFlow {
PAY_AT_START = "pay_at_start",
PAY_AT_END = "pay_at_end",
}

export enum SubmissionStatus {
DRAFT = "draft",
PENDING_PAYMENT = "pending_payment",
PAID = "paid",
PUBLISHED = "published",
REJECTED = "rejected",
}
  • Pay at Start: User pays before the submission is reviewed.
  • Pay at End: User submits for free, pays only after approval.

API Route Reference

Stripe Routes

RouteMethodDescription
/api/stripe/checkoutPOSTCreate a checkout session
/api/stripe/subscriptionGET/POSTManage subscriptions
/api/stripe/subscription/portalPOSTCreate billing portal session
/api/stripe/subscription/[id]/cancelPOSTCancel a subscription
/api/stripe/payment-intentPOSTCreate a payment intent
/api/stripe/payment-methods/listGETList saved payment methods
/api/stripe/payment-methods/createPOSTAdd a payment method
/api/stripe/payment-methods/deletePOSTRemove a payment method
/api/stripe/setup-intentPOSTCreate a setup intent
/api/stripe/webhookPOSTHandle Stripe webhooks

LemonSqueezy Routes

RouteMethodDescription
/api/lemonsqueezy/checkoutPOSTCreate checkout session
/api/lemonsqueezy/cancelPOSTCancel a subscription
/api/lemonsqueezy/reactivatePOSTReactivate a subscription
/api/lemonsqueezy/update-planPOSTChange subscription plan
/api/lemonsqueezy/listGETList user subscriptions
/api/lemonsqueezy/webhookPOSTHandle webhooks

Polar Routes

RouteMethodDescription
/api/polar/checkoutPOSTCreate checkout session
/api/polar/subscription/portalPOSTCreate customer portal
/api/polar/subscription/[id]/cancelPOSTCancel a subscription
/api/polar/subscription/[id]/reactivatePOSTReactivate
/api/polar/webhookPOSTHandle webhooks

Utility Functions

The payment-types.ts file includes useful formatting helpers:

// Format cents to currency string
formatCentsToCurrency(2900, 'USD', 'en-US');
// => "$29.00"

// Convert cents to decimal
convertCentsToDecimal(2900);
// => 29.00

// Convert timestamp to Date
convertNumberToDate(1640995200);
// => Date: 2022-01-01T00:00:00.000Z

Next Steps