breakage

package
v1.0.0-beta.231 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

Credit Expiration Breakage

This package keeps future credit-expiration ledger entries aligned with actual customer credit usage.

The ledger is the accounting source of truth. Breakage records are an allocation/index layer: they let later collection and correction flows find open planned breakage, reopen released breakage, and project customer-visible expired credit.

Notation

  • FBO(r) is the customer credit account route.
  • BR(b) is the breakage account route used for breakage accounting.
  • r.priority is the credit draw-down priority. Lower values are consumed first.
  • @T is ledger booked_at.
  • E is an expiration timestamp.
  • plan means pre-booked future breakage for issued expiring credit.
  • release means a future entry that reduces planned breakage because credit was used or removed before expiration.
  • reopen means a future entry that increases planned breakage again because previously used credit became unused.
  • breakage impact means the customer-visible expired amount after plans, releases, and reopens are netted.

All breakage record amounts are positive. The sign lives in the ledger entries.

Core Invariant

Breakage avoids grant-level lineage only if:

FBO consumption order == breakage release order

The shared ordering is:

credit_priority asc
expires_at asc
stable cursor asc

credit_priority comes from the FBO route. expires_at is the planned breakage transaction's booked_at; it is not copied into the FBO route.

The correctness argument:

  1. Expiring issued credit creates a future plan.
  2. The collector consumes available FBO sources in the same order it asks breakage for open plans.
  3. Every consumed planned slice creates a release against that same plan.
  4. Therefore the open planned amount at an expiry is exactly the remaining unused expiring credit for that expiry.

If collection order and release order diverge, a release can reduce the wrong expiry. At that point the system would need explicit grant lineage to recover correctness.

Routing And Metadata

Use route dimensions for fields that define collection eligibility, ordering, or routing validation.

Use annotations or breakage records for metadata and links.

That means:

  • credit_priority is a route dimension because it affects normal FBO collection.
  • expires_at is represented by planned breakage booked_at.
  • breakage kind and source links are annotations/record data.
  • breakage account routes should carry only dimensions relevant to breakage accounting/revenue recognition.

Breakage-generated FBO entries must not be treated as normal credit issuance or usage. They are marked as breakage activity:

ledger.collection.type = breakage
ledger.breakage.kind = plan|release|reopen

Type: Plan

When expiring credit is issued:

@T
FBO(route{priority=P}): +x
OFFSET:                 -x

If the credit has no expiration, breakage does nothing.

If the credit expires at E, breakage pre-books a plan:

@E [breakage.plan]
FBO(route{priority=P}): -x
BR(b):                  +x

Record shape:

type = plan
amount = x
expires_at = E
fbo_route = route
breakage_route = b
source_transaction = <credit issuance tx>
breakage_transaction = <planned breakage tx>

Example:

10 credit arrives at T1, expires at T10

@T1
FBO +10

@T10 [plan]
FBO -10
BR  +10

If nothing else happens, the balance is 10 before T10 and 0 at T10.

Type: Release

When usage consumes real credit:

@T
FBO(route):     -x
ACCRUED/OFFSET: +x

Breakage walks open plans in the same order as FBO collection:

credit_priority asc, expires_at asc, stable cursor asc

For each selected plan slice y, it books a release at that plan's expiry:

@plan.expires_at [breakage.release]
FBO(plan.fbo_route):     +y
BR(plan.breakage_route): -y

Record shape:

type = release
amount = y
plan = <breakage.plan>
source_transaction = <FBO consumption tx>
source_entry = <FBO source entry, when available>
breakage_transaction = <future release tx>

Example:

10 credit at T1, expires T10
15 credit at T5, expires T15

Initial plans:

@T10 [plan]
FBO -10
BR  +10

@T15 [plan]
FBO -15
BR  +15

Usage of 5 at T2 consumes the first expiring plan:

@T2
FBO     -5
ACCRUED +5

@T10 [release]
FBO +5
BR  -5

