Skip to main content

Domain Types Reference

Shared Value Objects

Money

Arbitrary-precision monetary value with currency.

import "github.com/contract-to-cash/core/domain/shared"

// Construction
price := shared.NewMoney(new(big.Rat).SetInt64(3000), shared.CurrencyJPY)
zero := shared.Zero(shared.CurrencyJPY)

// Arithmetic (returns error on currency mismatch)
sum, err := a.Add(b)
diff, err := a.Subtract(b)
product := a.Multiply(new(big.Rat).SetFrac64(10, 100))
negated := a.Negate()

// Comparison
a.IsZero()
a.IsNegative()
a.GreaterThan(b)
min, err := a.Min(b)

// Access
a.Amount() // *big.Rat
a.Currency() // Currency

Currencies: CurrencyJPY, CurrencyUSD, CurrencyEUR

DateRange

Half-open interval [start, end).

period, err := shared.NewDateRange(start, end) // error if start >= end
period.Start()
period.End()
period.Contains(t time.Time) bool
period.Duration() time.Duration

ID Types

ULID-based identifiers:

TypeConstructor
ContractIDNewContractID()
AccountIDNewAccountID()
InvoiceIDNewInvoiceID()
PaymentIDNewPaymentID()
ProductIDNewProductID()
PriceIDNewPriceID()
UsageRecordIDNewUsageRecordID()
BalanceEntryIDNewBalanceEntryID()
CreditNoteIDNewCreditNoteID()

Clock

type Clock interface {
Now() time.Time
}

// Implementations
shared.SystemClock{} // Real time
shared.FixedClock{FixedTime: time.Date(2026, 4, 1, ...)} // Fixed time (testing)

DomainError

type ErrorCode string

const (
ErrCodeInvalidStateTransition ErrorCode = "invalid_state_transition"
ErrCodeBusinessRule ErrorCode = "business_rule_violation"
ErrCodeValidation ErrorCode = "validation_error"
ErrCodeNotFound ErrorCode = "not_found"
ErrCodeConflict ErrorCode = "conflict"
ErrCodeDuplicateRequest ErrorCode = "duplicate_request"
ErrCodeVersionConflict ErrorCode = "version_conflict"
ErrCodeCurrencyMismatch ErrorCode = "currency_mismatch"
ErrCodeInvalidDateRange ErrorCode = "invalid_date_range"
ErrCodeUnknownEvent ErrorCode = "unknown_event"
)

shared.NewDomainError(code ErrorCode, message string) *DomainError

Contract

ContractAggregate

import "github.com/contract-to-cash/core/domain/contract"

agg := contract.NewContractAggregate(contractID, clock)

Status constants: ContractStatusDraft, ContractStatusTrialing, ContractStatusActive, ContractStatusPastDue, ContractStatusSuspended, ContractStatusCancelled, ContractStatusExpired

Type constants: ContractTypeOneTime, ContractTypeSubscription, ContractTypeUsageBased

