Skip to content

Commit 0e96582

Browse files
committed
fix(ledger): book advance attribution at purchase time
1 parent 59287dd commit 0e96582

3 files changed

Lines changed: 120 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
@@ -13,6 +13,7 @@ import (
1313
"github.com/openmeterio/openmeter/openmeter/billing/charges/lineage"
1414
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1515
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction"
16+
"github.com/openmeterio/openmeter/pkg/clock"
1617
"github.com/openmeterio/openmeter/pkg/currencyx"
1718
"github.com/openmeterio/openmeter/pkg/models"
1819
"github.com/openmeterio/openmeter/pkg/timeutil"
@@ -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 == charge.Intent.Currency &&
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: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/openmeterio/openmeter/openmeter/ledger"
1818
"github.com/openmeterio/openmeter/openmeter/ledger/breakage"
1919
"github.com/openmeterio/openmeter/openmeter/ledger/transactions"
20+
"github.com/openmeterio/openmeter/pkg/clock"
2021
"github.com/openmeterio/openmeter/pkg/cmpx"
2122
"github.com/openmeterio/openmeter/pkg/currencyx"
2223
"github.com/openmeterio/openmeter/pkg/framework/transaction"
@@ -214,7 +215,10 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
214215
}
215216
annotations := chargeAnnotationsForCreditPurchaseCharge(charge)
216217
featureFilters := charge.Intent.FeatureFilters.Normalize()
217-
bookedAt := charge.Intent.ServicePeriod.To
218+
// Advance attribution belongs to the purchase event so a later purchase cannot
219+
// reuse the same advance. Only newly issued credit waits for grant effectiveness.
220+
purchasedAt := clock.Now()
221+
effectiveAt := charge.Intent.ServicePeriod.To
218222