Usage of 10 at T3 continues from the remaining T10 plan, then moves to T15:

@T3
FBO     -10
ACCRUED +10

@T10 [release]
FBO +5
BR  -5

@T15 [release]
FBO +5
BR  -5

The releases follow expiry order without needing to know which "grant" the usage came from.

Type: Reopen

Usage correction restores previously consumed credit:

@T
FBO(route):     +x
ACCRUED/OFFSET: -x

Breakage reopens releases produced by the original consumption in reverse unwind order:

@plan.expires_at [breakage.reopen]
FBO(plan.fbo_route):     -y
BR(plan.breakage_route): +y

This increases breakage because the credit is unused again.

Example:

original collection:
  source #0: 5 from expiry T10
  source #1: 5 from expiry T15

correction restores 7

Breakage reopens:

@T15 [reopen]
FBO -5
BR  +5

@T10 [reopen]
FBO -2
BR  +2

The remaining usage is equivalent to the original collection prefix:

T10 used: 3
T15 used: 0

Usage Corrections

Usage corrections are corrections of prior FBO -> accrued movement.

Usage correction is an unwind up to the original collected amount. It restores FBO and reopens any breakage releases attached to the restored source entries.

If a future flow ever produces additional real-credit consumption as a correction, it should apply the normal release rule:

@T
FBO(route):     -x
ACCRUED/OFFSET: +x

@plan.expires_at [breakage.release]
FBO(plan.fbo_route):     +y
BR(plan.breakage_route): -y

The currently important path is restoration:

@T
FBO(route):     +x
ACCRUED/OFFSET: -x

@plan.expires_at [breakage.reopen]
FBO(plan.fbo_route):     -y
BR(plan.breakage_route): +y

Credit Purchase Corrections

Credit purchase corrections would correct prior receivable -> FBO issuance.

Removing issued expiring credit would reduce planned breakage by booking a release at the matching expiry:

@T
FBO(route):       -x
RECEIVABLE/OTHER: +x

@plan.expires_at [breakage.release]
FBO(plan.fbo_route):     +y
BR(plan.breakage_route): -y

Restoring issued expiring credit would behave like expiring credit issuance and create a plan.

This source kind is reserved, but full credit-purchase correction semantics are not implemented yet. The charge domain still needs to define correction/delete policy, removable amount checks, and behavior when the purchased credit has already been consumed.

Advance

Advance-backed usage has no expiring real credit yet, so it creates no breakage.

@T
FBO(advance/no-expiry): -x
ACCRUED/OFFSET:         +x

Example:

usage needs 15, but only 10 real credit exists

@T
FBO(real)    -10
ACCRUED      +10

@T
FBO(advance) -5
ACCRUED      +5

Only the real-credit portion can release existing planned breakage. The advance portion has no breakage until real expiring credit later covers it.

Advance Backfill

Advance backfill means later real credit is assigned to already-used advance-backed value.

For breakage, this is both:

  1. issuance of expiring real credit;
  2. immediate consumption of that same credit, because the advance usage already happened.

If 5 advance is covered by new credit at T5 that expires at T20, the FBO attribution makes the covered value real credit and breakage books both sides:

@T20 [plan]
FBO(route{priority=P}): -5
BR(b):                  +5

@T20 [release]
FBO(route{priority=P}): +5
BR(b):                  -5

Net breakage for the covered advance is zero.

If the original advance-backed usage is corrected later, the advance-backfill release is reopened:

@T20 [reopen]
FBO(route{priority=P}): -5
BR(b):                  +5

Now the covered credit is unused again and can expire.

Expired Breakage Impact

Customer-visible expired credit is not one breakage transaction. It is the net impact of visible breakage records grouped by expiry and currency:

impact = -(plans - releases + reopens)

Zero-impact groups are hidden. Negative internal breakage totals are invalid because releases/reopens exceeded their backing plans.

Example:

@T10 [plan]    10
@T10 [release] 5
@T10 [reopen]  2

