Package Exports
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (payment-core) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
@payments/core
A framework- and provider-agnostic payments module with explicit ports, a provider registry, idempotent webhook handling, and tiny server adapters. Built to be plugged into multiple repos.
What this package provides
- Core types and ports (DI-friendly): logger, metrics, config, persistence, events, idempotency, webhook inbox, provider.
- Provider registry with a LemonSqueezy provider and an extension point for more.
- Webhook middleware for Express (Fastify adapter pattern ready).
- Dev/test router factory to seed data without external webhooks.
- High-level
PaymentService(checkout orchestration with idempotency).
Install (private registry examples)
GitHub Packages:
@payments:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}Then in your app:
npm i @payments/corePrivate npm scope (npmjs):
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
@payments:registry=https://registry.npmjs.org/Core concepts
Ports (you implement these in your app):
- Logger, Metrics
- ProviderConfigStore (per-tenant provider creds)
- BillingPersistencePort (plans, prices, subscriptions, invoices, refunds)
- DomainEventPublisherPort (emit domain events)
- IdempotencyStore (ensure once-only for commands)
- WebhookInbox (dedupe incoming events)
Providers (implemented in the package):
lemonsqueezy(baseline). You can register more viaregisterProvider(name, provider).
Types
import type { CheckoutData, Subscription, BillingEvent, Plan, Price, Invoice, Refund } from '@payments/core';LemonSqueezy Configuration
The LemonSqueezy provider automatically maps variant IDs to billing plans using the external_id column in your billing_plans table.
Database Setup
Step 1: Add external_id column and fix constraints
-- Add external_id column
ALTER TABLE billing_plans ADD COLUMN external_id VARCHAR(255);
CREATE INDEX idx_billing_plans_external_id ON billing_plans(external_id);
-- Fix provider constraint to allow lemonsqueezy
ALTER TABLE billing_plans DROP CONSTRAINT IF EXISTS billing_plans_provider_ck;
ALTER TABLE billing_plans ADD CONSTRAINT billing_plans_provider_ck CHECK (provider IN ('stripe', 'paypal', 'razorpay', 'square', 'lemonsqueezy'));Step 2: Map your plans to LemonSqueezy variant IDs
-- Map existing plans to LemonSqueezy variant IDs
UPDATE billing_plans SET external_id = '992413', provider = 'lemonsqueezy' WHERE name = 'pro';
UPDATE billing_plans SET external_id = '992415', provider = 'lemonsqueezy' WHERE name = 'basic';Step 3: Verify the setup
-- Check that mappings are correct
SELECT id, name, external_id, provider FROM billing_plans WHERE external_id IS NOT NULL;Database Verification Scripts
Create these temporary scripts to verify your database setup:
apps/server/verify-database-setup.js
const { createClient } = require('@supabase/supabase-js');
require('dotenv').config();
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);
async function verifySetup() {
console.log('๐ Verifying LemonSqueezy database setup...\n');
// Check external_id column exists
const { data: testData, error: testError } = await supabase
.from('billing_plans')
.select('external_id')
.limit(1);
if (testError && testError.message.includes('external_id')) {
console.log('โ external_id column missing - run the SQL setup first');
return;
}
console.log('โ
external_id column exists');
// Check current plans and mappings
const { data: plans } = await supabase
.from('billing_plans')
.select('id, name, external_id, provider')
.order('name');
console.log('\n๐ Current billing_plans:');
plans.forEach(plan => {
const status = plan.external_id ? 'โ
' : 'โ';
console.log(` ${status} ${plan.name}: ${plan.external_id || 'NO EXTERNAL_ID'} (${plan.provider || 'NO PROVIDER'})`);
});
// Test variant mapping
const variantMapping = {};
plans.filter(p => p.external_id && p.provider === 'lemonsqueezy').forEach(plan => {
variantMapping[plan.external_id] = plan.id;
});
console.log('\n๐ Generated variant mapping:', JSON.stringify(variantMapping, null, 2));
const testVariants = ['992413', '992415'];
testVariants.forEach(variant => {
const planId = variantMapping[variant];
console.log(`${planId ? 'โ
' : 'โ'} Variant ${variant} -> ${planId || 'NO MAPPING'}`);
});
}
verifySetup().catch(console.error);Run the verification:
cd apps/server
node verify-database-setup.jsTesting and Verification
Comprehensive API Test Script:
# Test LemonSqueezy API connection, products, and checkout creation
cd apps/server
node scripts/test-lemonsqueezy.jsThis script validates:
- โ API key authentication
- โ Store access and configuration
- โ Product and variant availability
- โ Checkout creation functionality
- โ Integration readiness
Database Verification Script:
# Verify database setup and mappings
cd apps/server
node verify-database-setup.jsThis script checks:
- โ Database schema (external_id column)
- โ Variant ID mappings
- โ Recent subscriptions and invoices
- โ Constraint compliance
The system automatically queries the database to build variant mappings dynamically. No environment variables or code changes needed for new stores/plans.
Quick start (Express)
Create adapters in your app (DB, events, etc.) implementing the ports. See example in this repo:
apps/server/src/services/payments.adapters.ts.Create a container factory wiring ports and registry:
import { providerRegistry, type PaymentContainer, PaymentService } from '@payments/core';
// import your adapters here
export function createPaymentContainer(): PaymentContainer {
return {
providers: providerRegistry,
persistence: yourPersistence,
events: yourEventBus,
logger: yourLogger,
metrics: yourMetrics,
configs: yourConfigStore,
inbox: yourWebhookInbox,
idempotency: yourIdempotencyStore,
};
}
export function createPaymentService(): PaymentService {
return new PaymentService(createPaymentContainer());
}- Mount webhook route with raw body:
import { raw } from 'body-parser';
import { createWebhookHandler } from '@payments/core';
import { createPaymentContainer } from './payments.container';
const container = createPaymentContainer();
app.post('/billing/webhook/:provider', raw({ type: '*/*' }), createWebhookHandler(container));
// Optional: mount dev/test routes
import { createDevPaymentsRoutes } from '@payments/core';
if (process.env.NODE_ENV !== 'production') {
// Protect dev routes and pass identity accessors
app.use('/payments-dev', authGuard, createDevPaymentsRoutes(container, {
getUserId: (req) => (req as any).user?.id,
getUserEmail: (req) => (req as any).user?.email,
}));
}- Initiate checkout in your controller:
import { createPaymentService } from './payments.container';
const service = createPaymentService();
const session = await service.createCheckout({
tenantId: 'default',
planId: variantId,
userId,
userEmail,
provider: 'lemonsqueezy'
});
return res.json({ checkout_url: session.checkoutUrl });Webhook handling
- Requires raw body to verify signatures.
- Multi-tenant: read
tenantIdfromx-tenant-idheader or?tenantIdquery. - Dedupe via
WebhookInbox. - Maps provider payloads to canonical events; persists facts and publishes domain events.
Dev router: seeding helpers
Endpoints (dev-only):
GET /payments-dev/webhook-statusโ quick readiness checkPOST /payments-dev/seed-subscriptionโ persist a subscription for the authenticated user
Example request (Authorization required):
POST /payments-dev/seed-subscription
Authorization: Bearer <supabase_jwt>
Content-Type: application/json
{
"provider": "lemonsqueezy",
"plan_id": "992415",
"status": "active"
}Implementation details:
- The dev route runs under your app's auth; pass hooks to read
user.id/user.email. - It coerces non-UUID
plan_idto a UUID and upserts a plan with defaults to satisfy FK (amount_cents=0,currency='usd',interval='month',is_active=true). - It persists
user_subscriptionswith fields:id,user_id,plan_id,status,start_date,current_period_end,provider,provider_subscription_id. - Ensure your DB
providerCHECK includes your provider (for LemonSqueezy add'lemonsqueezy').
Idempotency and webhook inbox: storage options
Choose one per app; no extra DB is required if you prefer cache.
- In-memory: simplest, single-instance only.
- Redis: recommended for distributed idempotency (use
SET key NX PX <ttl>). - App DB: durable; create two small tables (see SQL below) in your appโs existing schema.
SQL (Postgres/Supabase)
create table if not exists webhook_events (
provider text not null,
event_id text not null,
processed_at timestamptz,
created_at timestamptz not null default now(),
constraint webhook_events_pk primary key (provider, event_id)
);
create table if not exists idempotency_keys (
key text primary key,
result_hash text,
created_at timestamptz not null default now()
);Provider config
Implement ProviderConfigStore to supply per-tenant credentials from env or DB. Example shape:
{ provider: 'lemonsqueezy', tenantId, liveMode: false, credentials: { apiKey, signingSecret }, defaultCurrency: 'USD' }Extending providers
Register new providers at boot:
import { registerProvider } from '@payments/core';
registerProvider('stripe', stripeProviderImpl);Publishing
Monorepo scripts (example):
npm version patch -w packages/payments
npm publish -w packages/payments --access restrictedFAQ
- DB required? No. Use in-memory or Redis adapters if you don't want DB tables.
- Multi-tenant? Yes; pass
tenantIdand implementProviderConfigStore. - Frontend SDK? Keep thin: call your server to create checkout, open hosted URL.
Recent Changes Summary
This section documents the major improvements made to the @payments/core package based on real-world implementation and testing.
๐ง Database Schema Improvements
Problem: Hardcoded variant IDs and environment variable dependencies made the system inflexible and hard to scale.
Solution: Implemented database-driven variant mapping using external_id column in billing_plans table.
Changes:
- Added
external_idcolumn tobilling_planstable - Updated provider constraint to include 'lemonsqueezy'
- Dynamic variant mapping from database instead of environment variables
- Automatic fallback to variant ID if no mapping exists
๐ ๏ธ Webhook Processing Fixes
Problem: Multiple database constraint violations during webhook processing.
Solution: Fixed UUID generation, NOT NULL constraints, and foreign key relationships.
Changes:
- Generate proper UUIDs for subscription IDs instead of using LemonSqueezy numeric IDs
- Provide default
current_period_endvalues (30 days from now) to satisfy NOT NULL constraints - Map LemonSqueezy variant IDs to existing billing plan UUIDs to satisfy foreign key constraints
- Added comprehensive error handling and logging
๐๏ธ Architecture Improvements
Problem: Hardcoded values and tight coupling made the system difficult to maintain.
Solution: Made the system fully configurable and database-driven.
Changes:
- Removed hardcoded variant ID mappings
- Implemented database-driven configuration via
SupabaseConfigStore - Added dynamic variant mapping lookup in webhook processing
- Created comprehensive database verification scripts
๐ Real-Time Data Flow
How data flows from UI to database:
- User clicks "Get Started" โ
SubscriptionScreen.tsxcallspaymentService.startPayment() - Server creates checkout โ
billing.controller.tscallscreatePaymentService().createCheckout() - User completes payment โ LemonSqueezy processes payment and sends webhook
- Webhook processing โ
packages/payments/src/web/express.tsautomatically:- Verifies webhook signature
- Fetches subscription data from LemonSqueezy API
- Maps variant ID to plan UUID using database lookup
- Inserts subscription into
user_subscriptionstable - Records invoice in
invoicestable
- UI updates โ
SubscriptionScreen.tsxpolls for subscription updates and displays status
๐งช Testing and Verification
Database verification script:
# Create and run verification script
cd apps/server
node verify-database-setup.jsManual verification:
-- Check variant mappings
SELECT id, name, external_id, provider FROM billing_plans WHERE external_id IS NOT NULL;
-- Check recent subscriptions
SELECT * FROM user_subscriptions WHERE provider = 'lemonsqueezy' ORDER BY created_at DESC LIMIT 5;
-- Check recent invoices
SELECT * FROM invoices WHERE provider = 'lemonsqueezy' ORDER BY issued_at DESC LIMIT 5;๐ Benefits
- โ Scalable: Add unlimited stores/plans without code changes
- โ Multi-tenant: Each tenant can have different variant mappings
- โ Dynamic: Changes take effect immediately without restarts
- โ Maintainable: All configuration in database
- โ Robust: Comprehensive error handling and fallbacks
- โ Testable: Database verification scripts for easy debugging