Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions openmeter/ledger/chargeadapter/creditpurchase.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (h *creditPurchaseHandler) OnCreditPurchasePaymentAuthorized(ctx context.Co
CustomerID: customerID,
Namespace: charge.Namespace,
},
transactions.FundCustomerReceivableTemplate{
transactions.AuthorizeCustomerReceivablePaymentTemplate{
At: charge.CreatedAt,
Amount: charge.Intent.CreditAmount,
Currency: charge.Intent.Currency,
Expand Down Expand Up @@ -121,7 +121,7 @@ func (h *creditPurchaseHandler) OnCreditPurchasePaymentSettled(ctx context.Conte
CustomerID: customerID,
Namespace: charge.Namespace,
},
transactions.SettleCustomerReceivablePaymentTemplate{
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: charge.CreatedAt,
Amount: charge.Intent.CreditAmount,
Currency: charge.Intent.Currency,
Expand Down Expand Up @@ -236,13 +236,13 @@ func (h *creditPurchaseHandler) issueCreditPurchase(ctx context.Context, charge
// Promotional grants settle immediately through wash so the credited FBO balance
// does not leave an unsettled receivable behind.
templates = append(templates,
transactions.FundCustomerReceivableTemplate{
transactions.AuthorizeCustomerReceivablePaymentTemplate{
At: charge.CreatedAt,
Amount: charge.Intent.CreditAmount,
Currency: charge.Intent.Currency,
CostBasis: &costBasis,
},
transactions.SettleCustomerReceivablePaymentTemplate{
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: charge.CreatedAt,
Amount: charge.Intent.CreditAmount,
Currency: charge.Intent.Currency,
Expand Down
6 changes: 3 additions & 3 deletions openmeter/ledger/chargeadapter/creditpurchase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ func TestOnCreditPurchasePaymentAuthorized(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, ref.TransactionGroupID)

require.True(t, env.sumBalance(t, env.receivableSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(-100)))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(100)))
require.True(t, env.sumBalance(t, env.washSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(-100)))
require.True(t, env.sumBalance(t, env.receivableSubAccount(t, costBasis)).Equal(alpacadecimal.Zero))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(-100)))
require.True(t, env.sumBalance(t, env.washSubAccount(t, costBasis)).Equal(alpacadecimal.Zero))
require.True(t, env.sumBalance(t, env.fboSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(100)))
}

Expand Down
8 changes: 4 additions & 4 deletions openmeter/ledger/chargeadapter/flatfee.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ func (h *flatFeeHandler) OnCreditsOnlyUsageAccruedCorrection(ctx context.Context
})
}

