Skip to content

Commit 5f4e6a8

Browse files
committed
fix(ledger): book advance attribution at purchase time
1 parent 8cdf8ad commit 5f4e6a8

3 files changed

Lines changed: 187 additions & 14 deletions

File tree

openmeter/billing/charges/creditpurchase/service/promotional_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1515
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction"
1616
currenciestestutils "github.com/openmeterio/openmeter/openmeter/currencies/testutils/currency"
17+
"github.com/openmeterio/openmeter/pkg/clock"
1718
"github.com/openmeterio/openmeter/pkg/models"
1819
"github.com/openmeterio/openmeter/pkg/timeutil"
1920
)
@@ -25,6 +26,10 @@ func TestPromotionalCreditPurchaseStateMachineAdvancesCreatedChargeToFinal(t *te
2526
// - the promotional state machine advances until stable
2627
// then:
2728
// - it grants the promotional credits, backfills lineage, and persists the final status
29+
purchasedAt := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC)
30+
clock.FreezeTime(purchasedAt)
31+
defer clock.UnFreeze()
32+
2833
stateMachine, charge, adapter, lineageService := newPromotionalStateMachineTestMachine(
2934
t,
3035
creditpurchase.StatusCreated,
@@ -37,11 +42,12 @@ func TestPromotionalCreditPurchaseStateMachineAdvancesCreatedChargeToFinal(t *te
3742
require.Equal(t, creditpurchase.StatusFinal, advancedCharge.Status)
3843
require.Equal(t, creditpurchase.StatusFinal, adapter.updatedBase.Status)
3944
require.NotNil(t, advancedCharge.Realizations.CreditGrantRealization)
40-
require.NotEmpty(t, advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID)
45+
require.Equal(t, "ledger-tx-1", advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID)
46+
require.Equal(t, purchasedAt, advancedCharge.Realizations.CreditGrantRealization.Time)
4147
require.Equal(t, 1, adapter.createCreditGrantCalls)
4248
require.Equal(t, charge.GetChargeID(), adapter.createdGrantChargeID)
4349
require.Equal(t, advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID, adapter.createdGrantInput.TransactionGroupID)
44-
require.False(t, adapter.createdGrantInput.GrantedAt.IsZero())
50+
require.Equal(t, purchasedAt, adapter.createdGrantInput.GrantedAt)
4551
lineageService.AssertExpectations(t)
4652
}
4753

@@ -217,7 +223,7 @@ func newPromotionalStateMachineTestMachine(
217223
input.CustomerID == charge.Intent.CustomerID &&
218224
input.Currency.GetCode() == charge.Intent.Currency.GetCode() &&
219225
input.Amount.Equal(charge.Intent.CreditAmount) &&
220-
input.BackingTransactionGroupID != ""
226+
input.BackingTransactionGroupID == "ledger-tx-1"
221227
})).
222228
Return(nil).
223229
Once()

openmeter/ledger/chargeadapter/creditpurchase.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/openmeterio/openmeter/openmeter/ledger"
1919
"github.com/openmeterio/openmeter/openmeter/ledger/breakage"
2020
"github.com/openmeterio/openmeter/openmeter/ledger/transactions"
21+
"github.com/openmeterio/openmeter/pkg/clock"
2122
"github.com/openmeterio/openmeter/pkg/cmpx"
2223
"github.com/openmeterio/openmeter/pkg/currencyx"
2324
"github.com/openmeterio/openmeter/pkg/framework/transaction"
@@ -227,7 +228,15 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
227228
}
228229
annotations := chargeAnnotationsForCreditPurchaseCharge(charge)
229230
featureFilters := charge.Intent.FeatureFilters.Normalize()
230-
bookedAt := charge.Intent.ServicePeriod.To
231+
effectiveAt := charge.Intent.ServicePeriod.To
232+
// LedgerTransaction.CreatedAt retains recording time. For effective time,
233+
// future-effective purchases book attribution immediately so subsequent
234+
// committed purchases observe reduced advance; already-effective purchases
235+
// backdate attribution alongside issuance and settlement.
236+
advanceAttributionEffectiveAt := clock.Now()
237+
if effectiveAt.Before(advanceAttributionEffectiveAt) {
238+
advanceAttributionEffectiveAt = effectiveAt
239+
}
231240