219223
advanceAttributions, err := h.advanceAttributions(ctx, customerID, charge.Intent.Currency, charge.Intent.CreditAmount, featureFilters)
220224
if err != nil {
@@ -235,7 +239,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
235239

236240
for _, attribution := range advanceAttributions {
237241
templates = append(templates, transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{
238-
At: bookedAt,
242+
At: purchasedAt,
239243
Amount: attribution.advanceAmount,
240244
Currency: charge.Intent.Currency,
241245
CostBasis: &costBasis,
@@ -247,7 +251,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
247251

248252
if attribution.accruedAmount.IsPositive() {
249253
templates = append(templates, transactions.TranslateCustomerAccruedCostBasisTemplate{
250-
At: bookedAt,
254+
At: purchasedAt,
251255
Amount: attribution.accruedAmount,
252256
Currency: charge.Intent.Currency,
253257
TaxCode: attribution.taxCode,
@@ -262,7 +266,7 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
262266

263267
if issuableAmount.IsPositive() {
264268
templates = append(templates, transactions.IssueCustomerReceivableTemplate{
265-
At: bookedAt,
269+
At: effectiveAt,
266270
Amount: issuableAmount,
267271
Currency: charge.Intent.Currency,
268272
CostBasis: &costBasis,
@@ -278,15 +282,15 @@ func (h *creditPurchaseHandler) issueCreditPurchaseGroup(ctx context.Context, ch
278282
// does not leave an unsettled receivable behind.
279283
templates = append(templates,
280284
transactions.AuthorizeCustomerReceivablePaymentTemplate{
281-
At: bookedAt,
285+
At: effectiveAt,
282286
Amount: charge.Intent.CreditAmount,
283287
Currency: charge.Intent.Currency,
284288
CostBasis: &costBasis,
285289
Features: featureFilters,
286290
SourceChargeID: &charge.ID,
287291
},
288292
transactions.SettleCustomerReceivableFromPaymentTemplate{
289-
At: bookedAt,
293+
At: effectiveAt,
290294
Amount: charge.Intent.CreditAmount,
291295
Currency: charge.Intent.Currency,
292296
CostBasis: &costBasis,

openmeter/ledger/chargeadapter/creditpurchase_test.go

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,21 @@ func TestOnCreditPurchaseInitiated(t *testing.T) {
138138
require.True(t, env.sumBalance(t, env.receivableSubAccount(t, costBasis)).Equal(alpacadecimal.NewFromInt(-100)))
139139
}
140140

141-
func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T) {
141+
func TestOnCreditPurchaseInitiated_FutureEffectiveGrantBackfillsAdvanceAtPurchaseTime(t *testing.T) {
142142
env := newCreditPurchaseHandlerTestEnv(t)
143143

144+
// given:
145+
// - existing advance and a credit purchase whose remainder becomes effective later
146+
// when:
147+
// - the materialized charge initiates the purchase now
148+
// then:
149+
// - advance attribution is booked now while only the remainder is issued later
150+
purchasedAt := env.Now()
151+
env.createAdvanceExposure(t, alpacadecimal.NewFromInt(40))
152+
144153
costBasis := mustDecimal(t, "0.5")
145154
charge := env.newExternalCharge(alpacadecimal.NewFromInt(100), costBasis)
146-
effectiveAt := charge.CreatedAt.Add(2 * time.Hour)
155+
effectiveAt := purchasedAt.Add(2 * time.Hour)
147156
effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt}
148157
charge.Intent.ServicePeriod = effectivePeriod
149158
charge.Intent.FullServicePeriod = effectivePeriod
@@ -153,10 +162,71 @@ func TestOnCreditPurchaseInitiated_UsesServicePeriodEndAsBookedAt(t *testing.T)
153162
require.NoError(t, err)
154163
require.NotEmpty(t, ref.TransactionGroupID)
155164

156-
for _, bookedAt := range env.transactionBookedAtTimes(t, ref.TransactionGroupID) {
157-
requireLedgerBookedAtEqual(t, effectiveAt, bookedAt)
158-
requireLedgerBookedAtNotEqual(t, charge.CreatedAt, bookedAt)
165+
bookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, ref.TransactionGroupID)
166+
for _, template := range []transactions.TransactionTemplate{
167+
transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{},
168+
transactions.TranslateCustomerAccruedCostBasisTemplate{},
169+
} {
170+
bookedAt := bookedAtByTemplate[transactions.TemplateCode(template)]
171+
require.Len(t, bookedAt, 1)
172+
requireLedgerBookedAtEqual(t, purchasedAt, bookedAt[0])
159173
}
174+
175+
issuanceBookedAt := bookedAtByTemplate[transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{})]
176+
require.Len(t, issuanceBookedAt, 1)
177+
requireLedgerBookedAtEqual(t, effectiveAt, issuanceBookedAt[0])
178+
179+
// The backfilled amount is reflected now, but the future remainder is not spendable yet.
180+
require.Equal(t, float64(40), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), purchasedAt).InexactFloat64())
181+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), purchasedAt).InexactFloat64())
182+
require.Equal(t, float64(60), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64())
183+
}
184+
185+
func TestOnCreditPurchaseInitiated_SubsequentFuturePurchaseCannotOverAttributeAdvance(t *testing.T) {
186+
env := newCreditPurchaseHandlerTestEnv(t)
187+
188+
// given:
189+
// - 100 of existing advance and two future-effective purchases of 60 each
190+
// when:
191+
// - both purchases are initiated now
192+
// then:
193+
// - the first attributes 60, the second attributes only the remaining 40, and 20 stays future issuance
194+
purchasedAt := env.Now()
195+
effectiveAt := purchasedAt.Add(2 * time.Hour)
196+
effectivePeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt}
197+
env.createAdvanceExposure(t, alpacadecimal.NewFromInt(100))
198+
199+
costBasis := mustDecimal(t, "0.5")
200+
firstCharge := env.newExternalCharge(alpacadecimal.NewFromInt(60), costBasis)
201+
firstCharge.ID = "01JABCDEF0123456789ABCDEFG"
202+
firstCharge.Intent.ServicePeriod = effectivePeriod
203+
firstCharge.Intent.FullServicePeriod = effectivePeriod
204+
firstCharge.Intent.BillingPeriod = effectivePeriod
205+
206+
secondCharge := env.newExternalCharge(alpacadecimal.NewFromInt(60), costBasis)
207+
secondCharge.ID = "01JBCDEF0123456789ABCDEFGH"
208+
secondCharge.Intent.ServicePeriod = effectivePeriod
209+
secondCharge.Intent.FullServicePeriod = effectivePeriod
210+
secondCharge.Intent.BillingPeriod = effectivePeriod
211+
212+
firstRef, err := env.handler.OnCreditPurchaseInitiated(t.Context(), firstCharge)
213+
require.NoError(t, err)
214+
secondRef, err := env.handler.OnCreditPurchaseInitiated(t.Context(), secondCharge)
215+
require.NoError(t, err)
216+
217+
require.NotContains(t, env.transactionTemplateCodes(t, firstRef.TransactionGroupID), transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{}))
218+
secondBookedAtByTemplate := env.transactionBookedAtByTemplateCode(t, secondRef.TransactionGroupID)
219+
secondAttributionBookedAt := secondBookedAtByTemplate[transactions.TemplateCode(transactions.AttributeCustomerAdvanceReceivableCostBasisTemplate{})]
220+
require.Len(t, secondAttributionBookedAt, 1)
221+
requireLedgerBookedAtEqual(t, purchasedAt, secondAttributionBookedAt[0])
222+
secondIssuanceBookedAt := secondBookedAtByTemplate[transactions.TemplateCode(transactions.IssueCustomerReceivableTemplate{})]
223+
require.Len(t, secondIssuanceBookedAt, 1)
224+
requireLedgerBookedAtEqual(t, effectiveAt, secondIssuanceBookedAt[0])
225+
226+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.unknownReceivableSubAccount(t), purchasedAt).InexactFloat64())
227+
require.Equal(t, float64(100), env.sumBalanceAsOf(t, env.accruedSubAccount(t, costBasis), purchasedAt).InexactFloat64())
228+
require.Equal(t, float64(0), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), purchasedAt).InexactFloat64())
229+
require.Equal(t, float64(20), env.sumBalanceAsOf(t, env.fboSubAccount(t, costBasis), effectiveAt).InexactFloat64())
160230
}
161231