// OnFlatFeePaymentAuthorized currently only stages receivable funding from wash
// for the directly-invoiced portion. Revenue recognition is handled elsewhere.
// OnFlatFeePaymentAuthorized stages the directly-invoiced receivable as
// authorized. Revenue recognition is handled elsewhere.
func (h *flatFeeHandler) OnPaymentAuthorized(ctx context.Context, charge flatfee.Charge) (ledgertransaction.GroupReference, error) {
if err := charge.Validate(); err != nil {
return ledgertransaction.GroupReference{}, err
Expand Down Expand Up @@ -234,7 +234,7 @@ func (h *flatFeeHandler) OnPaymentAuthorized(ctx context.Context, charge flatfee
CustomerID: customerID,
Namespace: charge.Namespace,
},
transactions.FundCustomerReceivableTemplate{
transactions.AuthorizeCustomerReceivablePaymentTemplate{
At: charge.Intent.InvoiceAt,
Amount: receivableReplenishment,
Currency: charge.Intent.Currency,
Expand Down Expand Up @@ -287,7 +287,7 @@ func (h *flatFeeHandler) OnPaymentSettled(ctx context.Context, charge flatfee.Ch
CustomerID: customerID,
Namespace: charge.Namespace,
},
transactions.SettleCustomerReceivablePaymentTemplate{
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: charge.Intent.InvoiceAt,
Amount: charge.Realizations.AccruedUsage.Totals.Total,
Currency: charge.Intent.Currency,
Expand Down
30 changes: 15 additions & 15 deletions openmeter/ledger/chargeadapter/flatfee_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ func TestOnFlatFeeStandardInvoiceUsageAccrued(t *testing.T) {
}

func TestOnFlatFeePaymentAuthorized(t *testing.T) {
t.Run("credit_then_invoice stages receivable funding from receivable-backed accrued", func(t *testing.T) {
t.Run("credit_then_invoice stages open receivable as authorized", func(t *testing.T) {
env := newFlatFeeHandlerTestEnv(t)

// First accrue usage: receivable → accrued
Expand All @@ -319,16 +319,16 @@ func TestOnFlatFeePaymentAuthorized(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, ref.TransactionGroupID)

// Receivable is only funded into the authorized staging bucket at authorization time.
require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-75)))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(75)))
require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.NewFromInt(-75)))
// Authorization only moves the receivable between status buckets.
require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.Zero))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-75)))
require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.Zero))
// No revenue recognition happens here anymore.
require.True(t, env.sumBalance(t, env.invoiceAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(75)))
require.True(t, env.sumBalance(t, env.invoiceEarningsSubAccount(t)).Equal(alpacadecimal.Zero))
})

t.Run("credit_then_invoice mixed FBO and receivable only stages receivable funding", func(t *testing.T) {
t.Run("credit_then_invoice mixed FBO and receivable only authorizes receivable", func(t *testing.T) {
env := newFlatFeeHandlerTestEnv(t)

// Fund FBO with 40
Expand All @@ -355,9 +355,9 @@ func TestOnFlatFeePaymentAuthorized(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, ref.TransactionGroupID)

// Receivable funding stays staged until settlement.
require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-20)))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(20)))
// Cash movement stays deferred until settlement.
require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.Zero))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-20)))
// Existing accrued balances stay untouched until a later recognition flow.
require.True(t, env.sumBalance(t, env.creditAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(40)))
require.True(t, env.sumBalance(t, env.invoiceAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(20)))
Expand All @@ -376,9 +376,9 @@ func TestOnFlatFeePaymentAuthorized(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, ref.TransactionGroupID)

require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-30)))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(75)))
require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.NewFromInt(-75)))
require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(45)))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-75)))
require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.Zero))
require.True(t, env.sumBalance(t, env.invoiceAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(30)))
require.True(t, env.sumBalance(t, env.invoiceEarningsSubAccount(t)).Equal(alpacadecimal.Zero))
})
Expand Down Expand Up @@ -412,7 +412,7 @@ func TestOnFlatFeePaymentAuthorized(t *testing.T) {
}

func TestOnFlatFeePaymentSettled(t *testing.T) {
t.Run("credit_then_invoice settles authorized receivable into open receivable", func(t *testing.T) {
t.Run("credit_then_invoice settles authorized receivable from wash", func(t *testing.T) {
env := newFlatFeeHandlerTestEnv(t)

total := alpacadecimal.NewFromInt(40)
Expand Down Expand Up @@ -583,13 +583,13 @@ func (e *flatFeeHandlerTestEnv) fundPriority(t *testing.T, priority int, amount
CostBasis: &costBasis,
CreditPriority: &priority,
},
transactions.FundCustomerReceivableTemplate{
transactions.AuthorizeCustomerReceivablePaymentTemplate{
At: e.Now(),
Amount: alpacadecimal.NewFromInt(amount),
Currency: e.Currency,
CostBasis: &costBasis,
},
transactions.SettleCustomerReceivablePaymentTemplate{
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: e.Now(),
Amount: alpacadecimal.NewFromInt(amount),
Currency: e.Currency,
Expand Down
4 changes: 2 additions & 2 deletions openmeter/ledger/chargeadapter/usagebased.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func (h *usageBasedHandler) OnPaymentAuthorized(ctx context.Context, input usage
CustomerID: customerID,
Namespace: input.Charge.Namespace,
},
transactions.FundCustomerReceivableTemplate{
transactions.AuthorizeCustomerReceivablePaymentTemplate{
At: eventTime,
Amount: receivableReplenishment,
Currency: input.Charge.Intent.Currency,
Expand Down Expand Up @@ -191,7 +191,7 @@ func (h *usageBasedHandler) OnPaymentSettled(ctx context.Context, input usagebas
CustomerID: customerID,
Namespace: input.Charge.Namespace,
},
transactions.SettleCustomerReceivablePaymentTemplate{
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: eventTime,
Amount: input.Run.InvoiceUsage.Totals.Total,
Currency: input.Charge.Intent.Currency,
Expand Down
14 changes: 7 additions & 7 deletions openmeter/ledger/chargeadapter/usagebased_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,10 @@ func TestOnUsageBasedPaymentAuthorized(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, ref.TransactionGroupID)

// Receivable is only funded into the authorized staging bucket at authorization time.
require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-40)))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(40)))
require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.NewFromInt(-40)))
// Authorization only moves the receivable between status buckets.
require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.Zero))
require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-40)))
require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.Zero))
// No revenue recognition happens here anymore.
require.True(t, env.sumBalance(t, env.invoiceAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(40)))
require.True(t, env.sumBalance(t, env.invoiceEarningsSubAccount(t)).Equal(alpacadecimal.Zero))
Expand All @@ -280,7 +280,7 @@ func TestOnUsageBasedPaymentAuthorized(t *testing.T) {
}