232241
advanceAttributions, err := h.advanceAttributions(ctx, customerID, charge.Intent.Currency.GetCode(), charge.Intent.CreditAmount, featureFilters)
233242
if err != nil {
@@ -248,7 +257,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
248257

249258
for _, attribution := range advanceAttributions {
250259
templates = append(templates, transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{
251-
At: bookedAt,
260+
At: advanceAttributionEffectiveAt,
252261
Amount: attribution.advanceAmount,
253262
Currency: charge.Intent.Currency.GetCode(),
254263
CostBasis: &costBasis,
@@ -260,7 +269,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
260269

261270
if attribution.accruedAmount.IsPositive() {
262271
templates = append(templates, transactions.TranslateCustomerAccruedCostBasisTemplate{
263-
At: bookedAt,
272+
At: advanceAttributionEffectiveAt,
264273
Amount: attribution.accruedAmount,
265274
Currency: charge.Intent.Currency.GetCode(),
266275
TaxCode: attribution.taxCode,
@@ -275,7 +284,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
275284

276285
if issuableAmount.IsPositive() {
277286
templates = append(templates, transactions.IssueCustomerReceivableTemplate{
278-
At: bookedAt,
287+
At: effectiveAt,
279288
Amount: issuableAmount,
280289
Currency: charge.Intent.Currency.GetCode(),
281290
CostBasis: &costBasis,
@@ -291,15 +300,15 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
291300
// does not leave an unsettled receivable behind.
292301
templates = append(templates,
293302
transactions.AuthorizeCustomerReceivablePaymentTemplate{
294-
At: bookedAt,
303+
At: effectiveAt,
295304
Amount: charge.Intent.CreditAmount,
296305
Currency: charge.Intent.Currency.GetCode(),
297306
CostBasis: &costBasis,
298307
Features: featureFilters,
299308
SourceChargeID: &charge.ID,
300309
},
301310
transactions.SettleCustomerReceivableFromPaymentTemplate{
302-
At: bookedAt,
311+
At: effectiveAt,
303312
Amount: charge.Intent.CreditAmount,
304313
Currency: charge.Intent.Currency.GetCode(),
305314
CostBasis: &costBasis,

openmeter/ledger/chargeadapter/creditpurchase_test.go

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,68 @@ func TestOnPromotionalCreditPurchase_BacksAdvanceBeforeTopUp(t *testing.T) {
7575
require.True(t, env.sumBalance(t, env.washSubAccount(t, alpacadecimal.Zero)).Equal(alpacadecimal.NewFromInt(-100)))
7676
}
7777

78+
func TestOnCreditPurchaseInitiated_PastEffectiveGrantBackdatesAdvanceAttribution(t *testing.T) {
79+
env := newCreditPurchaseHandlerTestEnv(t)
80+
81+
// given:
82+
// - advance exposure that predates a credit purchase's effective time
83+
// - the purchase is materialized after that effective time
84+
// when:
85+
// - the purchase is initiated
86+
// then:
87+
// - recording time stays current while every purchase posting is effective in the past
88+
recordedAt := env.Now()
89+
effectiveAt := recordedAt.Add(-2 * time.Hour)
90+
func() {
91+
clock.FreezeTime(effectiveAt.Add(-time.Hour))
92+
defer clock.UnFreeze()
93+
env.createAdvanceExposure(t, alpacadecimal.NewFromInt(40))
94+
}()
95+
clock.FreezeTime(recordedAt)
96+
defer clock.UnFreeze()
97+
98+
costBasis := mustDecimal(t, "0.5")
99+
charge := env.newExternalCharge(alpacadecimal.NewFromInt(100), costBasis)
100+
effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt}
101+
charge.Intent.ServicePeriod = effectivePeriod
102+
charge.Intent.FullServicePeriod = effectivePeriod
103+
charge.Intent.BillingPeriod = effectivePeriod
104+
105+
ref, err := env.handler.OnCreditPurchaseInitiated(t.Context(), charge)
106+
require.NoError(t, err)
107+
require.NotEmpty(t, ref.TransactionGroupID)
108+
109+
bookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, ref.TransactionGroupID)
110+
for _, template := range []transactions.TransactionTemplate{
111+
transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{},
112+
transactions.TranslateCustomerAccruedCostBasisTemplate{},
113+
transactions.IssueCustomerReceivableTemplate{},
114+
} {
115+
bookedAt := bookedAtByTemplate[transactions.TemplateCode(template)]
116+
require.Len(t, bookedAt, 1)
117+
requireLedgerBookedAtEqual(t, effectiveAt, bookedAt[0])
118+
}
119+
120+
transactionRows, err := env.DB.LedgerTransaction.Query().
121+
Where(
122+
ledgertransactiondb.Namespace(env.Namespace),
123+
ledgertransactiondb.GroupID(ref.TransactionGroupID),
124+
).
125+
All(t.Context())
126+
require.NoError(t, err)
127+
require.NotEmpty(t, transactionRows)
128+
for _, transactionRow := range transactionRows {
129+
require.False(t, transactionRow.CreatedAt.Before(recordedAt))
130+
}
131+
132+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.unknownReceivableSubAccount(t), effectiveAt).InexactFloat64())
133+
require.Equal(t, float64(-100), env.sumBalanceAsOf(t, env.receivableSubAccount(t, costBasis), effectiveAt).InexactFloat64())
134+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.authorizedReceivableSubAccount(t, costBasis), effectiveAt).InexactFloat64())
135+
require.Equal(t, float64(40), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), effectiveAt).InexactFloat64())
136+
require.Equal(t, float64(60), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64())
137+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.washSubAccount(t, costBasis), effectiveAt).InexactFloat64())
138+
}
139+
78140
func TestOnCreditPurchaseInitiated_BackfillsOnlyMatchingFeatureAdvances(t *testing.T) {
79141
env := newCreditPurchaseHandlerTestEnv(t)
80142
env.createAdvanceExposureWithFeatures(t, alpacadecimal.NewFromInt(40), []string{"api-calls"})
@@ -140,12 +202,21 @@ func TestOnCreditPurchaseInitiated(t *testing.T) {
140202
require.True(t, env.sumBalance(t, env.receivableSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(-100)))
141203
}
142204

143-
func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T) {
205+
func TestOnCreditPurchaseInitiated_FutureEffectiveGrantBackfillsAdvanceAtPurchaseTime(t *testing.T) {
144206
env := newCreditPurchaseHandlerTestEnv(t)
145207

208+
// given:
209+
// - existing advance and a credit purchase whose remainder becomes effective later
210+
// when:
211+
// - the materialized charge initiates the purchase now
212+
// then:
213+
// - advance attribution is booked now while only the remainder is issued later
214+
purchasedAt := env.Now()
215+
env.createAdvanceExposure(t, alpacadecimal.NewFromInt(40))
216+
146217
costBasis := mustDecimal(t, "0.5")
147218
charge := env.newExternalCharge(alpacadecimal.NewFromInt(100), costBasis)
148-
effectiveAt := charge.CreatedAt.Add(2 * time.Hour)
219+
effectiveAt := purchasedAt.Add(2 * time.Hour)
149220
effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt}
150221
charge.Intent.ServicePeriod = effectivePeriod
151222
charge.Intent.FullServicePeriod = effectivePeriod
@@ -155,10 +226,71 @@ func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T)
155226
require.NoError(t, err)
156227
require.NotEmpty(t, ref.TransactionGroupID)
157228

158-
for _, bookedAt := range env.transactionBookedAtTimes(t, ref.TransactionGroupID) {
159-
requireLedgerBookedAtEqual(t, effectiveAt, bookedAt)
160-
requireLedgerBookedAtNotEqual(t, charge.CreatedAt, bookedAt)
229+
bookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, ref.TransactionGroupID)
230+
for _, template := range []transactions.TransactionTemplate{
231+
transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{},
232+
transactions.TranslateCustomerAccruedCostBasisTemplate{},
233+
} {
234+
bookedAt := bookedAtByTemplate[transactions.TemplateCode(template)]
235+
require.Len(t, bookedAt, 1)
236+
requireLedgerBookedAtEqual(t, purchasedAt, bookedAt[0])
161237
}
238+
239+
issuanceBookedAt := bookedAtByTemplate[transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{})]
240+
require.Len(t, issuanceBookedAt, 1)
241+
requireLedgerBookedAtEqual(t, effectiveAt, issuanceBookedAt[0])
242+
243+
// The backfilled amount is reflected now, but the future remainder is not spendable yet.
244+
require.Equal(t, float64(40), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), purchasedAt).InexactFloat64())
245+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), purchasedAt).InexactFloat64())
246+
require.Equal(t, float64(60), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64())
247+
}
248+
249+
func TestOnCreditPurchaseInitiated_SubsequentFuturePurchaseCannotOverAttributeAdvance(t *testing.T) {
250+
env := newCreditPurchaseHandlerTestEnv(t)
251+
252+
// given:
253+
// - 100 of existing advance and two future-effective purchases of 60 each
254+
// when:
255+
// - both purchases are initiated now
256+
// then:
257+
// - the first attributes 60, the second attributes only the remaining 40, and 20 stays future issuance
258+
purchasedAt := env.Now()
259+
effectiveAt := purchasedAt.Add(2 * time.Hour)
260+
effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt}
261+
env.createAdvanceExposure(t, alpacadecimal.NewFromInt(100))
262+
263+
costBasis := mustDecimal(t, "0.5")
264+
firstCharge := env.newExternalCharge(alpacadecimal.NewFromInt(60), costBasis)
265+
firstCharge.ID = "01JABCDEF0123456789ABCDEFG"
266+
firstCharge.Intent.ServicePeriod = effectivePeriod
267+
firstCharge.Intent.FullServicePeriod = effectivePeriod
268+
firstCharge.Intent.BillingPeriod = effectivePeriod
269+
270+
secondCharge := env.newExternalCharge(alpacadecimal.NewFromInt(60), costBasis)
271+
secondCharge.ID = "01JBCDEF0123456789ABCDEFGH"
272+
secondCharge.Intent.ServicePeriod = effectivePeriod
273+
secondCharge.Intent.FullServicePeriod = effectivePeriod
274+
secondCharge.Intent.BillingPeriod = effectivePeriod
275+
276+
firstRef, err := env.handler.OnCreditPurchaseInitiated(t.Context(), firstCharge)
277+
require.NoError(t, err)
278+
secondRef, err := env.handler.OnCreditPurchaseInitiated(t.Context(), secondCharge)
279+
require.NoError(t, err)
280+
281+
require.NotContains(t, env.transactionTemplateCodes(t, firstRef.TransactionGroupID), transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{}))
282+
secondBookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, secondRef.TransactionGroupID)
283+
secondAttributionBookedAt := secondBookedAtByTemplate[transactions.TemplateCode(transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{})]
284+
require.Len(t, secondAttributionBookedAt, 1)
285+
requireLedgerBookedAtEqual(t, purchasedAt, secondAttributionBookedAt[0])
286+
secondIssuanceBookedAt := secondBookedAtByTemplate[transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{})]
287+
require.Len(t, secondIssuanceBookedAt, 1)
288+
requireLedgerBookedAtEqual(t, effectiveAt, secondIssuanceBookedAt[0])
289+
290+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.unknownReceivableSubAccount(t), purchasedAt).InexactFloat64())
291+
require.Equal(t, float64(100), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), purchasedAt).InexactFloat64())
292+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), purchasedAt).InexactFloat64())
293+
require.Equal(t, float64(20), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64())
162294
}
163295