plans - releases + reopens = 10 - 5 + 2 = 7
customer-visible impact = -7

The visible transaction cursor is the newest ledger transaction cursor among the records that contributed to the net impact. That keeps pagination stable while presenting one customer-facing expired row per expiry/currency bucket.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ValidateExpiredRouteFilter

func ValidateExpiredRouteFilter(route ledger.RouteFilter) error

Types

type Adapter

type Adapter interface {
	// CreateRecords persists planned/released/reopened breakage rows.
	CreateRecords(ctx context.Context, input CreateRecordsInput) error

	// ListCandidateRecords returns plan and adjustment rows needed to compute
	// open plans. Implementations should lock returned rows when the caller is in
	// a transaction so concurrent collectors cannot release the same open amount.
	ListCandidateRecords(ctx context.Context, input ListPlansInput) ([]Record, error)

	// ListReleaseRecords returns release and reopen rows for the given source
	// entries. Implementations should lock returned rows when the caller is in a
	// transaction so concurrent corrections cannot reopen the same release amount.
	ListReleaseRecords(ctx context.Context, input ListReleasesInput) ([]Record, error)

	// ListExpiredRecords returns breakage rows whose expiry is visible as of the
	// query time. The caller owns netting plan/release/reopen rows into a
	// customer-facing expired transaction.
	ListExpiredRecords(ctx context.Context, input ListExpiredRecordsInput) ([]Record, error)
}

type BreakageImpact

type BreakageImpact struct {
	ID          models.NamespacedID
	CreatedAt   time.Time
	BookedAt    time.Time
	CustomerID  customer.CustomerID
	Currency    currencyx.Code
	Amount      alpacadecimal.Decimal
	SourceKind  SourceKind
	Annotations models.Annotations
}

BreakageImpact is the customer-facing effect of breakage that has reached its expiration timestamp. Amount is negative because expiry reduces FBO balance.

func (BreakageImpact) Cursor

type Config

type Config struct {
	// Adapter stores durable record rows. The ledger entries themselves are
	// still committed through the caller's ledger transaction group.
	Adapter      Adapter
	Dependencies transactions.ResolverDependencies
}

func (Config) Validate

func (c Config) Validate() error

type CreateRecordsInput

type CreateRecordsInput struct {
	Records []Record
}

CreateRecordsInput persists record rows for already committed breakage ledger transactions.

func (CreateRecordsInput) Validate

func (i CreateRecordsInput) Validate() error

type ListExpiredBreakageImpactsInput

type ListExpiredBreakageImpactsInput struct {
	CustomerID customer.CustomerID
	Currency   *currencyx.Code
	AsOf       time.Time
	After      *ledger.TransactionCursor
	Before     *ledger.TransactionCursor
	Limit      int
	Route      ledger.RouteFilter
}

ListExpiredBreakageImpactsInput selects customer-visible breakage impact rows. Impacts are derived by netting expired plan/release/reopen records by expiry.

func (ListExpiredBreakageImpactsInput) Validate

type ListExpiredBreakageImpactsResult

type ListExpiredBreakageImpactsResult struct {
	Items   []BreakageImpact
	HasMore bool
}

ListExpiredBreakageImpactsResult contains breakage impacts ordered by ledger cursor descending, matching customer transaction listing semantics.

type ListExpiredRecordsInput

type ListExpiredRecordsInput struct {
	CustomerID customer.CustomerID
	Currency   *currencyx.Code
	AsOf       time.Time
	Route      ledger.RouteFilter
}

ListExpiredRecordsInput selects breakage rows that have reached their expiration timestamp and can be presented as customer-visible expired credit.

type ListPlansInput

type ListPlansInput struct {
	CustomerID customer.CustomerID
	Currency   currencyx.Code
	AsOf       time.Time
}

ListPlansInput selects expiring credit that can still produce breakage as of the collector's AsOf time. Plans expiring at or before AsOf are not candidates because those credits are already expired from the collector's perspective.

type ListReleasesInput

