JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 70
  • Score
    100M100P100Q88831F
  • License MIT

Enterprise revenue management system with subscriptions, payments, escrow, splits - Modern, Type-safe, Resilient

Package Exports

  • @classytic/revenue
  • @classytic/revenue/core
  • @classytic/revenue/enums
  • @classytic/revenue/providers
  • @classytic/revenue/schemas
  • @classytic/revenue/schemas/validation
  • @classytic/revenue/services
  • @classytic/revenue/utils

Readme

@classytic/revenue

Modern, Type-safe Revenue Management for Node.js

Enterprise-grade library for subscriptions, payments, escrow, and multi-party splits. Built with TypeScript, Zod validation, and resilience patterns.

Installation

npm install @classytic/revenue @classytic/revenue-manual

Quick Start

import { Revenue, Money, loggingPlugin } from '@classytic/revenue';
import { ManualProvider } from '@classytic/revenue-manual';

const revenue = Revenue
  .create({ defaultCurrency: 'USD' })
  .withModels({ Transaction, Subscription })
  .withProvider('manual', new ManualProvider())
  .withProvider('stripe', new StripeProvider({ apiKey: '...' }))
  .withPlugin(loggingPlugin())
  .withRetry({ maxAttempts: 3, baseDelay: 1000 })
  .withCircuitBreaker()
  .withCommission(10, 2.5) // 10% platform, 2.5% gateway fee
  .forEnvironment('production')
  .build();

// Access services
await revenue.monetization.create({ ... });
await revenue.payments.verify(transactionId);
await revenue.escrow.hold(transactionId);

Shorthand Factory

import { createRevenue } from '@classytic/revenue';

const revenue = createRevenue({
  models: { Transaction, Subscription },
  providers: { manual: new ManualProvider() },
  options: { defaultCurrency: 'USD' },
});

Core Concepts

Money (Integer-Safe Currency)

import { Money } from '@classytic/revenue';

// Create from cents (safe)
const price = Money.usd(1999);        // $19.99
const price2 = Money.of(19.99, 'USD'); // Auto-converts to 1999 cents

// Arithmetic
const discounted = price.multiply(0.9);  // 10% off
const withTax = price.add(Money.usd(200));
const perPerson = price.divide(3);

// Format
console.log(price.format());      // "$19.99"
console.log(price.toUnit());      // 19.99
console.log(price.amount);        // 1999 (integer cents)

// Split fairly (handles rounding)
const [a, b, c] = Money.usd(100).allocate([1, 1, 1]); // [34, 33, 33] cents

Result Type (No Throws)

import { Result, ok, err, match } from '@classytic/revenue';

// Execute with Result
const result = await revenue.execute(
  () => riskyOperation(),
  { idempotencyKey: 'order_123' }
);

// Pattern matching
match(result, {
  ok: (value) => console.log('Success:', value),
  err: (error) => console.log('Error:', error.message),
});

// Or simple check
if (result.ok) {
  console.log(result.value);
} else {
  console.log(result.error);
}

Type-Safe Events

// Subscribe to events
revenue.on('payment.succeeded', (event) => {
  console.log('Transaction:', event.transactionId);
  console.log('Amount:', event.transaction.amount);
});

revenue.on('subscription.renewed', (event) => {
  sendEmail(event.subscription.customerId, 'Renewed!');
});

revenue.on('escrow.released', (event) => {
  console.log('Released:', event.releasedAmount);
});

// Wildcard - catch all events
revenue.on('*', (event) => {
  analytics.track(event.type, event);
});

Validation (Zod v4)

import { CreatePaymentSchema, validate, safeValidate } from '@classytic/revenue';

// Validate input (throws on error)
const payment = validate(CreatePaymentSchema, userInput);

// Safe validation (returns result)
const result = safeValidate(CreatePaymentSchema, userInput);
if (!result.success) {
  console.log(result.error.issues);
}

Services

Monetization (Purchases & Subscriptions)

// One-time purchase
const { transaction, paymentIntent } = await revenue.monetization.create({
  data: {
    customerId: user._id,
    organizationId: org._id,
    referenceId: order._id,
    referenceModel: 'Order',
  },
  planKey: 'one_time',
  monetizationType: 'purchase',
  amount: 1500,
  gateway: 'manual',
  paymentData: { method: 'card' },
});