Billing interval: use pricing.BillingInterval ({unit, count}). Convenience constructors: pricing.Daily(), pricing.Weekly(), pricing.Monthly(), pricing.Yearly(), pricing.Quarterly(), pricing.SemiAnnual(). The pricing.BillingCycle string constants (BillingCycleDaily/Weekly/Monthly/Yearly) remain in the pricing package for Price construction, display, and adapter code, but the contract domain no longer surfaces them (removed in #111).

One-time contracts may omit the interval (#218): CreateContractCommand.Interval may be left zero when ContractType == ContractTypeOneTime. The contract then activates with an unset (zero-value) CurrentPeriod(), is excluded from renewal/expiry queries and the renewal batch, and RenewWithInterval returns a business-rule error. All other contract types still require a non-zero interval. On the Price side, use pricing.NewOneTimePrice. Existing one_time contracts created WITH an interval remain fully valid.

Commands

MethodFrom StatusTo Status
Create(cmd, metadata)(new)draft
Activate(metadata)draft, trialingactive
StartTrial(config, metadata)drafttrialing
EndTrial(converted, metadata)trialingactive/cancelled
Suspend(config, metadata)active, past_duesuspended
Resume(metadata)suspendedactive
Cancel(reason, metadata)draft, trialing, active, suspended, past_duecancelled
RenewWithInterval(newInterval BillingInterval, metadata)activeactive (new period)
ChangePrice(priceID, policy, proration, metadata)activeactive
UnscheduleChange(reason, metadata)active (has pending)active

CreateContractCommand

type CreateContractCommand struct {
AccountID shared.AccountID
PriceID shared.PriceID
ContractType ContractType
Interval BillingInterval // optional (zero) for one_time only (#218); required otherwise
Price shared.Money
BasePrice shared.Money
AutoRenew bool
IdempotencyKey string
}

SuspensionConfiguration

type SuspensionConfiguration struct {
SuspendedAt time.Time
ResumeDate *time.Time
BillingBehavior SuspensionBillingBehavior // Skip, Defer, Continue
ExtendContract bool
Reason string
}

ChangePolicy

const (
ChangePolicyImmediate ChangePolicy = "immediate"
ChangePolicyEndOfTerm ChangePolicy = "end_of_term"
)

Getters

agg.ContractID() shared.ContractID
agg.AccountID() shared.AccountID
agg.Status() ContractStatus
agg.GetContractType() ContractType
agg.GetInterval() BillingInterval
agg.CurrentPeriod() shared.DateRange
agg.TrialConfig() *TrialConfiguration
agg.SuspensionConfig() *SuspensionConfiguration
agg.PaymentMethodID() *string
agg.PriceID() shared.PriceID
agg.Price() shared.Money
agg.BasePrice() shared.Money
agg.AutoRenew() bool
agg.CancelAtPeriodEnd() bool
agg.PendingPriceID() *shared.PriceID
agg.HasPendingChange() bool
agg.CreatedAt() time.Time
agg.UpdatedAt() time.Time

Event Sourcing

agg.LoadFromHistory(events []eventstore.Event) error
agg.LoadFromSnapshot(snapshot eventstore.Snapshot) error
agg.MarshalSnapshot() ([]byte, error)
agg.UncommittedEvents() []eventstore.Event
agg.ClearUncommittedEvents()
agg.Version() int

Repository

type Repository interface {
Save(ctx context.Context, aggregate *ContractAggregate) error
FindByID(ctx context.Context, id shared.ContractID) (*ContractAggregate, error)
FindByAccountID(ctx context.Context, accountID shared.AccountID) ([]*ContractAggregate, error)
FindExpiring(ctx context.Context, before time.Time) ([]*ContractAggregate, error)
FindTrialsEndingBefore(ctx context.Context, before time.Time) ([]*ContractAggregate, error)
FindByIDAsOf(ctx context.Context, id shared.ContractID, asOf time.Time) (*ContractAggregate, error)
FindDueForRenewal(ctx context.Context, asOf time.Time) ([]*ContractAggregate, error)
}

Invoice

import "github.com/contract-to-cash/core/domain/invoice"

Status constants: InvoiceStatusDraft, InvoiceStatusFinalized, InvoiceStatusIssued, InvoiceStatusPaid, InvoiceStatusPartialPaid, InvoiceStatusOverdue, InvoiceStatusVoided, InvoiceStatusRefunded

Construction

inv := invoice.NewInvoice(id, accountID, contractID, subtotal, discountAmount, taxAmount,
invoice.WithLineItems(items),
invoice.WithBillingPeriod(period),
invoice.WithDueDate(dueDate),
invoice.WithStatus(invoice.InvoiceStatusDraft),
invoice.WithAppliedBalance(creditAmount),
invoice.WithAmountDue(amountDue),
invoice.WithAllowPartialPayment(false),
invoice.WithInvoiceNumber("INV-2026-001"),
invoice.WithOriginalInvoiceID(originalID), // for reissued invoices
invoice.WithRevisionOf(parentID), // for revision chain linking
invoice.WithPaymentMethodID(&pmID), // invoice-level payment method override
)

Methods

inv.Finalize() error           // draft → finalized
inv.MarkIssued() error // finalized → issued (delivery flows; integrator fires)
inv.MarkOverdue(now time.Time) error // finalized|issued → overdue (now strictly after due date)
inv.Void() error // draft|finalized → voided
inv.VoidWithReason(reason string) error // any except voided|refunded → voided (with reason)
inv.ValidatePayment(amount shared.Money) error // finalized|issued|partial_paid|overdue → (validates payment amount)
inv.RecordPayment(amount shared.Money, paidAt time.Time) error // records payment, updates balance and status
inv.SetRevisionOf(id shared.InvoiceID) // sets direct parent in revision chain
inv.SetOriginalInvoiceID(id shared.InvoiceID) // sets root of revision chain

inv.ID() shared.InvoiceID
inv.InvoiceNumber() string
inv.AccountID() shared.AccountID
inv.ContractID() shared.ContractID
inv.LineItems() []LineItem
inv.Subtotal() shared.Money
inv.DiscountAmount() shared.Money
inv.TaxAmount() shared.Money
inv.Total() shared.Money
inv.AppliedBalance() shared.Money
inv.AmountDue() shared.Money
inv.PaidAmount() shared.Money
inv.Balance() shared.Money
inv.Status() InvoiceStatus
inv.BillingPeriod() shared.DateRange
inv.IssueDate() time.Time
inv.DueDate() time.Time
inv.PaidAt() *time.Time
inv.AllowPartialPay() bool
inv.PaymentMethodID() *string
inv.OriginalInvoiceID() *shared.InvoiceID // root of the revision chain
inv.RevisionOf() *shared.InvoiceID // direct parent in the revision chain
inv.VoidReason() string
inv.Metadata() map[string]string

Revision Support

Invoices support a two-level linking model for void-and-recreate workflows:

  • originalInvoiceID — always points to the first invoice in the revision chain (the root), regardless of how many revisions have occurred.
  • revisionOf — points to the direct parent (the immediate predecessor in the chain).

Example: Inv-1 -> Inv-2 -> Inv-3

  • Inv-2: originalInvoiceID=Inv-1, revisionOf=Inv-1
  • Inv-3: originalInvoiceID=Inv-1, revisionOf=Inv-2

CreditNote

import "github.com/contract-to-cash/core/domain/invoice"

A credit note adjusts a previously issued invoice. It follows a lifecycle from draft through issuance to resolution (applied, refunded, or voided).

Status constants: CreditNoteStatusDraft, CreditNoteStatusIssued, CreditNoteStatusApplied, CreditNoteStatusRefunded, CreditNoteStatusVoided

Reason constants: CreditNoteReasonDuplicate, CreditNoteReasonOrderChange, CreditNoteReasonCancellation, CreditNoteReasonProductUnsatisfactory, CreditNoteReasonOther

CreditNoteItem

item := invoice.NewCreditNoteItem(invoiceLineItemID, description, amount, taxRate, taxAmount)

item.InvoiceLineItemID() string
item.Description() string
item.Amount() shared.Money
item.TaxRate() *big.Rat
item.TaxAmount() shared.Money

Construction

cn, err := invoice.NewCreditNote(id, invoiceID, accountID, contractID, reason, items,
invoice.WithCreditNoteMemo("Adjustment for duplicate charge"),
invoice.WithCreditNoteNumber("CN-2026-001"),
)

Returns an error if items is empty.

Methods

cn.Issue(issuedAt time.Time) error              // draft → issued
cn.Apply(creditAmount shared.Money) error // issued → applied
cn.Refund(refundAmount shared.Money) error // issued → refunded
cn.Void() error // draft|issued → voided

cn.ID() shared.CreditNoteID
cn.Number() string
cn.InvoiceID() shared.InvoiceID
cn.AccountID() shared.AccountID
cn.ContractID() shared.ContractID
cn.Status() CreditNoteStatus
cn.Reason() CreditNoteReason
cn.Memo() string
cn.Items() []CreditNoteItem
cn.Subtotal() shared.Money
cn.TaxAmount() shared.Money
cn.Total() shared.Money // subtotal + taxAmount
cn.CreditAmount() shared.Money // amount applied as account credit
cn.RefundAmount() shared.Money // amount refunded via payment gateway
cn.IssuedAt() *time.Time
cn.CreatedAt() time.Time

CreditNoteRepository

type CreditNoteRepository interface {
Save(ctx context.Context, cn *CreditNote) error
FindByID(ctx context.Context, id shared.CreditNoteID) (*CreditNote, error)
FindByInvoiceID(ctx context.Context, invoiceID shared.InvoiceID) ([]*CreditNote, error)
FindByAccountID(ctx context.Context, accountID shared.AccountID) ([]*CreditNote, error)
FindByContractID(ctx context.Context, contractID shared.ContractID) ([]*CreditNote, error)
FindByStatus(ctx context.Context, status CreditNoteStatus) ([]*CreditNote, error)
}

Payment

import "github.com/contract-to-cash/core/domain/payment"

Status constants: PaymentStatusPending, PaymentStatusCompleted, PaymentStatusFailed, PaymentStatusPartiallyRefunded, PaymentStatusRefunded, PaymentStatusChargedBack

Method constants: PaymentMethodCreditCard, PaymentMethodBankTransfer, PaymentMethodDirectDebit, PaymentMethodConvenience, PaymentMethodCarrier

Methods

payment.Complete() error                         // pending → completed
payment.Fail(reason string) error // pending → failed
payment.MarkRefunded() error // completed|partially_refunded → refunded
payment.MarkPartiallyRefunded() error

payment.ID() shared.PaymentID
payment.InvoiceID() shared.InvoiceID
payment.Amount() shared.Money
payment.Status() PaymentStatus
payment.GatewayTransactionID() string

Product

import "github.com/contract-to-cash/core/domain/product"

Status constants: ProductStatusActive, ProductStatusArchived


Price

import "github.com/contract-to-cash/core/domain/pricing"

Prices are immutable after creation. To change pricing, create a new Price.

Status constants: PriceStatusActive, PriceStatusArchived

price := pricing.NewPrice(productID, amount, currency, billingCycle, pricingModel, createdAt)

// Flexible intervals (quarterly, semi-annual, ...). Rejects a zero interval.
price, err := pricing.NewPriceWithInterval(productID, amount, currency, interval, pricingModel, createdAt)

// One-time price (#218): zero Interval(), empty BillingCycle(), nil PricingModel
// (the flat amount is charged once). For one_time contracts.
price, err := pricing.NewOneTimePrice(productID, amount, currency, createdAt)

price.ID() shared.PriceID
price.ProductID() shared.ProductID
price.Amount() shared.Money
price.Currency() shared.Currency
price.BillingCycle() BillingCycle
price.PricingModel() PricingModel
price.Status() PriceStatus

Pricing Models

type PricingModel interface {
CalculatePrice(usage int64) shared.Money
}

// Flat pricing (nil PricingModel on Price)
// Tiered pricing — construct via NewTieredPrice, which validates the tiers
// (sorted ascending by UpTo, UpTo=0 only on the last tier, single currency) and
// returns (TieredPrice, error). Graduated: each tier priced independently.
pricing.NewTieredPrice([]pricing.PriceTier{...}, pricing.TieredPricingGraduated)
// Volume pricing (all units at the tier they fall into)
pricing.NewTieredPrice([]pricing.PriceTier{...}, pricing.TieredPricingVolume)

Credit

import "github.com/contract-to-cash/core/domain/balance"

Reason constants: BalanceReasonProration, BalanceReasonCancellation, BalanceReasonManualAdjustment, BalanceReasonRefundConversion, BalanceReasonGoodwill

entry.IsExpired(now time.Time) bool
entry.IsFullyConsumed() bool
entry.Consume(amount shared.Money) (shared.Money, error) // Returns consumed amount (FIFO)

Usage

import "github.com/contract-to-cash/core/domain/usage"
record, err := usage.NewUsageRecord(id, contractID, metricName, quantity, timestamp, idempotencyKey)

type UsageSummary struct {
ContractID shared.ContractID
MetricName string
Period shared.DateRange
TotalUsage int64
}