type ListReleasesInput struct {
	CustomerID               customer.CustomerID
	SourceEntryID            []string
	SourceTransactionGroupID []string
	ReleaseSourceKind        []SourceKind
}

ListReleasesInput selects usage release rows that may need to be reopened by a correction of the original FBO collection source entries or by unwinding an advance backfill.

type NoopService

type NoopService struct{}

NoopService disables breakage while keeping constructors explicit. It is useful for legacy tests and deployments that wire credit purchases without expiration support.

func (NoopService) ListExpiredRecords

func (NoopService) ListExpiredRecords(context.Context, ListExpiredRecordsInput) ([]Record, error)

func (NoopService) ListPlans

func (NoopService) ListReleases

func (NoopService) PersistCommittedRecords

func (NoopService) PersistCommittedRecords(context.Context, []PendingRecord, ledger.TransactionGroup) error

type PendingRecord

type PendingRecord struct {
	Record

	SourceEntryIdentityKey string
}

PendingRecord is a record row that has been planned before the ledger commit. PersistCommittedRecords fills in committed ledger ids after CommitGroup succeeds. SourceEntryIdentityKey is transient; it lets usage releases attach to the committed FBO source entry without knowing the entry id before commit.

type Plan

type Plan struct {
	Record

	OpenAmount      alpacadecimal.Decimal
	FBOAddress      ledger.PostingAddress
	BreakageAddress ledger.PostingAddress
}

Plan is a planned breakage row with its current unreleased amount and the posting addresses needed to book a release. It is calculated from plan rows minus releases plus reopens.

type PlanIssuanceImmediateRelease

type PlanIssuanceImmediateRelease struct {
	Amount        alpacadecimal.Decimal
	SpendChargeID *string
}

type PlanIssuanceInput

type PlanIssuanceInput struct {
	CustomerID customer.CustomerID

	Amount            alpacadecimal.Decimal
	ImmediateReleases []PlanIssuanceImmediateRelease
	Currency          currencyx.Code
	TaxCode           *string
	TaxBehavior       *ledger.TaxBehavior
	CostBasis         *alpacadecimal.Decimal
	CreditPriority    *int
	Features          []string
	ExpiresAt         time.Time
	SourceChargeID    *string
}

PlanIssuanceInput describes newly issued expiring credit and, optionally, the slice that immediately covers already-consumed advance.

func (PlanIssuanceInput) Validate

func (i PlanIssuanceInput) Validate() error

type Record

type Record struct {
	ID        models.NamespacedID
	Kind      ledger.BreakageKind
	Amount    alpacadecimal.Decimal
	CreatedAt time.Time
	UpdatedAt time.Time
	DeletedAt *time.Time

	CustomerID customer.CustomerID
	Currency   currencyx.Code

	CreditPriority int
	ExpiresAt      time.Time

	SourceKind               SourceKind
	SourceChargeID           *string
	SourceTransactionGroupID *string
	SourceTransactionID      *string
	SourceEntryID            *string

	BreakageTransactionGroupID string
	BreakageTransactionID      string

	FBOSubAccountID      string
	BreakageSubAccountID string

	PlanID    *string
	ReleaseID *string

	Annotations models.Annotations
}

Record is the durable bookkeeping row for one breakage ledger transaction.

It intentionally repeats the fields the allocator needs to find and lock open plans without joining back through ledger transactions or parsing annotations. The ledger entry remains the accounting source of truth; the record is the allocation/index layer that ties plan, release, and reopen transactions together.

func (Record) Validate

func (c Record) Validate() error

func (Record) ValidateForReference

func (c Record) ValidateForReference() error

type Release

type Release struct {
	Record

	OpenAmount      alpacadecimal.Decimal
	FBOAddress      ledger.PostingAddress
	BreakageAddress ledger.PostingAddress
}

Release is a breakage release row with the amount that can still be reopened by corrections.

type ReleasePlanInput

