Преминете към основното съдържание

Payment Integration Overview

The Ever Works supports multiple payment providers to handle subscriptions, one-time payments, and premium features.

Supported Payment Providers

Primary Providers

  1. Stripe - Recommended for most use cases
  2. LemonSqueezy - Great for digital products

Provider Comparison

FeatureStripeLemonSqueezy
Global Coverage195+ countries180+ countries
Payment Methods100+ methods20+ methods
Tax HandlingManual setupAutomatic
Merchant of RecordNoYes
Developer ExperienceExcellentGood
Fees2.9% + 30¢5% + 50¢
Subscription ManagementAdvancedBasic

Payment Architecture

graph TB
User[User] --> Checkout[Checkout Flow]
Checkout --> Provider{Payment Provider}

Provider -->|Stripe| StripeAPI[Stripe API]
Provider -->|LemonSqueezy| LemonAPI[LemonSqueezy API]

StripeAPI --> StripeWebhook[Stripe Webhooks]
LemonAPI --> LemonWebhook[LemonSqueezy Webhooks]

StripeWebhook --> Database[(Database)]
LemonWebhook --> Database

Database --> UserAccount[User Account]
Database --> Subscription[Subscription Status]
Database --> Features[Feature Access]

Pricing Tiers

Default Pricing Structure

export const pricingTiers = {
free: {
name: 'Free',
price: 0,
currency: 'USD',
interval: null,
features: [
'Submit up to 3 items',
'Basic profile',
'Community support',
],
limits: {
submissions: 3,
featured: 0,
},
},
pro: {
name: 'Pro',
price: 10,
currency: 'USD',
interval: 'month',
features: [
'Unlimited submissions',
'Priority review',
'Analytics dashboard',
'Email support',
],
limits: {
submissions: -1, // unlimited
featured: 1,
},
},
premium: {
name: 'Premium',
price: 25,
currency: 'USD',
interval: 'month',
features: [
'Everything in Pro',
'Featured listings',
'Custom branding',
'Priority support',
'API access',
],
limits: {
submissions: -1, // unlimited
featured: 5,
},
},
} as const;

Payment Flow

Subscription Flow

sequenceDiagram
participant U as User
participant A as App
participant P as Payment Provider
participant W as Webhook
participant D as Database

U->>A: Select pricing plan
A->>P: Create checkout session
P->>A: Return checkout URL
A->>U: Redirect to checkout
U->>P: Complete payment
P->>W: Send webhook event
W->>D: Update subscription
P->>A: Redirect back to app
A->>U: Show success page

One-time Payment Flow

sequenceDiagram
participant U as User
participant A as App
participant P as Payment Provider
participant W as Webhook
participant D as Database

U->>A: Purchase feature
A->>P: Create payment intent
P->>A: Return client secret
A->>U: Show payment form
U->>P: Submit payment
P->>W: Send webhook event
W->>D: Record payment
P->>A: Confirm payment
A->>U: Grant feature access

Stripe Integration

Setup Requirements

# Environment variables
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."

# Price IDs
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID="price_..."
NEXT_PUBLIC_STRIPE_PREMIUM_PRICE_ID="price_..."

Checkout Session Creation

// lib/stripe/checkout.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});

export async function createCheckoutSession({
priceId,
userId,
successUrl,
cancelUrl,
}: {
priceId: string;
userId: string;
successUrl: string;
cancelUrl: string;
}) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
metadata: {
userId,
},
success_url: successUrl,
cancel_url: cancelUrl,
customer_email: user.email,
allow_promotion_codes: true,
});

return session;
}

Webhook Handling

// app/api/stripe/webhook/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';

export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get('stripe-signature')!;

let event: Stripe.Event;

try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Webhook signature verification failed', {
status: 400,
});
}

switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
}

return new Response('OK');
}

LemonSqueezy Integration

Setup Requirements

# Environment variables
LEMONSQUEEZY_API_KEY="your-api-key"
LEMONSQUEEZY_STORE_ID="your-store-id"
LEMONSQUEEZY_WEBHOOK_SECRET="your-webhook-secret"

# Product IDs
NEXT_PUBLIC_LEMONSQUEEZY_PRO_PRODUCT_ID="your-pro-product-id"
NEXT_PUBLIC_LEMONSQUEEZY_PREMIUM_PRODUCT_ID="your-premium-product-id"

Checkout Creation

// lib/lemonsqueezy/checkout.ts
export async function createLemonSqueezyCheckout({
productId,
userId,
userEmail,
}: {
productId: string;
userId: string;
userEmail: string;
}) {
const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
'Content-Type': 'application/vnd.api+json',
'Accept': 'application/vnd.api+json',
},
body: JSON.stringify({
data: {
type: 'checkouts',
attributes: {
checkout_data: {
email: userEmail,
custom: {
user_id: userId,
},
},
},
relationships: {
store: {
data: {
type: 'stores',
id: process.env.LEMONSQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: 'variants',
id: productId,
},
},
},
},
}),
});

