Skip to main content

Architecture Overview

Event Sourcing + Plugin Architecture for contract-to-cash billing

1. Overview

1.1 Purpose

Contract and billing logic is fundamentally similar across SaaS and service businesses. This package provides:

  • Contract lifecycle management (one-time / subscription / usage-based)
  • Complete audit trail via Event Sourcing
  • Extensibility through a Plugin Architecture (coupons, discounts, tax, etc.)

1.2 System Diagram

2. Design Principles

2.1 Layer Architecture (Clean Architecture + DDD)

Strict rules:

  • domain/ takes no third-party external dependencies: only the stdlib, github.com/oklog/ulid/v2, and the same-module infrastructure-free eventstore/ interfaces (the event-sourced domain/contract aggregate embeds eventstore.BaseAggregate and implements eventstore.DomainEvent/EventRegistry; eventstore/ itself depends only on domain/shared, so no cycle). No other domain/* package imports eventstore/.
  • application/ depends on domain/ plus the infrastructure-free base packages eventstore/ and plugin/ (see the dependency graph in 2.2), never on infrastructure/
  • Dependencies always point inward (Dependency Inversion)
  • Interfaces are defined in domain/ or application/port/; implementations live in infrastructure/

2.2 Dependency Graph

2.3 CQRS (Command Query Responsibility Segregation)

ItemDecision
CQRSSimplified CQRS
Projection updatesConsumer's choice
DescriptionUses projection tables in the same DB. Sync/async selectable via options

2.4 Other Design Decisions

ItemDecisionNotes
Multi-tenancyDelegated to consumerNot handled by this library
TimezoneUTC onlyAll event timestamps are UTC
Billing cyclesProvided by libraryDaily / Weekly / Monthly / Yearly

3. Package Structure

github.com/contract-to-cash/core/
├── domain/ # Domain layer (no external deps)
│ ├── contract/ # Event Sourced aggregate
│ ├── invoice/ # Invoice + CreditNote entities
│ ├── payment/ # Payment entity + Dunning
│ ├── balance/ # Credit ledger
│ ├── billing/ # Billing calculation abstraction
│ ├── pricing/ # Immutable Price, pricing models
│ ├── product/ # Product definition
│ ├── usage/ # Usage record + summary
│ └── shared/ # Shared value objects (Money, Clock, etc.)

├── application/ # Application layer
│ ├── port/ # External integration IFs (PaymentGateway, etc.)
│ ├── query/ # Temporal query service
│ ├── projection/ # Projection service (sync/async)
│ ├── tx/ # Transaction management (TxManager, Saga)
│ └── service/ # BillingService, PaymentService, SnapshotService, CreditNoteService

├── plugin/ # Plugin system core
├── eventstore/ # Event Store interfaces
├── batch/ # Batch processing (ContractRenewal, etc.)
├── infrastructure/inmemory/ # In-memory implementations (test/demo)
└── plugins/ # Official plugins
├── coupon/
├── tax/
└── invoicecleanup/

4. Core Components

4.1 Domain Entities

EntityKindNotes
ContractEvent Sourced AggregateStates: Draft -> Trialing -> Active -> PastDue/Suspended -> Cancelled/Expired
InvoiceEntityRevision chain support (void-and-recreate)
CreditNoteEntityLine-item-level adjustments
PaymentEntityIdempotency key required
PriceImmutable EntityFlat / Tiered (Graduated, Volume) / Usage pricing models
ProductEntityDefines "what to sell"; separated from Price ("how to charge")
BalanceEntryEntityFIFO consumption, expiration support

4.2 Contract Types

TypeDescription
one_timeOne-time purchase
subscriptionRecurring billing
usage_basedMetered billing

4.3 Contract State Machine

A pattern where service access is withheld until the first payment succeeds. This uses existing state transitions only -- no new statuses needed.

5.1 Flow

StepContract StatusInvoice StatusDescription
1Draft(none)Create contract
2DraftDraftGenerate invoice (Draft status allows this)
3ActiveFinalizedUser confirms; finalize both contract and invoice
4SuspendedFinalizedImmediately suspend (awaiting payment)
5ActivePaidPayment confirmed -> Resume -> Service starts

5.2 Design Points

  • Unified Suspended state: Used for both "awaiting first payment" and "payment failure suspension"
  • No new transitions needed: Active -> Suspended -> Active (Resume) already exists
  • Payment gates service access: Activate then immediately Suspend; Resume only after payment confirmation

A simpler flow (Draft -> Activate -> Generate Invoice -> Process Payment) is also supported. Choose based on business requirements.

6. Plugin System

6.1 Extension Points

All hooks follow ISP (Interface Segregation Principle). Implement only the hooks you need.

CategoryHooksPurpose
Billing calculationDiscountHook, TaxHook, InvoiceLifecycleHookDiscounts, tax, pre/post calculation
Contract lifecycleOnContractCreate/Activate/Suspend/Resume/Cancel/CancelScheduled/CancelUnscheduled/Renew/TrialEndHookReact to individual contract events
PaymentBeforeChargeHook, AfterChargeHook, OnPaymentFailedHook, OnRefundHook, OnCompensationExecutedHookPre/post charge, failure, refund, saga compensation (charge reversal)
Credit notesOnCreditNoteIssuedHook, OnInvoiceRevisedHookCN issuance, invoice revision
MetricsOnContractChangeHook, OnInvoiceIssuedHook, OnPaymentProcessedHookKPI collection
Invoice generationInvoiceGenerationHookPDF generation, delivery

6.2 Hook Firing Responsibility

Not every hook is fired by the core. Of the 23 hook interfaces, 15 are invoked automatically by core services/batch processors; the rest are fired by the integrator or by an adapter (see docs/internals/plugin-system.md section 5.3 for the per-hook detail):

Fired byHooksWhere
Core (15)DiscountHook, TaxHook, InvoiceLifecycleHook, OnInvoiceIssuedHookBillingService (billing pipeline; FinalizeInvoice fires OnInvoiceIssued)
BeforeChargeHook, AfterChargeHook, OnPaymentProcessedHook, OnPaymentFailedHook, OnRefundHook, OnCompensationExecutedHookPaymentService (OnCompensationExecutedHook fires non-fatally after saga compensation of a gateway charge, on both the reversed and the failed/manual-reconciliation outcome; issue #257)
OnCreditNoteIssuedHook, OnInvoiceRevisedHookCreditNoteService
OnContractRenewHook, OnContractTrialEndHook, OnContractChangeHookbatch.ContractRenewalProcessor, batch.TrialExpirationProcessor
Integrator (7)OnContractCreate/Activate/Suspend/Resume/Cancel/CancelScheduled/CancelUnscheduled HookContract lifecycle operations (including ScheduleCancellation/UnscheduleCancellation) call aggregate methods directly (no core application service), so the integrator fires the matching hooks. Reference: examples/hosting-integration-demo/main.go
Adapter (1)InvoiceGenerationHookInvoice rendering/delivery is out of core scope; the consumer's invoice-generation adapter fires BuildDocument/AfterRender/AfterDelivery

Transactional outbox extension (issue #248). Separately from the 23 hooks (#248 itself added no hooks), the core also calls two integrator portsPaymentOutboxWriter and InvoiceOutboxWriter (in application/port) — inside the bookkeeping transaction, immediately after the payment/invoice row is saved and before commit. Wired via WithPaymentOutboxWriter / WithInvoiceOutboxWriter (nil = skipped), they let an integrator write a durable notification row in the SAME transaction as the write, closing the event-loss window that the post-commit hooks cannot. A writer error vetoes (rolls back) the transaction; on the payment path that reverses the gateway charge via saga compensation. See docs/internals/plugin-system.md §11.

6.3 Billing Pipeline

The core structurally guarantees the accounting-correct calculation order. Plugin Priority values only control execution order within the same hook type.

Calculation order detail (plugin-observable; matches executeBillingPipeline in application/service/billing_service.go):

Every amount the pipeline persists is quantized to the currency's minor unit with BillingConfig.TaxRoundingMode (default RoundDown) so the invoice reconciles exactly against integer-only gateways (issue #189): the subtotal is rounded up front, the summed discount is rounded before the cap guard, and the summed tax is rounded once per invoice. All hooks are fired via plugin.SafeInvoke/SafeInvokeMoney, which converts a plugin panic into a *PluginPanicError (fatality policy in docs/internals/plugin-system.md §5.4).

  1. InvoiceLifecycleHook.BeforeCalculation() -- Pre-calculation processing. ctx.Subtotal() returns ZERO here — the core creates the CalculationContext with a zero subtotal and only calls SetSubtotal after this hook. (ctx.ProductID() and ctx.BillingPeriod() are already available; ProductID is resolved from the contract's Price and the billing period is set before any calculation hook runs.)
  2. Base price is computed (core, branched by contract type), rounded to the minor unit, and populated onto the context; from here ctx.Subtotal() returns the base price.
    • subscription: fixed price
    • usage_based: UsageRecord aggregation -> included allowance deduction -> PricingModel
    • one_time: fixed price (once)
    • hybrid: base price + usage charge
  3. DiscountHook.CalculateDiscount() -- Discount calculation (ctx.Subtotal() = base price)
    • Boundary validation: a negative discount aborts with ErrCodeBusinessRule, a currency mismatch with ErrCodeCurrencyMismatch (both name the plugin; issue #188)
    • Summed discount rounded to the minor unit (issue #189)
    • Discount cap guard: total discount is capped at subtotal
  4. Subtotal after discount (core: subtotal - totalDiscount) -> ctx.SetSubtotalAfterDiscount()
  5. TaxHook.CalculateTax() -- Tax on ctx.SubtotalAfterDiscount()
    • Boundary validation: a negative tax aborts with ErrCodeBusinessRule (issue #188)
    • Summed tax rounded to the minor unit, once per invoice (issue #189)
  6. Total computation (core: afterDiscount + totalTax)
  7. Credit ledger application (core, inside the transaction) -- FIFO deduction from balance
  8. Create draft invoice (core, inside the transaction) -> finalize after GracePeriod via FinalizeInvoice
  9. InvoiceLifecycleHook.AfterCalculation() -- Post-calculation processing, fired before Save (the invoice the plugin receives is not yet persisted; use OnInvoiceIssuedHook, fired after the save in FinalizeInvoice, for persistence-dependent work)
  10. Save (core, inside the transaction)
DocumentContents
Domain ModelEntities, value objects, detailed design
Event SourcingEvent Store, temporal reconstruction
Plugin SystemPlugin implementation guide
Payment GatewayPayment interface design
Integration GuideHow to integrate into your service