162232
func TestOnCreditPurchaseInitiated_SeparatesSourceChargeBuckets(t *testing.T) {
@@ -872,6 +942,32 @@ func (e *creditPurchaseHandlerTestEnv) transactionBookedAtTimes(t *testing.T, gr
872942
return out
873943
}
874944

945+
func (e *creditPurchaseHandlerTestEnv) transactionBookedAtByTemplateCode(t *testing.T, groupID string) map[string][]time.Time {
946+
t.Helper()
947+
948+
transactionRows, err := e.DB.LedgerTransaction.Query().
949+
Where(
950+
ledgertransactiondb.Namespace(e.Namespace),
951+
ledgertransactiondb.GroupID(groupID),
952+
).
953+
Order(
954+
ledgertransactiondb.ByCreatedAt(),
955+
ledgertransactiondb.ByID(),
956+
).
957+
All(t.Context())
958+
require.NoError(t, err)
959+
require.NotEmpty(t, transactionRows, "expected at least one ledger transaction for group")
960+
961+
out := make(map[string][]time.Time, len(transactionRows))
962+
for _, transactionRow := range transactionRows {
963+
code, err := ledger.TransactionTemplateCodeFromAnnotations(transactionRow.Annotations)
964+
require.NoError(t, err)
965+
out[code] = append(out[code], transactionRow.BookedAt)
966+
}
967+
968+
return out
969+
}
970+
875971
func (e *creditPurchaseHandlerTestEnv) transactionTemplateCodes(t *testing.T, groupID string) []string {
876972
t.Helper()
877973

0 commit comments

Comments
 (0)