return response.json();
}

Subscription Management

Subscription Status

// lib/subscription/types.ts
export enum SubscriptionStatus {
ACTIVE = 'active',
CANCELED = 'canceled',
INCOMPLETE = 'incomplete',
INCOMPLETE_EXPIRED = 'incomplete_expired',
PAST_DUE = 'past_due',
TRIALING = 'trialing',
UNPAID = 'unpaid',
}

export interface Subscription {
id: string;
userId: string;
provider: 'stripe' | 'lemonsqueezy';
providerId: string;
status: SubscriptionStatus;
priceId: string;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
createdAt: Date;
updatedAt: Date;
}

Feature Access Control

// lib/subscription/access.ts
export function hasFeatureAccess(
subscription: Subscription | null,
feature: string
): boolean {
if (!subscription || subscription.status !== SubscriptionStatus.ACTIVE) {
return false;
}

const tier = getTierFromPriceId(subscription.priceId);
return tierHasFeature(tier, feature);
}

export function getSubmissionLimit(
subscription: Subscription | null
): number {
if (!subscription || subscription.status !== SubscriptionStatus.ACTIVE) {
return pricingTiers.free.limits.submissions;
}

const tier = getTierFromPriceId(subscription.priceId);
return pricingTiers[tier].limits.submissions;
}

Customer Portal

// lib/stripe/portal.ts
export async function createCustomerPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account/billing`,
});

return session;
}

Payment Components

Pricing Table

// components/pricing/PricingTable.tsx
export function PricingTable() {
const { data: session } = useSession();
const { data: subscription } = useSubscription();

return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{Object.entries(pricingTiers).map(([key, tier]) => (
<PricingCard
key={key}
tier={tier}
isCurrentPlan={subscription?.priceId === tier.priceId}
onSelect={() => handlePlanSelect(tier)}
/>
))}
</div>
);
}

Checkout Button

// components/payment/CheckoutButton.tsx
interface CheckoutButtonProps {
priceId: string;
provider: 'stripe' | 'lemonsqueezy';
children: React.ReactNode;
}

export function CheckoutButton({ priceId, provider, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);

const handleCheckout = async () => {
setLoading(true);

try {
const response = await fetch(`/api/${provider}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});

const { url } = await response.json();
window.location.href = url;
} catch (error) {
console.error('Checkout error:', error);
} finally {
setLoading(false);
}
};

return (
<button
onClick={handleCheckout}
disabled={loading}
className="w-full bg-primary text-white py-2 px-4 rounded-lg disabled:opacity-50"
>
{loading ? 'Loading...' : children}
</button>
);
}

Testing Payments

Test Cards (Stripe)

// Test card numbers
export const testCards = {
visa: '4242424242424242',
visaDebit: '4000056655665556',
mastercard: '5555555555554444',
amex: '378282246310005',
declined: '4000000000000002',
insufficientFunds: '4000000000009995',
expiredCard: '4000000000000069',
};

Test Environment Setup

# Use test API keys
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."

# Test webhook endpoint
STRIPE_WEBHOOK_SECRET="whsec_test_..."

Security Considerations

Webhook Security

// Verify webhook signatures
export function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}

PCI Compliance

  • Never store card details
  • Use HTTPS for all payment pages
  • Validate input on both client and server
  • Log payment events securely
  • Regular security audits

Error Handling

Payment Errors

export enum PaymentError {
CARD_DECLINED = 'card_declined',
INSUFFICIENT_FUNDS = 'insufficient_funds',
EXPIRED_CARD = 'expired_card',
PROCESSING_ERROR = 'processing_error',
NETWORK_ERROR = 'network_error',
}

export function handlePaymentError(error: PaymentError): string {
switch (error) {
case PaymentError.CARD_DECLINED:
return 'Your card was declined. Please try a different payment method.';
case PaymentError.INSUFFICIENT_FUNDS:
return 'Insufficient funds. Please check your account balance.';
case PaymentError.EXPIRED_CARD:
return 'Your card has expired. Please use a different card.';
default:
return 'Payment failed. Please try again or contact support.';
}
}

Analytics and Reporting

Revenue Tracking

// Track revenue metrics
export async function getRevenueMetrics(period: 'month' | 'year') {
const subscriptions = await db
.select()
.from(subscriptionsTable)
.where(
and(
eq(subscriptionsTable.status, 'active'),
gte(subscriptionsTable.createdAt, getStartOfPeriod(period))
)
);

return {
totalRevenue: calculateTotalRevenue(subscriptions),
newSubscriptions: subscriptions.length,
churnRate: calculateChurnRate(subscriptions),
averageRevenuePerUser: calculateARPU(subscriptions),
};
}

Next Steps