type ReleasePlanInput struct {
	Plan                   Plan
	Amount                 alpacadecimal.Decimal
	SourceKind             SourceKind
	SourceEntryIdentityKey string
	SourceChargeID         *string
	SpendChargeID          *string
}

ReleasePlanInput describes how much of one open plan should be released and which business flow caused the release.

func (ReleasePlanInput) Validate

func (i ReleasePlanInput) Validate() error

type ReopenReleaseInput

type ReopenReleaseInput struct {
	Release        Release
	Amount         alpacadecimal.Decimal
	SourceKind     SourceKind
	SourceChargeID *string
	SpendChargeID  *string
}

ReopenReleaseInput describes how much of one released plan should be reopened and which correction flow caused it.

func (ReopenReleaseInput) Validate

func (i ReopenReleaseInput) Validate() error

type Service

type Service interface {
	// PlanIssuance creates the future expiration entries for newly issued
	// expiring credit. ImmediateReleases handles credit that covers already
	// consumed advance: the issued credit has an expiry, but the covered slice is
	// already used, so its planned breakage is released in the same ledger group.
	PlanIssuance(ctx context.Context, input PlanIssuanceInput) ([]ledger.TransactionInput, []PendingRecord, error)

	// ReleasePlan creates a future-dated inverse entry that reduces a planned
	// breakage amount because the underlying expiring credit has been consumed or
	// otherwise removed before expiry.
	ReleasePlan(ctx context.Context, input ReleasePlanInput) (ledger.TransactionInput, PendingRecord, error)

	// ReopenRelease creates a future-dated entry that increases breakage again
	// because a correction made previously consumed expiring credit unused.
	ReopenRelease(ctx context.Context, input ReopenReleaseInput) (ledger.TransactionInput, PendingRecord, error)

	// ListPlans returns unreleased planned breakage in the same order the FBO
	// collector must consume expiring credit.
	ListPlans(ctx context.Context, input ListPlansInput) ([]Plan, error)

	// ListReleases returns usage releases keyed by the original FBO source entry
	// ids, with already-reopened amounts removed.
	ListReleases(ctx context.Context, input ListReleasesInput) ([]Release, error)

	// ListExpiredRecords returns raw breakage rows that have reached expiry.
	// Consumers must net plan/release/reopen rows before showing customer-facing
	// expired credit transactions.
	ListExpiredRecords(ctx context.Context, input ListExpiredRecordsInput) ([]Record, error)

	// ListExpiredBreakageImpacts returns customer-visible breakage impacts by
	// netting raw breakage rows that have reached expiry.
	ListExpiredBreakageImpacts(ctx context.Context, input ListExpiredBreakageImpactsInput) (ListExpiredBreakageImpactsResult, error)

	// PersistCommittedRecords turns pending record metadata into durable
	// rows after the corresponding breakage ledger transactions have committed.
	PersistCommittedRecords(ctx context.Context, pending []PendingRecord, group ledger.TransactionGroup) error
}

func NewNoopService

func NewNoopService() Service

NewNoopService returns a breakage service that never plans or releases breakage.

func NewService

func NewService(config Config) (Service, error)

type SourceKind

type SourceKind = ledger.BreakageSourceKind
const (
	SourceKindCreditPurchase  SourceKind = ledger.BreakageSourceKindCreditPurchase
	SourceKindUsage           SourceKind = ledger.BreakageSourceKindUsage
	SourceKindUsageCorrection SourceKind = ledger.BreakageSourceKindUsageCorrection
	// SourceKindCreditPurchaseCorrection is reserved for a future credit-purchase
	// correction flow. The breakage primitive is clear, but the charge domain does
	// not yet define correction/delete semantics, source-specific removable amount
	// checks, or policy for already-consumed purchased credit.
	SourceKindCreditPurchaseCorrection SourceKind = ledger.BreakageSourceKindCreditPurchaseCorrection
	SourceKindAdvanceBackfill          SourceKind = ledger.BreakageSourceKindAdvanceBackfill
)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL