diff --git a/openmeter/billing/charges/creditpurchase/service/promotional_test.go b/openmeter/billing/charges/creditpurchase/service/promotional_test.go index 0dbb545618..9e9781d6eb 100644 --- a/openmeter/billing/charges/creditpurchase/service/promotional_test.go +++ b/openmeter/billing/charges/creditpurchase/service/promotional_test.go @@ -14,6 +14,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" currenciestestutils "github.com/openmeterio/openmeter/openmeter/currencies/testutils/currency" + "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/timeutil" ) @@ -25,6 +26,10 @@ func TestPromotionalCreditPurchaseStateMachineAdvancesCreatedChargeToFinal(t *te // - the promotional state machine advances until stable // then: // - it grants the promotional credits, backfills lineage, and persists the final status + purchasedAt := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC) + clock.FreezeTime(purchasedAt) + defer clock.UnFreeze() + stateMachine, charge, adapter, lineageService := newPromotionalStateMachineTestMachine( t, creditpurchase.StatusCreated, @@ -37,11 +42,12 @@ func TestPromotionalCreditPurchaseStateMachineAdvancesCreatedChargeToFinal(t *te require.Equal(t, creditpurchase.StatusFinal, advancedCharge.Status) require.Equal(t, creditpurchase.StatusFinal, adapter.updatedBase.Status) require.NotNil(t, advancedCharge.Realizations.CreditGrantRealization) - require.NotEmpty(t, advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID) + require.Equal(t, "ledger-tx-1", advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID) + require.Equal(t, purchasedAt, advancedCharge.Realizations.CreditGrantRealization.Time) require.Equal(t, 1, adapter.createCreditGrantCalls) require.Equal(t, charge.GetChargeID(), adapter.createdGrantChargeID) require.Equal(t, advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID, adapter.createdGrantInput.TransactionGroupID) - require.False(t, adapter.createdGrantInput.GrantedAt.IsZero()) + require.Equal(t, purchasedAt, adapter.createdGrantInput.GrantedAt) lineageService.AssertExpectations(t) } @@ -217,7 +223,7 @@ func newPromotionalStateMachineTestMachine( input.CustomerID == charge.Intent.CustomerID && input.Currency.GetCode() == charge.Intent.Currency.GetCode() && input.Amount.Equal(charge.Intent.CreditAmount) && - input.BackingTransactionGroupID != "" + input.BackingTransactionGroupID == "ledger-tx-1" })). Return(nil). Once() diff --git a/openmeter/ledger/chargeadapter/creditpurchase.go b/openmeter/ledger/chargeadapter/creditpurchase.go index 49cd608ee0..291d3e2d86 100644 --- a/openmeter/ledger/chargeadapter/creditpurchase.go +++ b/openmeter/ledger/chargeadapter/creditpurchase.go @@ -18,6 +18,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/ledger" "github.com/openmeterio/openmeter/openmeter/ledger/breakage" "github.com/openmeterio/openmeter/openmeter/ledger/transactions" + "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/cmpx" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/framework/transaction" @@ -227,7 +228,15 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch } annotations := chargeAnnotationsForCreditPurchaseCharge(charge) featureFilters := charge.Intent.FeatureFilters.Normalize() - bookedAt := charge.Intent.ServicePeriod.To + effectiveAt := charge.Intent.ServicePeriod.To + // LedgerTransaction.CreatedAt retains recording time. For effective time, + // future-effective purchases book attribution immediately so subsequent + // committed purchases observe reduced advance; already-effective purchases + // backdate attribution alongside issuance and settlement. + advanceAttributionEffectiveAt := clock.Now() + if effectiveAt.Before(advanceAttributionEffectiveAt) { + advanceAttributionEffectiveAt = effectiveAt + } advanceAttributions, err := h.advanceAttributions(ctx, customerID, charge.Intent.Currency.GetCode(), charge.Intent.CreditAmount, featureFilters) if err != nil { @@ -248,7 +257,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch for _, attribution := range advanceAttributions { templates = append(templates, transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{ - At: bookedAt, + At: advanceAttributionEffectiveAt, Amount: attribution.advanceAmount, Currency: charge.Intent.Currency.GetCode(), CostBasis: &costBasis, @@ -260,7 +269,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch if attribution.accruedAmount.IsPositive() { templates = append(templates, transactions.TranslateCustomerAccruedCostBasisTemplate{ - At: bookedAt, + At: advanceAttributionEffectiveAt, Amount: attribution.accruedAmount, Currency: charge.Intent.Currency.GetCode(), TaxCode: attribution.taxCode, @@ -275,7 +284,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch if issuableAmount.IsPositive() { templates = append(templates, transactions.IssueCustomerReceivableTemplate{ - At: bookedAt, + At: effectiveAt, Amount: issuableAmount, Currency: charge.Intent.Currency.GetCode(), CostBasis: &costBasis, @@ -291,7 +300,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch // does not leave an unsettled receivable behind. templates = append(templates, transactions.AuthorizeCustomerReceivablePaymentTemplate{ - At: bookedAt, + At: effectiveAt, Amount: charge.Intent.CreditAmount, Currency: charge.Intent.Currency.GetCode(), CostBasis: &costBasis, @@ -299,7 +308,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch SourceChargeID: &charge.ID, }, transactions.SettleCustomerReceivableFromPaymentTemplate{ - At: bookedAt, + At: effectiveAt, Amount: charge.Intent.CreditAmount, Currency: charge.Intent.Currency.GetCode(), CostBasis: &costBasis, diff --git a/openmeter/ledger/chargeadapter/creditpurchase_test.go b/openmeter/ledger/chargeadapter/creditpurchase_test.go index 3ffbe1fabf..46271240c3 100644 --- a/openmeter/ledger/chargeadapter/creditpurchase_test.go +++ b/openmeter/ledger/chargeadapter/creditpurchase_test.go @@ -75,6 +75,68 @@ func TestOnPromotionalCreditPurchase_BacksAdvanceBeforeTopUp(t *testing.T) { require.True(t, env.sumBalance(t, env.washSubAccount(t, alpacadecimal.Zero)).Equal(alpacadecimal.NewFromInt(-100))) } +func TestOnCreditPurchaseInitiated_PastEffectiveGrantBackdatesAdvanceAttribution(t *testing.T) { + env := newCreditPurchaseHandlerTestEnv(t) + + // given: + // - advance exposure that predates a credit purchase's effective time + // - the purchase is materialized after that effective time + // when: + // - the purchase is initiated + // then: + // - recording time stays current while every purchase posting is effective in the past + recordedAt := env.Now() + effectiveAt := recordedAt.Add(-2 * time.Hour) + func() { + clock.FreezeTime(effectiveAt.Add(-time.Hour)) + defer clock.UnFreeze() + env.createAdvanceExposure(t, alpacadecimal.NewFromInt(40)) + }() + clock.FreezeTime(recordedAt) + defer clock.UnFreeze() + + costBasis := mustDecimal(t, "0.5") + charge := env.newExternalCharge(alpacadecimal.NewFromInt(100), costBasis) + effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt} + charge.Intent.ServicePeriod = effectivePeriod + charge.Intent.FullServicePeriod = effectivePeriod + charge.Intent.BillingPeriod = effectivePeriod + + ref, err := env.handler.OnCreditPurchaseInitiated(t.Context(), charge) + require.NoError(t, err) + require.NotEmpty(t, ref.TransactionGroupID) + + bookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, ref.TransactionGroupID) + for _, template := range []transactions.TransactionTemplate{ + transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{}, + transactions.TranslateCustomerAccruedCostBasisTemplate{}, + transactions.IssueCustomerReceivableTemplate{}, + } { + bookedAt := bookedAtByTemplate[transactions.TemplateCode(template)] + require.Len(t, bookedAt, 1) + requireLedgerBookedAtEqual(t, effectiveAt, bookedAt[0]) + } + + transactionRows, err := env.DB.LedgerTransaction.Query(). + Where( + ledgertransactiondb.Namespace(env.Namespace), + ledgertransactiondb.GroupID(ref.TransactionGroupID), + ). + All(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, transactionRows) + for _, transactionRow := range transactionRows { + require.False(t, transactionRow.CreatedAt.Before(recordedAt)) + } + + require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.unknownReceivableSubAccount(t), effectiveAt).InexactFloat64()) + require.Equal(t, float64(-100), env.sumBalanceAsOf(t, env.receivableSubAccount(t, costBasis), effectiveAt).InexactFloat64()) + require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.authorizedReceivableSubAccount(t, costBasis), effectiveAt).InexactFloat64()) + require.Equal(t, float64(40), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), effectiveAt).InexactFloat64()) + require.Equal(t, float64(60), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64()) + require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.washSubAccount(t, costBasis), effectiveAt).InexactFloat64()) +} + func TestOnCreditPurchaseInitiated_BackfillsOnlyMatchingFeatureAdvances(t *testing.T) { env := newCreditPurchaseHandlerTestEnv(t) env.createAdvanceExposureWithFeatures(t, alpacadecimal.NewFromInt(40), []string{"api-calls"}) @@ -140,12 +202,21 @@ func TestOnCreditPurchaseInitiated(t *testing.T) { require.True(t, env.sumBalance(t, env.receivableSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(-100))) } -func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T) { +func TestOnCreditPurchaseInitiated_FutureEffectiveGrantBackfillsAdvanceAtPurchaseTime(t *testing.T) { env := newCreditPurchaseHandlerTestEnv(t) + // given: + // - existing advance and a credit purchase whose remainder becomes effective later + // when: + // - the materialized charge initiates the purchase now + // then: + // - advance attribution is booked now while only the remainder is issued later + purchasedAt := env.Now() + env.createAdvanceExposure(t, alpacadecimal.NewFromInt(40)) + costBasis := mustDecimal(t, "0.5") charge := env.newExternalCharge(alpacadecimal.NewFromInt(100), costBasis) - effectiveAt := charge.CreatedAt.Add(2 * time.Hour) + effectiveAt := purchasedAt.Add(2 * time.Hour) effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt} charge.Intent.ServicePeriod = effectivePeriod charge.Intent.FullServicePeriod = effectivePeriod @@ -155,10 +226,71 @@ func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T) require.NoError(t, err) require.NotEmpty(t, ref.TransactionGroupID) - for _, bookedAt := range env.transactionBookedAtTimes(t, ref.TransactionGroupID) { - requireLedgerBookedAtEqual(t, effectiveAt, bookedAt) - requireLedgerBookedAtNotEqual(t, charge.CreatedAt, bookedAt) + bookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, ref.TransactionGroupID) + for _, template := range []transactions.TransactionTemplate{ + transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{}, + transactions.TranslateCustomerAccruedCostBasisTemplate{}, + } { + bookedAt := bookedAtByTemplate[transactions.TemplateCode(template)] + require.Len(t, bookedAt, 1) + requireLedgerBookedAtEqual(t, purchasedAt, bookedAt[0]) } + + issuanceBookedAt := bookedAtByTemplate[transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{})] + require.Len(t, issuanceBookedAt, 1) + requireLedgerBookedAtEqual(t, effectiveAt, issuanceBookedAt[0]) + + // The backfilled amount is reflected now, but the future remainder is not spendable yet. + require.Equal(t, float64(40), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), purchasedAt).InexactFloat64()) + require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), purchasedAt).InexactFloat64()) + require.Equal(t, float64(60), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64()) +} + +func TestOnCreditPurchaseInitiated_SubsequentFuturePurchaseCannotOverAttributeAdvance(t *testing.T) { + env := newCreditPurchaseHandlerTestEnv(t) + + // given: + // - 100 of existing advance and two future-effective purchases of 60 each + // when: + // - both purchases are initiated now + // then: + // - the first attributes 60, the second attributes only the remaining 40, and 20 stays future issuance + purchasedAt := env.Now() + effectiveAt := purchasedAt.Add(2 * time.Hour) + effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt} + env.createAdvanceExposure(t, alpacadecimal.NewFromInt(100)) + + costBasis := mustDecimal(t, "0.5") + firstCharge := env.newExternalCharge(alpacadecimal.NewFromInt(60), costBasis) + firstCharge.ID = "01JABCDEF0123456789ABCDEFG" + firstCharge.Intent.ServicePeriod = effectivePeriod + firstCharge.Intent.FullServicePeriod = effectivePeriod + firstCharge.Intent.BillingPeriod = effectivePeriod + + secondCharge := env.newExternalCharge(alpacadecimal.NewFromInt(60), costBasis) + secondCharge.ID = "01JBCDEF0123456789ABCDEFGH" + secondCharge.Intent.ServicePeriod = effectivePeriod + secondCharge.Intent.FullServicePeriod = effectivePeriod + secondCharge.Intent.BillingPeriod = effectivePeriod + + firstRef, err := env.handler.OnCreditPurchaseInitiated(t.Context(), firstCharge) + require.NoError(t, err) + secondRef, err := env.handler.OnCreditPurchaseInitiated(t.Context(), secondCharge) + require.NoError(t, err) + + require.NotContains(t, env.transactionTemplateCodes(t, firstRef.TransactionGroupID), transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{})) + secondBookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, secondRef.TransactionGroupID) + secondAttributionBookedAt := secondBookedAtByTemplate[transactions.TemplateCode(transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{})] + require.Len(t, secondAttributionBookedAt, 1) + requireLedgerBookedAtEqual(t, purchasedAt, secondAttributionBookedAt[0]) + secondIssuanceBookedAt := secondBookedAtByTemplate[transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{})] + require.Len(t, secondIssuanceBookedAt, 1) + requireLedgerBookedAtEqual(t, effectiveAt, secondIssuanceBookedAt[0]) + + require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.unknownReceivableSubAccount(t), purchasedAt).InexactFloat64()) + require.Equal(t, float64(100), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), purchasedAt).InexactFloat64()) + require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), purchasedAt).InexactFloat64()) + require.Equal(t, float64(20), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64()) } func TestOnCreditPurchaseInitiated_SeparatesSourceChargeBuckets(t *testing.T) { @@ -876,6 +1008,32 @@ func (e *creditPurchaseHandlerTestEnv) transactionBookedAtTimes(t *testing.T, gr return out } +func (e *creditPurchaseHandlerTestEnv) transactionBookedAtByTemplateCode(t *testing.T, groupID string) map[string][]time.Time { + t.Helper() + + transactionRows, err := e.DB.LedgerTransaction.Query(). + Where( + ledgertransactiondb.Namespace(e.Namespace), + ledgertransactiondb.GroupID(groupID), + ). + Order( + ledgertransactiondb.ByCreatedAt(), + ledgertransactiondb.ByID(), + ). + All(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, transactionRows, "expected at least one ledger transaction for group") + + out := make(map[string][]time.Time, len(transactionRows)) + for _, transactionRow := range transactionRows { + code, err := ledger.TransactionTemplateCodeFromAnnotations(transactionRow.Annotations) + require.NoError(t, err) + out[code] = append(out[code], transactionRow.BookedAt) + } + + return out +} + func (e *creditPurchaseHandlerTestEnv) transactionTemplateCodes(t *testing.T, groupID string) []string { t.Helper()