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-freeeventstore/interfaces (the event-sourceddomain/contractaggregate embedseventstore.BaseAggregateand implementseventstore.DomainEvent/EventRegistry;eventstore/itself depends only ondomain/shared, so no cycle). No otherdomain/*package importseventstore/.application/depends ondomain/plus the infrastructure-free base packageseventstore/andplugin/(see the dependency graph in 2.2), never oninfrastructure/- Dependencies always point inward (Dependency Inversion)
- Interfaces are defined in
domain/orapplication/port/; implementations live ininfrastructure/
2.2 Dependency Graph
2.3 CQRS (Command Query Responsibility Segregation)
| Item | Decision |
|---|---|
| CQRS | Simplified CQRS |
| Projection updates | Consumer's choice |
| Description | Uses projection tables in the same DB. Sync/async selectable via options |
2.4 Other Design Decisions
| Item | Decision | Notes |
|---|---|---|
| Multi-tenancy | Delegated to consumer | Not handled by this library |
| Timezone | UTC only | All event timestamps are UTC |
| Billing cycles | Provided by library | Daily / 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
| Entity | Kind | Notes |
|---|---|---|
| Contract | Event Sourced Aggregate | States: Draft -> Trialing -> Active -> PastDue/Suspended -> Cancelled/Expired |
| Invoice | Entity | Revision chain support (void-and-recreate) |
| CreditNote | Entity | Line-item-level adjustments |
| Payment | Entity | Idempotency key required |
| Price | Immutable Entity | Flat / Tiered (Graduated, Volume) / Usage pricing models |
| Product | Entity | Defines "what to sell"; separated from Price ("how to charge") |
| BalanceEntry | Entity | FIFO consumption, expiration support |
4.2 Contract Types
| Type | Description |
|---|---|
one_time | One-time purchase |
subscription | Recurring billing |
usage_based | Metered billing |
4.3 Contract State Machine
5. Payment-Gated Provisioning (Recommended Flow)
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
| Step | Contract Status | Invoice Status | Description |
|---|---|---|---|
| 1 | Draft | (none) | Create contract |
| 2 | Draft | Draft | Generate invoice (Draft status allows this) |
| 3 | Active | Finalized | User confirms; finalize both contract and invoice |
| 4 | Suspended | Finalized | Immediately suspend (awaiting payment) |
| 5 | Active | Paid | Payment 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.
| Category | Hooks | Purpose |
|---|---|---|
| Billing calculation | DiscountHook, TaxHook, InvoiceLifecycleHook | Discounts, tax, pre/post calculation |
| Contract lifecycle | OnContractCreate/Activate/Suspend/Resume/Cancel/CancelScheduled/CancelUnscheduled/Renew/TrialEndHook | React to individual contract events |
| Payment | BeforeChargeHook, AfterChargeHook, OnPaymentFailedHook, OnRefundHook, OnCompensationExecutedHook | Pre/post charge, failure, refund, saga compensation (charge reversal) |
| Credit notes | OnCreditNoteIssuedHook, OnInvoiceRevisedHook | CN issuance, invoice revision |
| Metrics | OnContractChangeHook, OnInvoiceIssuedHook, OnPaymentProcessedHook | KPI collection |
| Invoice generation | InvoiceGenerationHook | PDF 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 by | Hooks | Where |
|---|---|---|
| Core (15) | DiscountHook, TaxHook, InvoiceLifecycleHook, OnInvoiceIssuedHook | BillingService (billing pipeline; FinalizeInvoice fires OnInvoiceIssued) |
BeforeChargeHook, AfterChargeHook, OnPaymentProcessedHook, OnPaymentFailedHook, OnRefundHook, OnCompensationExecutedHook | PaymentService (OnCompensationExecutedHook fires non-fatally after saga compensation of a gateway charge, on both the reversed and the failed/manual-reconciliation outcome; issue #257) | |
OnCreditNoteIssuedHook, OnInvoiceRevisedHook | CreditNoteService | |
OnContractRenewHook, OnContractTrialEndHook, OnContractChangeHook | batch.ContractRenewalProcessor, batch.TrialExpirationProcessor | |
| Integrator (7) | OnContractCreate/Activate/Suspend/Resume/Cancel/CancelScheduled/CancelUnscheduled Hook | Contract 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) | InvoiceGenerationHook | Invoice 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 ports —
PaymentOutboxWriter 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(defaultRoundDown) 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 viaplugin.SafeInvoke/SafeInvokeMoney, which converts a plugin panic into a*PluginPanicError(fatality policy indocs/internals/plugin-system.md§5.4).
InvoiceLifecycleHook.BeforeCalculation()-- Pre-calculation processing.ctx.Subtotal()returns ZERO here — the core creates theCalculationContextwith a zero subtotal and only callsSetSubtotalafter this hook. (ctx.ProductID()andctx.BillingPeriod()are already available; ProductID is resolved from the contract's Price and the billing period is set before any calculation hook runs.)- 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
DiscountHook.CalculateDiscount()-- Discount calculation (ctx.Subtotal()= base price)- Boundary validation: a negative discount aborts with
ErrCodeBusinessRule, a currency mismatch withErrCodeCurrencyMismatch(both name the plugin; issue #188) - Summed discount rounded to the minor unit (issue #189)
- Discount cap guard: total discount is capped at subtotal
- Boundary validation: a negative discount aborts with
- Subtotal after discount (core: subtotal - totalDiscount) ->
ctx.SetSubtotalAfterDiscount() TaxHook.CalculateTax()-- Tax onctx.SubtotalAfterDiscount()- Boundary validation: a negative tax aborts with
ErrCodeBusinessRule(issue #188) - Summed tax rounded to the minor unit, once per invoice (issue #189)
- Boundary validation: a negative tax aborts with
- Total computation (core: afterDiscount + totalTax)
- Credit ledger application (core, inside the transaction) -- FIFO deduction from balance
- Create draft invoice (core, inside the transaction) -> finalize after GracePeriod via
FinalizeInvoice InvoiceLifecycleHook.AfterCalculation()-- Post-calculation processing, fired before Save (the invoice the plugin receives is not yet persisted; useOnInvoiceIssuedHook, fired after the save inFinalizeInvoice, for persistence-dependent work)- Save (core, inside the transaction)
7. Related Documents
| Document | Contents |
|---|---|
| Domain Model | Entities, value objects, detailed design |
| Event Sourcing | Event Store, temporal reconstruction |
| Plugin System | Plugin implementation guide |
| Payment Gateway | Payment interface design |
| Integration Guide | How to integrate into your service |