// Recurring subscription
const { subscription, transaction } = await revenue.monetization.create({
  data: { customerId: user._id },
  planKey: 'monthly',
  monetizationType: 'subscription',
  amount: 2999,
  gateway: 'stripe',
});

// Lifecycle management
await revenue.monetization.activate(subscription._id);
await revenue.monetization.renew(subscription._id, { gateway: 'stripe' });
await revenue.monetization.pause(subscription._id, { reason: 'Vacation' });
await revenue.monetization.resume(subscription._id);
await revenue.monetization.cancel(subscription._id, { immediate: true });

Payments

// Verify payment
const { transaction, paymentResult } = await revenue.payments.verify(
  transactionId,
  { verifiedBy: adminId }
);

// Get status
const { status, provider } = await revenue.payments.getStatus(transactionId);

// Full refund
const { refundTransaction } = await revenue.payments.refund(transactionId);

// Partial refund
const { refundTransaction } = await revenue.payments.refund(
  transactionId,
  500, // Amount in cents
  { reason: 'Partial return' }
);

// Handle webhook
const { event, transaction } = await revenue.payments.handleWebhook(
  'stripe',
  payload,
  headers
);

Escrow (Hold/Release)

// Hold funds in escrow
await revenue.escrow.hold(transactionId, {
  holdUntil: new Date('2024-12-31'),
  reason: 'Awaiting delivery confirmation',
});

// Release to recipient
await revenue.escrow.release(transactionId, {
  recipientId: vendorId,
  recipientType: 'organization',
  amount: 800, // Partial release
});

// Multi-party split
await revenue.escrow.split(transactionId, [
  { type: 'platform_commission', recipientId: 'platform', rate: 0.10 },
  { type: 'affiliate_commission', recipientId: 'aff_123', rate: 0.05 },
]);

// Cancel hold
await revenue.escrow.cancelHold(transactionId, { reason: 'Order cancelled' });

Plugins

import { loggingPlugin, auditPlugin, metricsPlugin, definePlugin } from '@classytic/revenue';

// Built-in plugins
const revenue = Revenue
  .create()
  .withPlugin(loggingPlugin({ level: 'info' }))
  .withPlugin(auditPlugin({ store: saveToDatabase }))
  .withPlugin(metricsPlugin({ onMetric: sendToDatadog }))
  .build();

// Custom plugin
const rateLimitPlugin = definePlugin({
  name: 'rate-limit',
  hooks: {
    'payment.create.before': async (ctx, input, next) => {
      if (await isRateLimited(input.customerId)) {
        throw new Error('Rate limited');
      }
      return next();
    },
  },
});

Resilience

Retry with Exponential Backoff

import { retry, retryWithResult, isRetryableError } from '@classytic/revenue';

// Simple retry
const data = await retry(
  () => fetchPaymentStatus(id),
  {
    maxAttempts: 5,
    baseDelay: 1000,
    maxDelay: 30000,
    backoffMultiplier: 2,
    jitter: 0.1,
  }
);

// Retry with Result (no throws)
const result = await retryWithResult(() => processPayment());
if (!result.ok) {
  console.log('All retries failed:', result.error.errors);
}

Circuit Breaker

import { CircuitBreaker, createCircuitBreaker } from '@classytic/revenue';

const breaker = createCircuitBreaker({
  failureThreshold: 5,
  resetTimeout: 30000,
});

const result = await breaker.execute(() => callExternalAPI());

// Check state
console.log(breaker.getState()); // 'closed' | 'open' | 'half-open'

Idempotency

import { IdempotencyManager } from '@classytic/revenue';

const idempotency = new IdempotencyManager({ ttl: 86400000 }); // 24h

const result = await idempotency.execute(
  'payment_order_123',
  { amount: 1999, customerId: 'cust_1' },
  () => chargeCard()
);

// Same key + same params = cached result
// Same key + different params = error

Transaction Model Setup

ONE Transaction model = Universal Financial Ledger

The Transaction model is the ONLY required model. Use it for subscriptions, purchases, refunds, and operational expenses. The Subscription model is optional (only for tracking subscription state).

import mongoose from 'mongoose';
import {
  // Enums
  TRANSACTION_TYPE_VALUES,
  TRANSACTION_STATUS_VALUES,
  // Mongoose schemas (compose into your model)
  gatewaySchema,
  paymentDetailsSchema,
  commissionSchema,
  holdSchema,
  splitSchema,
} from '@classytic/revenue';

