Skip to content

Billing Domain

The billing domain provides payments, subscriptions, and invoicing with a vendor-independent architecture. The domain layer uses integer-cents arithmetic via the Money value object and has zero knowledge of payment providers like Stripe or Paddle.

Terminal window
npx @backcap/cli add billing

All monetary amounts use integer cents to avoid floating-point issues.

import { Money } from "./domains/billing/domain/value-objects/money.vo";
const price = Money.create(2999, "USD"); // $29.99
const tax = Money.create(450, "USD"); // $4.50
const total = price.unwrap().add(tax.unwrap()); // $34.49
const discounted = total.unwrap().multiply(0.9); // 10% off
import { Customer } from "./domains/billing/domain/entities/customer.entity";
const result = Customer.create({
id: crypto.randomUUID(),
email: "user@example.com",
name: "Jane Doe",
});

Subscriptions compose SubscriptionStatus, Money, and BillingPeriod value objects. Mutations are immutable — cancel() and changePlan() return new instances.

import { Subscription } from "./domains/billing/domain/entities/subscription.entity";
const sub = Subscription.create({
id: crypto.randomUUID(),
customerId: "cust-1",
planId: "plan-pro",
status: "active",
priceAmount: 2999,
priceCurrency: "USD",
billingInterval: "monthly",
billingStartDate: new Date(),
billingEndDate: nextMonth,
});
// Immutable cancel
const canceled = sub.unwrap().cancel("user request");
import { Invoice } from "./domains/billing/domain/entities/invoice.entity";
const invoice = Invoice.create({
id: crypto.randomUUID(),
customerId: "cust-1",
subscriptionId: "sub-1",
amountValue: 2999,
amountCurrency: "USD",
status: "open",
dueDate: new Date("2026-04-01"),
});
const paid = invoice.unwrap().markPaid();
Use CaseDescription
CreateSubscriptionCreates subscription via payment provider, persists to repository
CancelSubscriptionCancels subscription both locally and with provider
ChangeSubscriptionPlanUpdates plan and price on active subscription
GetSubscriptionRetrieves subscription by ID
Use CaseDescription
ProcessPaymentCharges customer via payment provider
RefundPaymentRefunds full or partial amount
GetPaymentHistoryLists customer’s invoices
Use CaseDescription
GenerateInvoiceCreates and persists a new invoice
GetInvoiceRetrieves invoice by ID
ListInvoicesLists all invoices for a customer

The billing domain defines four ports for adapter injection:

  • IPaymentProvidercreateCustomer, charge, refund, createSubscription, cancelSubscription, attachPaymentMethod
  • ICustomerRepositoryfindById, findByEmail, save
  • ISubscriptionRepositoryfindById, findByCustomerId, save
  • IInvoiceRepositoryfindById, findByCustomerId, save

Wire everything through the contract factory:

import { createBillingService } from "./domains/billing/contracts";
const billing = createBillingService({
customerRepository: prismaCustomerRepo,
subscriptionRepository: prismaSubscriptionRepo,
invoiceRepository: prismaInvoiceRepo,
paymentProvider: stripeAdapter, // or any IPaymentProvider implementation
});
const result = await billing.createSubscription({
customerId: "cust-1",
planId: "plan-pro",
priceAmount: 2999,
priceCurrency: "USD",
billingInterval: "monthly",
});
EventPayload
SubscriptionCreatedsubscriptionId, customerId, planId
SubscriptionCanceledsubscriptionId, customerId, reason
PaymentSucceededcustomerId, amount, currency
PaymentFailedcustomerId, amount, currency, reason
InvoiceGeneratedinvoiceId, customerId, amount, currency

To use a different payment provider (e.g. Paddle, Braintree), implement IPaymentProvider with that provider’s SDK. No changes to domain or application layers are needed — just inject your implementation through the factory.