func TestOnUsageBasedPaymentSettled(t *testing.T) {
t.Run("credit_then_invoice settles authorized receivable", func(t *testing.T) {
t.Run("credit_then_invoice settles authorized receivable from wash", func(t *testing.T) {
env := newUsageBasedHandlerTestEnv(t)

total := alpacadecimal.NewFromInt(25)
Expand Down Expand Up @@ -517,13 +517,13 @@ func (e *usageBasedHandlerTestEnv) fundPriority(t *testing.T, priority int, amou
CostBasis: &costBasis,
CreditPriority: &priority,
},
transactions.FundCustomerReceivableTemplate{
transactions.AuthorizeCustomerReceivablePaymentTemplate{
At: e.Now(),
Amount: alpacadecimal.NewFromInt(amount),
Currency: e.Currency,
CostBasis: &costBasis,
},
transactions.SettleCustomerReceivablePaymentTemplate{
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: e.Now(),
Amount: alpacadecimal.NewFromInt(amount),
Currency: e.Currency,
Expand Down
4 changes: 2 additions & 2 deletions openmeter/ledger/customerbalance/testenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,12 @@ func (e *testEnv) fundOpenReceivableInCurrency(t *testing.T, amount alpacadecima
CustomerID: e.CustomerID,
Namespace: e.Namespace,
},
transactions.FundCustomerReceivableTemplate{
transactions.AuthorizeCustomerReceivablePaymentTemplate{
At: e.Now(),
Amount: amount,
Currency: currency,
},
transactions.SettleCustomerReceivablePaymentTemplate{
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: e.Now(),
Amount: amount,
Currency: currency,
Expand Down
4 changes: 2 additions & 2 deletions openmeter/ledger/routingrules/routingrules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func TestDefaultValidator_RejectsMismatchedReceivableAndFBORoute(t *testing.T) {
require.ErrorContains(t, err, "ledger routing rule violated")
}

func TestDefaultValidator_AllowsReceivableAuthorizationStageTransition(t *testing.T) {
func TestDefaultValidator_AllowsReceivableAuthorizationTransition(t *testing.T) {
validator := routingrules.DefaultValidator
openStatus := ledger.TransactionAuthorizationStatusOpen
status := ledger.TransactionAuthorizationStatusAuthorized
Expand All @@ -148,7 +148,7 @@ func TestDefaultValidator_AllowsReceivableAuthorizationStageTransition(t *testin
require.NoError(t, err)
}

func TestDefaultValidator_RejectsReceivableAuthorizationStageWithWrongDirection(t *testing.T) {
func TestDefaultValidator_RejectsReceivableAuthorizationTransitionWithWrongDirection(t *testing.T) {
validator := routingrules.DefaultValidator
openStatus := ledger.TransactionAuthorizationStatusOpen
status := ledger.TransactionAuthorizationStatusAuthorized
Expand Down
4 changes: 4 additions & 0 deletions openmeter/ledger/transactions/correction.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ func transactionTemplateByName(name string) (TransactionTemplate, error) {
return FundCustomerReceivableTemplate{}, nil
case templateName(SettleCustomerReceivablePaymentTemplate{}):
return SettleCustomerReceivablePaymentTemplate{}, nil
case templateName(AuthorizeCustomerReceivablePaymentTemplate{}):
return AuthorizeCustomerReceivablePaymentTemplate{}, nil
case templateName(SettleCustomerReceivableFromPaymentTemplate{}):
return SettleCustomerReceivableFromPaymentTemplate{}, nil
case templateName(AttributeCustomerAdvanceReceivableCostBasisTemplate{}):
return AttributeCustomerAdvanceReceivableCostBasisTemplate{}, nil
case templateName(CoverCustomerReceivableTemplate{}):
Expand Down
45 changes: 43 additions & 2 deletions openmeter/ledger/transactions/correction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,52 @@ func TestCorrectTransactionDispatchesTemplateStub(t *testing.T) {
OriginalTransaction: &correctionTestTransaction{
id: models.NamespacedID{Namespace: "ns", ID: "tx"},
annotations: ledger.TransactionAnnotations(
templateName(FundCustomerReceivableTemplate{}),
templateName(SettleCustomerReceivableFromPaymentTemplate{}),
ledger.TransactionDirectionForward,
),
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "FundCustomerReceivableTemplate correction is not implemented")
require.Contains(t, err.Error(), "SettleCustomerReceivableFromPaymentTemplate correction is not implemented")
}

func TestCorrectTransactionDispatchesArchivedReceivablePaymentTemplates(t *testing.T) {
t.Parallel()

tests := []struct {
name string
templateName string
expectedError string
}{
{
name: "legacy fund means settlement funding",
templateName: templateName(FundCustomerReceivableTemplate{}),
expectedError: "FundCustomerReceivableTemplate correction is not implemented",
},
{
name: "legacy settle means authorization status transfer",
templateName: templateName(SettleCustomerReceivablePaymentTemplate{}),
expectedError: "SettleCustomerReceivablePaymentTemplate correction is not implemented",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

_, err := CorrectTransaction(t.Context(), ResolverDependencies{}, CorrectionInput{
At: time.Now(),
Amount: alpacadecimal.NewFromInt(1),
OriginalTransaction: &correctionTestTransaction{
id: models.NamespacedID{Namespace: "ns", ID: "tx"},
annotations: ledger.TransactionAnnotations(
tt.templateName,
ledger.TransactionDirectionForward,
),
},
})
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
})
}
}
30 changes: 16 additions & 14 deletions openmeter/ledger/transactions/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,16 @@ func (t IssueCustomerReceivableTemplate) resolve(ctx context.Context, customerID
}, nil
}

// FundCustomerReceivableTemplate funds the authorized receivable sub-account from wash.
type FundCustomerReceivableTemplate struct {
// SettleCustomerReceivableFromPaymentTemplate records settled payment funds by
// clearing authorized receivable from wash.
type SettleCustomerReceivableFromPaymentTemplate struct {
At time.Time
Amount alpacadecimal.Decimal
Currency currencyx.Code
CostBasis *alpacadecimal.Decimal
}

func (t FundCustomerReceivableTemplate) Validate() error {
func (t SettleCustomerReceivableFromPaymentTemplate) Validate() error {
if t.At.IsZero() {
return fmt.Errorf("at is required")
}
Expand All @@ -173,17 +174,17 @@ func (t FundCustomerReceivableTemplate) Validate() error {
return nil
}

var _ CustomerTransactionTemplate = (FundCustomerReceivableTemplate{})
var _ CustomerTransactionTemplate = (SettleCustomerReceivableFromPaymentTemplate{})

func (t FundCustomerReceivableTemplate) correct(CorrectionInput) ([]ledger.TransactionInput, error) {
func (t SettleCustomerReceivableFromPaymentTemplate) correct(CorrectionInput) ([]ledger.TransactionInput, error) {
return nil, templateCorrectionNotImplemented(templateName(t))
}

func (t FundCustomerReceivableTemplate) typeGuard() guard {
func (t SettleCustomerReceivableFromPaymentTemplate) typeGuard() guard {
return true
}

func (t FundCustomerReceivableTemplate) resolve(ctx context.Context, customerID customer.CustomerID, resolvers ResolverDependencies) (ledger.TransactionInput, error) {
func (t SettleCustomerReceivableFromPaymentTemplate) resolve(ctx context.Context, customerID customer.CustomerID, resolvers ResolverDependencies) (ledger.TransactionInput, error) {
customerAccounts, err := resolvers.AccountService.GetCustomerAccounts(ctx, customerID)
if err != nil {
return nil, fmt.Errorf("failed to get customer accounts: %w", err)
Expand Down Expand Up @@ -226,15 +227,16 @@ func (t FundCustomerReceivableTemplate) resolve(ctx context.Context, customerID
}, nil
}

// SettleCustomerReceivablePaymentTemplate moves authorized receivable staging into the open receivable route.
type SettleCustomerReceivablePaymentTemplate struct {
// AuthorizeCustomerReceivablePaymentTemplate moves open receivable into the
// authorized receivable route without moving funds across the external cash boundary.
type AuthorizeCustomerReceivablePaymentTemplate struct {
At time.Time
Amount alpacadecimal.Decimal
Currency currencyx.Code
CostBasis *alpacadecimal.Decimal
}

func (t SettleCustomerReceivablePaymentTemplate) Validate() error {
func (t AuthorizeCustomerReceivablePaymentTemplate) Validate() error {
if t.At.IsZero() {
return fmt.Errorf("at is required")
}
Expand All @@ -256,17 +258,17 @@ func (t SettleCustomerReceivablePaymentTemplate) Validate() error {
return nil
}

func (t SettleCustomerReceivablePaymentTemplate) typeGuard() guard {
func (t AuthorizeCustomerReceivablePaymentTemplate) typeGuard() guard {
return true
}

var _ CustomerTransactionTemplate = (SettleCustomerReceivablePaymentTemplate{})
var _ CustomerTransactionTemplate = (AuthorizeCustomerReceivablePaymentTemplate{})

func (t SettleCustomerReceivablePaymentTemplate) correct(CorrectionInput) ([]ledger.TransactionInput, error) {
func (t AuthorizeCustomerReceivablePaymentTemplate) correct(CorrectionInput) ([]ledger.TransactionInput, error) {
return nil, templateCorrectionNotImplemented(templateName(t))
}

func (t SettleCustomerReceivablePaymentTemplate) resolve(ctx context.Context, customerID customer.CustomerID, resolvers ResolverDependencies) (ledger.TransactionInput, error) {
func (t AuthorizeCustomerReceivablePaymentTemplate) resolve(ctx context.Context, customerID customer.CustomerID, resolvers ResolverDependencies) (ledger.TransactionInput, error) {
customerAccounts, err := resolvers.AccountService.GetCustomerAccounts(ctx, customerID)
if err != nil {
return nil, fmt.Errorf("failed to get customer accounts: %w", err)
Expand Down
Loading
Loading