// Your app-specific categories
const CATEGORIES = [
  'platform_subscription',
  'course_enrollment',
  'product_order',
  'refund',
  'rent',
  'salary',
  'utilities',
];

const transactionSchema = new mongoose.Schema({
  // Core fields
  organizationId: { type: mongoose.Schema.Types.ObjectId, required: true, index: true },
  customerId: { type: mongoose.Schema.Types.ObjectId, index: true },
  type: { type: String, enum: TRANSACTION_TYPE_VALUES, required: true }, // income | expense
  category: { type: String, enum: CATEGORIES, index: true },
  status: { type: String, enum: TRANSACTION_STATUS_VALUES, default: 'pending' },
  amount: { type: Number, required: true, min: 0 },
  currency: { type: String, default: 'USD' },
  method: { type: String, required: true },

  // Library schemas (compose, don't spread)
  gateway: gatewaySchema,
  commission: commissionSchema,
  paymentDetails: paymentDetailsSchema,
  hold: holdSchema,
  splits: [splitSchema],

  // Polymorphic reference (link to any entity)
  referenceId: { type: mongoose.Schema.Types.ObjectId, refPath: 'referenceModel' },
  referenceModel: { type: String, enum: ['Subscription', 'Order', 'Enrollment'] },

  // Idempotency & verification
  idempotencyKey: { type: String, unique: true, sparse: true },
  verifiedAt: Date,
  verifiedBy: mongoose.Schema.Types.Mixed, // ObjectId or 'system'
  
  // Refunds
  refundedAmount: Number,
  refundedAt: Date,

  metadata: mongoose.Schema.Types.Mixed,
}, { timestamps: true });

export const Transaction = mongoose.model('Transaction', transactionSchema);

Available Schemas

Schema Purpose Usage
gatewaySchema Payment gateway details gateway: gatewaySchema
commissionSchema Platform commission commission: commissionSchema
paymentDetailsSchema Manual payment info paymentDetails: paymentDetailsSchema
holdSchema Escrow hold/release hold: holdSchema
splitSchema Multi-party splits splits: [splitSchema]
currentPaymentSchema For Order/Subscription models (includes refund tracking) currentPayment: currentPaymentSchema

Usage: Import and use as nested objects (NOT spread):

import { gatewaySchema, commissionSchema } from '@classytic/revenue';

const schema = new mongoose.Schema({
  gateway: gatewaySchema,     // ✅ Correct - nested
  commission: commissionSchema,
  // ...gatewaySchema,        // ❌ Wrong - don't spread
});

Group Payments (Split Pay)

Multiple payers can contribute to one purchase using referenceId:

// Order total: $100 (10000 cents)
const orderId = new mongoose.Types.ObjectId();
const orderTotal = 10000;

// Friend 1 pays $40
await revenue.monetization.create({
  data: {
    customerId: friend1,
    organizationId: restaurantId,
    referenceId: orderId,
    referenceModel: 'Order',
  },
  planKey: 'split_payment',
  monetizationType: 'purchase',
  amount: 4000,
  gateway: 'stripe',
  metadata: { splitGroup: 'dinner_dec_10' },
});

// Friend 2 pays $35
await revenue.monetization.create({
  data: {
    customerId: friend2,
    organizationId: restaurantId,
    referenceId: orderId,
    referenceModel: 'Order',
  },
  planKey: 'split_payment',
  monetizationType: 'purchase',
  amount: 3500,
  gateway: 'stripe',
  metadata: { splitGroup: 'dinner_dec_10' },
});

// Friend 3 pays $25
await revenue.monetization.create({
  data: {
    customerId: friend3,
    organizationId: restaurantId,
    referenceId: orderId,
    referenceModel: 'Order',
  },
  planKey: 'split_payment',
  monetizationType: 'purchase',
  amount: 2500,
  gateway: 'stripe',
  metadata: { splitGroup: 'dinner_dec_10' },
});

Check Payment Status

// Get all contributions for an order
const contributions = await Transaction.find({
  referenceId: orderId,
  referenceModel: 'Order',
});

// Calculate totals
const verified = contributions.filter(t => t.status === 'verified');
const totalPaid = verified.reduce((sum, t) => sum + t.amount, 0);
const remaining = orderTotal - totalPaid;
const isFullyPaid = totalPaid >= orderTotal;

console.log({
  totalPaid,      // 10000
  remaining,      // 0
  isFullyPaid,    // true
  payers: verified.map(t => ({
    customerId: t.customerId,
    amount: t.amount,
    paidAt: t.verifiedAt,
  })),
});

Query by Split Group

// Find all payments in a split group
const groupPayments = await Transaction.find({
  'metadata.splitGroup': 'dinner_dec_10',
});

// Pending payers
const pending = await Transaction.find({
  referenceId: orderId,
  status: 'pending',
});

Building Custom Providers

import { PaymentProvider, PaymentIntent, PaymentResult, RefundResult, WebhookEvent } from '@classytic/revenue';
import type { CreateIntentParams, ProviderCapabilities } from '@classytic/revenue';

export class StripeProvider extends PaymentProvider {
  public override readonly name = 'stripe';
  private stripe: Stripe;

  constructor(config: { apiKey: string }) {
    super(config);
    this.stripe = new Stripe(config.apiKey);
  }

  async createIntent(params: CreateIntentParams): Promise<PaymentIntent> {
    const intent = await this.stripe.paymentIntents.create({
      amount: params.amount,
      currency: params.currency ?? 'usd',
      metadata: params.metadata,
    });

    return new PaymentIntent({
      id: intent.id,
      paymentIntentId: intent.id,
      sessionId: null,
      provider: this.name,
      status: intent.status,
      amount: intent.amount,
      currency: intent.currency,
      clientSecret: intent.client_secret!,
      metadata: params.metadata ?? {},
    });
  }

  async verifyPayment(intentId: string): Promise<PaymentResult> {
    const intent = await this.stripe.paymentIntents.retrieve(intentId);
    return new PaymentResult({
      id: intent.id,
      provider: this.name,
      status: intent.status === 'succeeded' ? 'succeeded' : 'failed',
      amount: intent.amount,
      currency: intent.currency,
      paidAt: intent.status === 'succeeded' ? new Date() : undefined,
      metadata: {},
    });
  }

  async getStatus(intentId: string): Promise<PaymentResult> {
    return this.verifyPayment(intentId);
  }

  async refund(paymentId: string, amount?: number | null): Promise<RefundResult> {
    const refund = await this.stripe.refunds.create({
      payment_intent: paymentId,
      amount: amount ?? undefined,
    });

    return new RefundResult({
      id: refund.id,
      provider: this.name,
      status: refund.status === 'succeeded' ? 'succeeded' : 'failed',
      amount: refund.amount,
      currency: refund.currency,
      refundedAt: new Date(),
      metadata: {},
    });
  }

  async handleWebhook(payload: unknown, headers?: Record<string, string>): Promise<WebhookEvent> {
    const sig = headers?.['stripe-signature'];
    const event = this.stripe.webhooks.constructEvent(
      payload as string,
      sig!,
      this.config.webhookSecret as string
    );

    return new WebhookEvent({
      id: event.id,
      provider: this.name,
      type: event.type,
      data: event.data.object as any,
      createdAt: new Date(event.created * 1000),
    });
  }

  override getCapabilities(): ProviderCapabilities {
    return {
      supportsWebhooks: true,
      supportsRefunds: true,
      supportsPartialRefunds: true,
      requiresManualVerification: false,
    };
  }
}

Error Handling

import {
  RevenueError,
  TransactionNotFoundError,
  AlreadyVerifiedError,
  RefundError,
  ProviderNotFoundError,
  ValidationError,
  isRevenueError,
  isRetryable,
} from '@classytic/revenue';

try {
  await revenue.payments.verify(id);
} catch (error) {
  if (error instanceof AlreadyVerifiedError) {
    console.log('Already verified:', error.metadata.transactionId);
  } else if (error instanceof TransactionNotFoundError) {
    console.log('Not found');
  } else if (isRevenueError(error) && isRetryable(error)) {
    // Retry the operation
  }
}

TypeScript

Full TypeScript support with exported types:

import type {
  Revenue,
  TransactionDocument,
  SubscriptionDocument,
  PaymentProviderInterface,
  CreateIntentParams,
  ProviderCapabilities,
  RevenueEvents,
  MonetizationCreateParams,
} from '@classytic/revenue';

Testing

# Run all tests (75 tests)
npm test

# Run integration tests (requires MongoDB)
npm test -- tests/integration/

# Watch mode
npm run test:watch

# Coverage
npm run test:coverage

License

MIT © Classytic