subscriptionsync

package
v1.0.0-beta.234 Latest Latest
Warning

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

Go to latest
Published: Sep 25, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

README

Subscription sync

Subscription sync is the reconciliation boundary between the desired schedule owned by Subscription and the billing-side artifacts owned by Billing and Charges.

It translates a subscription view into invoice lines or charge intents. It does not edit subscriptions, calculate invoice totals, realize charges, or advance invoice and payment lifecycles.

Reconciliation model

A sync is given a subscription reference or expanded view and an asOf horizon. It resolves the subscription, including deleted records, and the customer's billing profile before acquiring billing's customer lock. Under that lock it:

  1. loads the persisted invoice-line and charge state owned by that subscription
  2. builds the target billable periods from the resolved subscription view
  3. diffs target and persisted items by their subscription-sync identity
  4. applies invoice patches, then charge patches
  5. records whether the subscription still has billables and when it next needs reconciliation

This is reconciliation, not event handling by side effect. Repeating the same sync must converge. Subscription events provide the prompt path, invoice creation triggers a refill of consumed future work, and the periodic reconciler repairs missed or failed event processing.

Invoice patches, charge patches, and the sync-state update share the billing transaction. A failure rolls back the plan rather than leaving only the earlier patches applied.

The entrypoints that also invoice the customer run pending-line collection after reconciliation so flat fees billed in advance can become invoices immediately.

Target time model

The target builder walks each phase and item version into service periods up to the requested horizon. The horizon normally expands to the end of the aligned billing period containing asOf; it is not simply a database query cutoff.

Each target item keeps three periods:

  • service period: the portion of service represented by this artifact
  • full service period: the untruncated period used for proration
  • billing period: the subscription-aligned outer period

Flat fees billed in advance use the billing-period start as InvoiceAt. Everything else is invoiced no earlier than both the service-period and billing-period end.

Recurring artifacts use the stable identity:

{subscriptionID}/{phaseKey}/{itemKey}/v[{itemVersion}]/period[{periodIndex}]

One-time artifacts omit the period component. This identity is stored on both invoice-backed and charge-backed state. Duplicate identities, including a collision across the two backends, are an integrity error.

Backend ownership

Persisted artifact type determines which backend continues to own an existing item. New target items are routed according to the available charge stack, feature gates, settlement mode, and price type. This permits gradual movement from invoice-backed subscription items to charge-backed items without silently replacing already persisted ownership.

Charge-backed reconciliation is the replacement path for supported new artifacts. The invoice backend remains for compatibility while that transition is incomplete. Existing artifacts keep their persisted backend rather than being migrated implicitly.

Invoice-backed patches may create gathering lines or update mutable gathering and standard invoices. If subscription state disagrees with an immutable standard invoice, sync records validation issues; it does not rewrite the invoice.

Charge-backed patches operate on the charge intent. Charge-managed invoice lines are projections of charge state and are deliberately excluded from the subscription-sync invoice read model. A customer-facing charge override also does not erase the subscription-owned base intent: later subscription changes continue to reconcile that base without resurrecting the overridden effective charge. Credit-then-invoice usage reconciliation may preserve a missing feature or meter as a product-catalog issue on a newly created charge; invoice assignment keeps its gathering line pending until the dependency is repaired.

When an existing flat-fee or usage-based charge's physical phase or item reference changes, the charge-specific comparison checks the target against the subscription-owned base intent while excluding the reference and reconciliation periods. The root subscription ID is the reconciliation boundary and cannot change. Plan key and version are also excluded: they are best-effort creation-time attribution, so a different snapshot does not establish that the physical item was replaced. This comparison is an approximation of item identity. A change to another source base-intent field means that the logical child reference now describes a replacement charge, so sync emits delete/create patches. Expanded currency data is not source intent; currency is compared by its stable reference. Flat-fee comparison treats disabled proration modes as equivalent and ignores the period-derived realized amount, but retains the unprorated source price as a replacement signal. If only the physical phase or item reference changed, sync repairs that reference and lets normal period reconciliation add a compatible shrink or extend. A changed period start also requires replacement because shrink and extend only move the period end. This start mismatch can remain from the legacy repair path, which silently overwrote a matched charge's subscription reference even when the referenced replacement item began at a different time.

