Package Exports
- @classytic/revenue
- @classytic/revenue/enums
- @classytic/revenue/schemas
- @classytic/revenue/utils
Readme
@classytic/revenue
Enterprise revenue management with subscriptions and payment processing
Thin, focused, production-ready library with smart defaults. Built for SaaS, marketplaces, and subscription businesses.
Features
- Subscriptions: Create, renew, pause, cancel with lifecycle management
- Payment Processing: Multi-gateway support (Stripe, SSLCommerz, manual, etc.)
- Transaction Management: Income/expense tracking with verification and refunds
- Escrow & Hold/Release: Platform-as-intermediary payment flow (NEW in v0.1.0)
- Multi-Party Splits: Distribute revenue to platform, affiliates, partners (NEW)
- Affiliate Commissions: Built-in support for referral/affiliate programs (NEW)
- Commission Tracking: Automatic platform commission calculation with gateway fee deduction
- Provider Pattern: Pluggable payment providers (like LangChain/Vercel AI SDK)
- Framework Agnostic: Works with Express, Fastify, Next.js, or standalone
- TypeScript Ready: Full type definitions included
Installation
npm install @classytic/revenue
npm install @classytic/revenue-manual # For manual paymentsQuick Start
Single-Tenant (Simple SaaS)
import { createRevenue } from '@classytic/revenue';
import { ManualProvider } from '@classytic/revenue-manual';
const revenue = createRevenue({
models: { Transaction },
providers: { manual: new ManualProvider() },
});
// Create subscription (no organizationId needed)
const { transaction } = await revenue.monetization.create({
data: { customerId: user._id },
planKey: 'monthly',
monetizationType: 'subscription',
amount: 2999, // $29.99
gateway: 'manual',
paymentData: { method: 'card' },
});
// Verify → Refund (use transaction._id - works for all providers)
await revenue.payments.verify(transaction._id);Multi-Tenant (Marketplace/Platform)
// Same API, just pass organizationId
const { transaction } = await revenue.monetization.create({
data: {
organizationId: vendor._id, // ← Multi-tenant
customerId: customer._id,
referenceId: order._id,
referenceModel: 'Order',
},
planKey: 'one_time',
monetizationType: 'purchase', // One-time purchase
amount: 1500,
gateway: 'manual',
paymentData: { method: 'bkash' },
});Works for both! Same API, different use cases.
Transaction Model Setup
The library requires a Transaction model with specific fields and provides reusable schemas:
import mongoose from 'mongoose';
import {
TRANSACTION_TYPE_VALUES,
TRANSACTION_STATUS_VALUES,
} from '@classytic/revenue/enums';
import {
gatewaySchema,
paymentDetailsSchema,
} from '@classytic/revenue/schemas';
const transactionSchema = new mongoose.Schema({
// ============ REQUIRED BY LIBRARY ============
amount: { type: Number, required: true, min: 0 },
type: { type: String, enum: TRANSACTION_TYPE_VALUES, required: true }, // 'income' | 'expense'
method: { type: String, required: true }, // 'manual' | 'bkash' | 'card' | etc.
status: { type: String, enum: TRANSACTION_STATUS_VALUES, required: true },
category: { type: String, required: true }, // Your custom categories
// ============ MULTI-TENANT (optional) ============
organizationId: { type: String, index: true }, // For multi-tenant platforms
// ============ LIBRARY SCHEMAS (nested) ============
gateway: gatewaySchema, // Payment gateway details
paymentDetails: paymentDetailsSchema, // Payment info (wallet, bank, etc.)
// ============ POLYMORPHIC REFERENCE (recommended) ============
// Links transaction to any entity (Order, Subscription, Enrollment, etc.)
referenceId: {
type: mongoose.Schema.Types.ObjectId,
refPath: 'referenceModel',
},
referenceModel: {
type: String,
enum: ['Subscription', 'Order', 'Enrollment', 'Membership'], // Your models
},
// ============ YOUR CUSTOM FIELDS ============
customerId: String,
currency: { type: String, default: 'BDT' },
verifiedAt: Date,
verifiedBy: mongoose.Schema.Types.ObjectId,
refundedAmount: Number,
idempotencyKey: { type: String, unique: true, sparse: true },
metadata: mongoose.Schema.Types.Mixed,
}, { timestamps: true });
export default mongoose.model('Transaction', transactionSchema);Available Schemas
| Schema | Purpose | Key Fields |
|---|---|---|
gatewaySchema |
Payment gateway integration | type, sessionId, paymentIntentId |
paymentDetailsSchema |
Payment method info | walletNumber, trxId, bankName |
commissionSchema |
Commission tracking (marketplace) | rate, grossAmount, gatewayFeeAmount, netAmount |
currentPaymentSchema |
Latest payment (for Order/Subscription models) | transactionId, status, verifiedAt |
subscriptionInfoSchema |
Subscription details (for Order models) | planKey, startDate, endDate |
Usage: Import and use as nested objects (NOT spread):
import { gatewaySchema } from '@classytic/revenue/schemas';
const schema = new mongoose.Schema({
gateway: gatewaySchema, // ✅ Correct - nested
// ...gatewaySchema, // ❌ Wrong - don't spread
});Core API
Subscriptions
// Create subscription
const { subscription, transaction, paymentIntent } =
await revenue.monetization.create({
data: { organizationId, customerId },
planKey: 'monthly',
amount: 1500,
currency: 'BDT',
gateway: 'manual',
paymentData: { method: 'bkash', walletNumber: '01712345678' },
});
// Verify and activate
await revenue.payments.verify(transaction._id);
await revenue.monetization.activate(subscription._id);
// Renew subscription
await revenue.monetization.renew(subscription._id, {
gateway: 'manual',
paymentData: { method: 'nagad' },
});
// Pause/Resume
await revenue.monetization.pause(subscription._id, { reason: 'Customer request' });
await revenue.monetization.resume(subscription._id, { extendPeriod: true });
// Cancel
await revenue.monetization.cancel(subscription._id, { immediate: true });Payments
// Verify payment (admin approval for manual)
const { transaction } = await revenue.payments.verify(paymentIntentId, {
verifiedBy: adminUserId,
});
// Get payment status
const { status } = await revenue.payments.getStatus(paymentIntentId);
// Refund (creates separate EXPENSE transaction)
const { transaction, refundTransaction } = await revenue.payments.refund(
transactionId,
500, // Amount or null for full refund
{ reason: 'Customer requested' }
);
// Handle webhook (for automated providers like Stripe)
const { event, transaction } = await revenue.payments.handleWebhook(
'stripe',
webhookPayload,
headers
);Transactions
// Get transaction by ID
const transaction = await revenue.transactions.get(transactionId);
// List with filters
const { transactions, total } = await revenue.transactions.list(
{ type: 'income', status: 'verified' },
{ limit: 50, sort: { createdAt: -1 } }
);
// Calculate net revenue
const income = await revenue.transactions.list({ type: 'income' });
const expense = await revenue.transactions.list({ type: 'expense' });
const netRevenue = income.total - expense.total;Transaction Types (Income vs Expense)
The library uses double-entry accounting:
- INCOME (
'income'): Money coming in - payments, subscriptions - EXPENSE (
'expense'): Money going out - refunds, payouts
const revenue = createRevenue({
models: { Transaction },
config: {
transactionTypeMapping: {
subscription: 'income',
purchase: 'income',
refund: 'expense', // Refunds create separate expense transactions
},
},
});Refund Pattern:
- Refund creates NEW transaction with
type: 'expense' - Original transaction status becomes
'refunded'or'partially_refunded' - Both linked via metadata for audit trail
- Calculate net:
SUM(income) - SUM(expense)
Custom Categories
Map logical entities to transaction categories:
const revenue = createRevenue({
models: { Transaction },
config: {
categoryMappings: {
Order: 'order_subscription',
PlatformSubscription: 'platform_subscription',
Membership: 'gym_membership',
Enrollment: 'course_enrollment',
},
},
});
// Usage
await revenue.monetization.create({
entity: 'Order', // Maps to 'order_subscription' category
monetizationType: 'subscription',
// ...
});Note: entity is a logical identifier (not a database model name) for organizing your business logic.
Commission Tracking (Marketplace)
Automatically calculate platform commission with gateway fee deduction:
const revenue = createRevenue({
models: { Transaction },
config: {
// Commission rates by category
commissionRates: {
'product_order': 0.10, // 10% platform commission
'course_enrollment': 0.10, // 10% on courses
'gym_membership': 0, // No commission
},
// Gateway fees (deducted from commission)
gatewayFeeRates: {
'stripe': 0.029, // 2.9% Stripe fee
'bkash': 0.018, // 1.8% bKash fee
'manual': 0, // No fee
},
},
});
// Commission calculated automatically
const { transaction } = await revenue.monetization.create({
amount: 10000, // $100
entity: 'ProductOrder', // → 10% commission
gateway: 'stripe', // → 2.9% fee
});
console.log(transaction.commission);
// {
// rate: 0.10,
// grossAmount: 1000, // $10 (10% of $100)
// gatewayFeeAmount: 290, // $2.90 (2.9% of $100)
// netAmount: 710, // $7.10 (platform keeps)
// status: 'pending'
// }
// Query pending commissions
const pending = await Transaction.find({ 'commission.status': 'pending' });Refund handling: Commission automatically reversed proportionally when refunds are processed.
See: examples/commission-tracking.js for complete guide.
Subscription Utilities
Universal helpers for period calculation, proration, and action eligibility:
import {
// Period calculation
calculatePeriodRange,
calculateProratedAmount,
addDuration,
// Action eligibility
canRenewSubscription,
canPauseSubscription,
isSubscriptionActive,
} from '@classytic/revenue/utils';
// Calculate period
const { startDate, endDate } = calculatePeriodRange({
duration: 30,
unit: 'days',
});
// Calculate prorated refund
const refund = calculateProratedAmount({
amountPaid: 1500,
startDate: sub.startDate,
endDate: sub.endDate,
asOfDate: new Date(),
});
// Check eligibility
if (canRenewSubscription(membership)) {
await revenue.monetization.renew(membership.subscriptionId);
}Escrow & Multi-Party Splits (v0.1.0+)
NEW: Platform-as-intermediary payment flow for marketplaces, group buy, and affiliate systems.
Basic Escrow Flow
// 1. Customer makes purchase
const { transaction } = await revenue.monetization.create({
amount: 1000,
gateway: 'stripe',
// ...
});
// 2. Verify payment
await revenue.payments.verify(transaction._id);
// 3. Hold in escrow
await revenue.escrow.hold(transaction._id);
// 4. Split to multiple recipients
await revenue.escrow.split(transaction._id, [
{ type: 'platform_commission', recipientId: 'platform', rate: 0.10 },
{ type: 'affiliate_commission', recipientId: 'affiliate-123', rate: 0.05 },
]);
// Auto-releases remainder to organization (85%)
// Or manually release
await revenue.escrow.release(transaction._id, {
recipientId: 'org-123',
recipientType: 'organization',
});Affiliate Commission (Simplified API)
import { calculateCommissionWithSplits } from '@classytic/revenue';
const commission = calculateCommissionWithSplits(
5000, // amount
0.10, // platform rate
0.029, // gateway fee
{
affiliateRate: 0.05,
affiliateId: 'affiliate-123',
}
);
// Returns:
// {
// grossAmount: 500, // Platform: 10%
// netAmount: 355, // After gateway fee (2.9%)
// affiliate: {
// grossAmount: 250, // Affiliate: 5%
// netAmount: 250,
// },
// splits: [...]
// }Multi-Party Splits
import { calculateSplits } from '@classytic/revenue';
const splits = calculateSplits(10000, [
{ type: 'platform_commission', recipientId: 'platform', rate: 0.10 },
{ type: 'affiliate_commission', recipientId: 'level1', rate: 0.05 },
{ type: 'affiliate_commission', recipientId: 'level2', rate: 0.02 },
{ type: 'partner_commission', recipientId: 'partner', rate: 0.03 },
], 0.029); // Gateway fee
// Returns splits array with calculated amounts
// Organization receives: 8000 (80%)Schemas for Escrow
Add to your transaction model when using escrow:
import { holdSchema, splitsSchema } from '@classytic/revenue';
TransactionSchema.add(holdSchema); // Adds hold/release tracking
TransactionSchema.add(splitsSchema); // Adds multi-party splitsUse Cases:
- E-commerce marketplaces (hold until delivery confirmed)
- Course platforms with affiliates
- Group buy / crowdfunding
- Multi-level marketing
- SaaS reseller programs
See: ESCROW_FEATURES.md and examples/escrow-flow.js
Polymorphic References
Link transactions to any entity (Order, Subscription, Enrollment):
// Create transaction linked to Order
const { transaction } = await revenue.monetization.create({
data: {
organizationId,
customerId,
referenceId: order._id, // ⭐ Direct field (not metadata)
referenceModel: 'Order', // ⭐ Model name
},
amount: 1500,
// ...
});
// Query all transactions for an order
const orderTransactions = await Transaction.find({
referenceModel: 'Order',
referenceId: order._id,
});
// Use Mongoose populate
const transactions = await Transaction.find({ ... })
.populate('referenceId'); // Populates based on referenceModelWhy top-level? Enables proper Mongoose queries and population. Storing in metadata prevents querying and indexing.
Hooks
const revenue = createRevenue({
models: { Transaction },
hooks: {
// Monetization lifecycle (specific)
'purchase.created': async ({ transaction, isFree }) => {
console.log('One-time purchase:', transaction._id);
},
'subscription.created': async ({ transaction, isFree }) => {
console.log('Recurring subscription:', transaction._id);
},
'free.created': async ({ transaction }) => {
console.log('Free access granted:', transaction._id);
},
// Generic event (fires for all types)
'monetization.created': async ({ transaction, monetizationType }) => {
console.log(`${monetizationType} created:`, transaction._id);
},
// Payment lifecycle
'payment.verified': async ({ transaction }) => {
// Send confirmation email
},
'payment.failed': async ({ transaction, error, provider }) => {
// Alert admin or send customer notification
console.error('Payment failed:', error);
},
'payment.refunded': async ({ refundTransaction }) => {
// Process refund notification
},
// Subscription management (requires Subscription model)
'subscription.activated': async ({ subscription }) => {
// Subscription activated after payment
},
'subscription.renewed': async ({ subscription, renewalCount }) => {
// Subscription renewed
},
'subscription.paused': async ({ subscription }) => {
// Subscription paused
},
'subscription.resumed': async ({ subscription }) => {
// Subscription resumed
},
'subscription.cancelled': async ({ subscription }) => {
// Subscription cancelled
},
},
});Available hooks:
Monetization Events (specific):
purchase.created- One-time purchasesubscription.created- Recurring subscriptionfree.created- Free access grantedmonetization.created- Generic event (fires for all types)
Payment Events:
payment.verified- Payment confirmedpayment.failed- Payment verification failedpayment.refunded- Refund processedpayment.webhook.{type}- Webhook events from providers
Subscription Management Events (requires Subscription model):
subscription.activated,subscription.renewedsubscription.paused,subscription.resumed,subscription.cancelled
Provider Patterns
Ready-to-use patterns for popular payment gateways (copy to your project):
Available Patterns
| Pattern | Use Case | Location |
|---|---|---|
| stripe-checkout | Single-tenant Stripe | provider-patterns/stripe-checkout/ |
| stripe-connect-standard | Multi-tenant marketplace | provider-patterns/stripe-connect-standard/ |
| stripe-platform-manual | Platform collects, manual payout | provider-patterns/stripe-platform-manual/ |
| sslcommerz | Bangladesh payment gateway | provider-patterns/sslcommerz/ |
See: provider-patterns/INDEX.md for complete guide.
Building Custom Providers
Create providers for any payment gateway:
import { PaymentProvider, PaymentIntent, PaymentResult } from '@classytic/revenue';
export class StripeProvider extends PaymentProvider {
constructor(config) {
super(config);
this.name = 'stripe';
this.stripe = new Stripe(config.apiKey);
}
async createIntent(params) {
const intent = await this.stripe.paymentIntents.create({
amount: params.amount,
currency: params.currency,
});
return new PaymentIntent({
id: intent.id,
sessionId: null, // No session for direct intents
paymentIntentId: intent.id, // Available immediately
provider: 'stripe',
status: intent.status,
amount: intent.amount,
currency: intent.currency,
clientSecret: intent.client_secret,
raw: intent,
});
}
async verifyPayment(intentId) {
const intent = await this.stripe.paymentIntents.retrieve(intentId);
return new PaymentResult({
id: intent.id,
provider: 'stripe',
status: intent.status === 'succeeded' ? 'succeeded' : 'failed',
paidAt: new Date(),
raw: intent,
});
}
// Implement: getStatus(), refund(), handleWebhook()
}See: docs/guides/PROVIDER_GUIDE.md for complete guide.
TypeScript
Full TypeScript support included:
import { createRevenue, Revenue, PaymentService } from '@classytic/revenue';
import { TRANSACTION_TYPE, TRANSACTION_STATUS } from '@classytic/revenue/enums';
const revenue: Revenue = createRevenue({
models: { Transaction },
});
// All services are fully typed
const payment = await revenue.payments.verify(id);
const subscription = await revenue.monetization.create({ ... });Examples
examples/single-tenant.js- Simple SaaS (no organizations)examples/transaction.model.js- Complete model setupexamples/complete-flow.js- Full lifecycle (types, refs, state)examples/commission-tracking.js- Commission calculationexamples/hooks-v0.2.0.js- v0.2.0 semantic hooks (NEW)
Error Handling
import {
TransactionNotFoundError,
ProviderNotFoundError,
AlreadyVerifiedError,
RefundError,
} from '@classytic/revenue';
try {
await revenue.payments.verify(id);
} catch (error) {
if (error instanceof AlreadyVerifiedError) {
console.log('Already verified');
} else if (error instanceof TransactionNotFoundError) {
console.log('Transaction not found');
}
}Documentation
- Provider Guide - Build custom payment providers
- Architecture - System design and patterns
- API Reference - Complete API documentation
Support
- GitHub: classytic/revenue
- Issues: Report bugs
- NPM: @classytic/revenue
License
MIT © Classytic