Payment Integration Overview
The Ever Works supports multiple payment providers to handle subscriptions, one-time payments, and premium features.
Supported Payment Providers
Primary Providers
- Stripe - Recommended for most use cases
- LemonSqueezy - Great for digital products
Provider Comparison
| Feature | Stripe | LemonSqueezy |
|---|---|---|
| Global Coverage | 195+ countries | 180+ countries |
| Payment Methods | 100+ methods | 20+ methods |
| Tax Handling | Manual setup | Automatic |
| Merchant of Record | No | Yes |
| Developer Experience | Excellent | Good |
| Fees | 2.9% + 30¢ | 5% + 50¢ |
| Subscription Management | Advanced | Basic |
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),
};
}