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
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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,
Expand All @@ -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)
}

Expand Down Expand Up @@ -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()
Expand Down
21 changes: 15 additions & 6 deletions openmeter/ledger/chargeadapter/creditpurchase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Comment thread
mark-vass-konghq marked this conversation as resolved.

advanceAttributions, err := h.advanceAttributions(ctx, customerID, charge.Intent.Currency.GetCode(), charge.Intent.CreditAmount, featureFilters)
if err != nil {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -291,15 +300,15 @@ 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,
Features: featureFilters,
SourceChargeID: &charge.ID,
},
transactions.SettleCustomerReceivableFromPaymentTemplate{
At: bookedAt,
At: effectiveAt,
Comment thread
GAlexIHU marked this conversation as resolved.
Amount: charge.Intent.CreditAmount,
Currency: charge.Intent.Currency.GetCode(),
CostBasis: &costBasis,
Expand Down
168 changes: 163 additions & 5 deletions openmeter/ledger/chargeadapter/creditpurchase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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()

Expand Down
Loading