164296
func TestOnCreditPurchaseInitiated_SeparatesSourceChargeBuckets(t *testing.T) {
@@ -876,6 +1008,32 @@ func (e *creditPurchaseHandlerTestEnv) transactionBookedAtTimes(t *testing.T, gr
8761008
return out
8771009
}
8781010

1011+
func (e *creditPurchaseHandlerTestEnv) transactionBookedAtByTemplateCode(t *testing.T, groupID string) map[string][]time.Time {
1012+
t.Helper()
1013+
1014+
transactionRows, err := e.DB.LedgerTransaction.Query().
1015+
Where(
1016+
ledgertransactiondb.Namespace(e.Namespace),
1017+
ledgertransactiondb.GroupID(groupID),
1018+
).
1019+
Order(
1020+
ledgertransactiondb.ByCreatedAt(),
1021+
ledgertransactiondb.ByID(),
1022+
).
1023+
All(t.Context())
1024+
require.NoError(t, err)
1025+
require.NotEmpty(t, transactionRows, "expected at least one ledger transaction for group")
1026+
1027+
out := make(map[string][]time.Time, len(transactionRows))
1028+
for _, transactionRow := range transactionRows {
1029+
code, err := ledger.TransactionTemplateCodeFromAnnotations(transactionRow.Annotations)
1030+
require.NoError(t, err)
1031+
out[code] = append(out[code], transactionRow.BookedAt)
1032+
}
1033+
1034+
return out
1035+
}
1036+
8791037
func (e *creditPurchaseHandlerTestEnv) transactionTemplateCodes(t *testing.T, groupID string) []string {
8801038
t.Helper()
8811039

0 commit comments

Comments
 (0)