This decision always follows system intent. Manual overrides remain associated with the retired or repaired charge and do not participate in comparison; users remain responsible for managing them. Charge deletion remains observable, while economic corrections for already realized credits or invoice history remain the responsibility of the downstream charge lifecycle rather than subscription sync.

Deletion, cancellation, and retries

Cancellation syncs through the subscription end so artifacts are shortened or removed according to the final desired periods. When a cancellation event's root UpdatedAt predates the current subscription, sync uses the current view and current cancellation end; a continued subscription needs no cancellation sync. Annotation writes can also advance the root timestamp, so age alone must not discard a still-valid cancellation. This timestamp is not a version for all child changes. A deleted subscription has no view and therefore produces an empty target, asking reconciliation to remove its remaining owned artifacts subject to immutable-invoice rules. Cleanup must use an ID-based entrypoint because the normal subscription view lookup excludes deleted records.

Sync state is written only after the plan has been applied. HasBillables prevents needless future scheduling; NextSyncAfter is the end of the generated horizon. Errors remain retryable through the event handler or periodic reconciler. Dry-run builds the same plan but only logs it and does not write artifacts or sync state.

Intentional limitations

  • invoice-backed subscription artifacts remain fiat-only; custom-currency items require the charges backend
  • charge intents preserve each subscription item's fiat or managed custom currency. For credit_then_invoice, subscription cost-basis mode maps to a dynamic charge cost basis or the subscription's pinned cost-basis resource; the charge lifecycle performs overage conversion into invoice currency
  • subscription-owned credit-purchase charges are unsupported
  • immutable invoice drift is reported, not automatically corrected
  • an asOf at the current instant is not a request to provision the entire future subscription; callers that need future artifacts must provide the corresponding horizon

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Adapter

type Adapter interface {
	SyncStateAdapter

	entutils.TxCreator
}

type EventHandler

type EventHandler interface {
	HandleCancelledEvent(ctx context.Context, event *subscription.CancelledEvent) error
	HandleDeletedEvent(ctx context.Context, event *subscription.DeletedEvent) error
	HandleSubscriptionSyncEvent(ctx context.Context, event *subscription.SubscriptionSyncEvent) error
	HandleInvoiceCreation(ctx context.Context, event *billing.StandardInvoiceCreatedEvent) error
}

type GetSyncStatesInput

type GetSyncStatesInput = []models.NamespacedID

type InvalidateSyncStateInput

type InvalidateSyncStateInput = models.NamespacedID

type Service

type Service interface {
	EventHandler
	SyncService
	SyncStateService
}

type SyncService

type SyncService interface {
	SyncByViewAndInvoiceCustomer(ctx context.Context, view subscription.SubscriptionView, asOf time.Time, opts ...SynchronizeSubscriptionOption) error
	SyncByIDAndInvoiceCustomer(ctx context.Context, subscriptionID models.NamespacedID, asOf time.Time, opts ...SynchronizeSubscriptionOption) error
	SyncByView(ctx context.Context, view subscription.SubscriptionView, asOf time.Time, opts ...SynchronizeSubscriptionOption) error
	SyncByID(ctx context.Context, subscriptionID models.NamespacedID, asOf time.Time, opts ...SynchronizeSubscriptionOption) error
}

type SyncState

type SyncState struct {
	SubscriptionID models.NamespacedID
	HasBillables   bool
	SyncedAt       time.Time
	NextSyncAfter  *time.Time
}

type SyncStateAdapter

type SyncStateAdapter interface {
	InvalidateSyncState(ctx context.Context, input InvalidateSyncStateInput) error
	GetSyncStates(ctx context.Context, input GetSyncStatesInput) ([]SyncState, error)
	UpsertSyncState(ctx context.Context, input UpsertSyncStateInput) error
}

type SyncStateService

type SyncStateService interface {
	GetSyncStates(ctx context.Context, input GetSyncStatesInput) ([]SyncState, error)
}

type SynchronizeSubscriptionOption

type SynchronizeSubscriptionOption func(*SynchronizeSubscriptionOptions)

func EnableDryRun

func EnableDryRun() SynchronizeSubscriptionOption

type SynchronizeSubscriptionOptions

type SynchronizeSubscriptionOptions struct {
	DryRun bool
}

type UpsertSyncStateInput

type UpsertSyncStateInput = SyncState

func (UpsertSyncStateInput) Validate

func (i UpsertSyncStateInput) Validate() error

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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