diff --git a/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go b/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go index 8f422e98f6..c4a9179832 100644 --- a/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go +++ b/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go @@ -5079,6 +5079,8 @@ func (s *CreditThenInvoiceTestSuite) TestStandardInvoiceManualEditSync() { Total: 3, }) + s.Require().Len(chargeBeforeEdit.Realizations.CurrentRun.CreditRealizations, 1) + updatedIntent := expectedFlatFeeIntent{ ServicePeriod: timeutil.ClosedPeriod{ From: originalLine.Period.From.Add(time.Hour), @@ -5165,6 +5167,10 @@ func (s *CreditThenInvoiceTestSuite) TestStandardInvoiceManualEditSync() { s.Equal(editedInvoice.ID, *chargeAfterEdit.Realizations.CurrentRun.InvoiceID) s.Equal(updatedIntent.ServicePeriod, chargeAfterEdit.Realizations.CurrentRun.ServicePeriod) s.Equal(updatedIntent.Amount, chargeAfterEdit.Realizations.CurrentRun.AmountAfterProration.InexactFloat64()) + // The edit moves booking before the original collection. Those two credits + // are already allocated, so the rerate must not collect them a second time. + s.Equal(chargeBeforeEdit.Realizations.CurrentRun.CreditRealizations, chargeAfterEdit.Realizations.CurrentRun.CreditRealizations) + s.assertTotals(chargeAfterEdit.Realizations.CurrentRun.Totals, expectedTotalsInput{ Amount: updatedIntent.Amount, CreditsTotal: 2, diff --git a/openmeter/ledger/chargeadapter/flatfee.go b/openmeter/ledger/chargeadapter/flatfee.go index b24ccbeadc..dff5760b18 100644 --- a/openmeter/ledger/chargeadapter/flatfee.go +++ b/openmeter/ledger/chargeadapter/flatfee.go @@ -65,19 +65,18 @@ func (h *flatFeeHandler) OnAllocateCredits(ctx context.Context, input flatfee.On } realizations, err := h.collector.CollectToAccrued(ctx, collector.CollectToAccruedInput{ - Namespace: input.Charge.Namespace, - ChargeID: input.Charge.ID, - CustomerID: intent.GetCustomerID(), - Annotations: chargeAnnotationsForFlatFeeCharge(input.Charge), - BookedAt: input.BookedAt, - SourceBalanceAsOf: intent.GetEffectiveInvoiceAt(), - Currency: intent.GetCurrency().Reference(), - TaxCode: lo.ToPtr(taxConfig.TaxCodeID), - TaxBehavior: (*ledger.TaxBehavior)(taxConfig.Behavior), - SettlementMode: intent.GetSettlementMode(), - ServicePeriod: input.ServicePeriod, - Filters: intent.GetCreditFilters(), - Amount: input.PreTaxAmountToAllocate, + Namespace: input.Charge.Namespace, + ChargeID: input.Charge.ID, + CustomerID: intent.GetCustomerID(), + Annotations: chargeAnnotationsForFlatFeeCharge(input.Charge), + BookedAt: input.BookedAt, + Currency: intent.GetCurrency().Reference(), + TaxCode: lo.ToPtr(taxConfig.TaxCodeID), + TaxBehavior: (*ledger.TaxBehavior)(taxConfig.Behavior), + SettlementMode: intent.GetSettlementMode(), + ServicePeriod: input.ServicePeriod, + Filters: intent.GetCreditFilters(), + Amount: input.PreTaxAmountToAllocate, }) if err != nil { return nil, err @@ -237,16 +236,15 @@ func (h *flatFeeHandler) OnAllocateFiatOverageCredits(ctx context.Context, input intent := input.Charge.Intent return h.collector.CollectToReceivable(ctx, collector.CollectToReceivableInput{ - Namespace: input.Charge.Namespace, - ChargeID: input.Charge.ID, - CustomerID: intent.GetCustomerID(), - Annotations: chargeAnnotationsForFlatFeeCharge(input.Charge), - BookedAt: input.BookedAt, - SourceBalanceAsOf: input.BookedAt, - Currency: currencies.NewCurrencyReference(currencyx.Code(fiatCurrency.GetFiatCode())), - Filters: intent.GetCreditFilters(), - ServicePeriod: input.Run.ServicePeriod, - Amount: input.AmountToAllocate, + Namespace: input.Charge.Namespace, + ChargeID: input.Charge.ID, + CustomerID: intent.GetCustomerID(), + Annotations: chargeAnnotationsForFlatFeeCharge(input.Charge), + BookedAt: input.BookedAt, + Currency: currencies.NewCurrencyReference(currencyx.Code(fiatCurrency.GetFiatCode())), + Filters: intent.GetCreditFilters(), + ServicePeriod: input.Run.ServicePeriod, + Amount: input.AmountToAllocate, }) } diff --git a/openmeter/ledger/chargeadapter/flatfee_test.go b/openmeter/ledger/chargeadapter/flatfee_test.go index 5586d4a589..512bae8e67 100644 --- a/openmeter/ledger/chargeadapter/flatfee_test.go +++ b/openmeter/ledger/chargeadapter/flatfee_test.go @@ -780,8 +780,8 @@ func (e *flatFeeHandlerTestEnv) newAllocateCreditsInputForCharge(charge chargefl func (e *flatFeeHandlerTestEnv) newAssignmentInputWithMode(amount alpacadecimal.Decimal, mode productcatalog.SettlementMode) chargeflatfee.OnAllocateCreditsInput { now := e.Now() servicePeriod := timeutil.ClosedPeriod{ - From: now.Add(-time.Hour), - To: now, + From: now, + To: now.Add(time.Hour), } return chargeflatfee.OnAllocateCreditsInput{ @@ -813,7 +813,7 @@ func (e *flatFeeHandlerTestEnv) newAssignmentInputWithMode(amount alpacadecimal. FullServicePeriod: servicePeriod, BillingPeriod: servicePeriod, }, - InvoiceAt: now, + InvoiceAt: now.Add(-time.Hour), PaymentTerm: productcatalog.InAdvancePaymentTerm, ProRating: productcatalog.ProRatingConfig{}, AmountBeforeProration: amount, @@ -932,8 +932,8 @@ func (e *flatFeeHandlerTestEnv) newAccrualInput(total alpacadecimal.Decimal) cha func (e *flatFeeHandlerTestEnv) newCreditsOnlyCharge(amount alpacadecimal.Decimal) chargeflatfee.Charge { now := e.Now() servicePeriod := timeutil.ClosedPeriod{ - From: now.Add(-time.Hour), - To: now, + From: now, + To: now.Add(time.Hour), } charge := e.newBaseCharge(servicePeriod, amount) diff --git a/openmeter/ledger/chargeadapter/origin_test.go b/openmeter/ledger/chargeadapter/origin_test.go index b645622b31..e87c451e5e 100644 --- a/openmeter/ledger/chargeadapter/origin_test.go +++ b/openmeter/ledger/chargeadapter/origin_test.go @@ -124,13 +124,12 @@ func (e *originTestEnv) collect(t *testing.T, spend string, amount int64) credit t.Helper() allocations, err := e.collector.CollectToAccrued(t.Context(), collector.CollectToAccruedInput{ - Namespace: e.Namespace, - CustomerID: e.CustomerID.ID, - ChargeID: spend, - BookedAt: e.Now(), - SourceBalanceAsOf: e.Now(), - Currency: e.currency.Reference(), - SettlementMode: productcatalog.CreditOnlySettlementMode, + Namespace: e.Namespace, + CustomerID: e.CustomerID.ID, + ChargeID: spend, + BookedAt: e.Now(), + Currency: e.currency.Reference(), + SettlementMode: productcatalog.CreditOnlySettlementMode, ServicePeriod: timeutil.ClosedPeriod{ From: e.Now().Add(-time.Hour), To: e.Now(), diff --git a/openmeter/ledger/chargeadapter/usagebased.go b/openmeter/ledger/chargeadapter/usagebased.go index d159d9c29f..bf28b2a137 100644 --- a/openmeter/ledger/chargeadapter/usagebased.go +++ b/openmeter/ledger/chargeadapter/usagebased.go @@ -249,16 +249,15 @@ func (h *usageBasedHandler) OnAllocateFiatOverageCredits(ctx context.Context, in intent := input.Charge.Intent return h.collector.CollectToReceivable(ctx, collector.CollectToReceivableInput{ - Namespace: input.Charge.Namespace, - ChargeID: input.Charge.ID, - CustomerID: intent.GetCustomerID(), - Annotations: chargeAnnotationsForUsageBasedCharge(input.Charge), - BookedAt: input.BookedAt, - SourceBalanceAsOf: input.BookedAt, - Currency: currencies.NewCurrencyReference(currencyx.Code(fiatCurrency.GetFiatCode())), - Filters: intent.GetCreditFilters(), - ServicePeriod: intent.GetEffectiveServicePeriod(), - Amount: input.AmountToAllocate, + Namespace: input.Charge.Namespace, + ChargeID: input.Charge.ID, + CustomerID: intent.GetCustomerID(), + Annotations: chargeAnnotationsForUsageBasedCharge(input.Charge), + BookedAt: input.BookedAt, + Currency: currencies.NewCurrencyReference(currencyx.Code(fiatCurrency.GetFiatCode())), + Filters: intent.GetCreditFilters(), + ServicePeriod: intent.GetEffectiveServicePeriod(), + Amount: input.AmountToAllocate, }) } @@ -369,19 +368,18 @@ func (h *usageBasedHandler) OnCreditsOnlyUsageAccrued(ctx context.Context, input } realizations, err := h.collector.CollectToAccrued(ctx, collector.CollectToAccruedInput{ - Namespace: input.Charge.Namespace, - ChargeID: input.Charge.ID, - CustomerID: intent.GetCustomerID(), - Annotations: chargeAnnotationsForUsageBasedCharge(input.Charge), - BookedAt: input.BookedAt, - SourceBalanceAsOf: input.BookedAt, - Currency: intent.GetCurrency().Reference(), - Filters: intent.GetCreditFilters(), - TaxCode: lo.ToPtr(taxConfig.TaxCodeID), - TaxBehavior: (*ledger.TaxBehavior)(taxConfig.Behavior), - SettlementMode: intent.GetSettlementMode(), - ServicePeriod: intent.GetEffectiveServicePeriod(), - Amount: input.AmountToAllocate, + Namespace: input.Charge.Namespace, + ChargeID: input.Charge.ID, + CustomerID: intent.GetCustomerID(), + Annotations: chargeAnnotationsForUsageBasedCharge(input.Charge), + BookedAt: input.BookedAt, + Currency: intent.GetCurrency().Reference(), + Filters: intent.GetCreditFilters(), + TaxCode: lo.ToPtr(taxConfig.TaxCodeID), + TaxBehavior: (*ledger.TaxBehavior)(taxConfig.Behavior), + SettlementMode: intent.GetSettlementMode(), + ServicePeriod: intent.GetEffectiveServicePeriod(), + Amount: input.AmountToAllocate, }) if err != nil { return nil, err diff --git a/openmeter/ledger/collector/README.md b/openmeter/ledger/collector/README.md index 110917dd46..193cb3e669 100644 --- a/openmeter/ledger/collector/README.md +++ b/openmeter/ledger/collector/README.md @@ -2,7 +2,7 @@ This package turns selected customer FBO credit into accrued value and, for custom-currency `credit_then_invoice` overage, fiat receivable coverage. -The [FBO plan](../fbo/README.md) owns source selection, consumption, expiry +The [FBO service](../fbo/README.md) owns source selection, consumption, expiry release, and credit-only advance for an uncovered amount. The collector commits the complete group and maps each consumption's transactions to billing allocations. Source order is preserved so correction and breakage can undo the @@ -11,25 +11,15 @@ same collected amounts. ## Vocab - `BookedAt`: timestamp used for the ledger transactions being written. -- `SourceBalanceAsOf`: timestamp used to decide which FBO sources are available. - `source`: one eligible FBO slice selected by the FBO service. - `source entry`: the concrete negative FBO ledger entry created by collection. - `allocation`: billing's collapsed record of collected credit. - `advance`: value moved through FBO/accrued before real credit exists to cover it. -`BookedAt` and `SourceBalanceAsOf` are intentionally separate. - -Example: - -```text -charge allocates at T1 -source balance is checked at T5 - -BookedAt = T1 -SourceBalanceAsOf = T5 -``` - -The transaction is booked at `T1`, but source selection can see credit and expiry state visible as of `T5`. +Credit eligibility and consumption use the same `BookedAt`. Invoice scheduling +and the time collection runs do not change which credits can fund that posting. +Spendable balances also preserve credit already allocated to later postings, so +backdating collection cannot spend it again. ## Collection Order diff --git a/openmeter/ledger/collector/collect.go b/openmeter/ledger/collector/collect.go index 3811182645..b97fa4aefa 100644 --- a/openmeter/ledger/collector/collect.go +++ b/openmeter/ledger/collector/collect.go @@ -48,18 +48,17 @@ func (c *accrualCollector) collectToAccrued(ctx context.Context, input CollectTo } inputs, err := c.fbo.Consume(ctx, fbo.ConsumeInput{ - ChargeID: input.ChargeID, - CustomerID: customer.CustomerID{Namespace: input.Namespace, ID: input.CustomerID}, - PostingInput: breakage.PostingInput{TransactionGroupID: groupID, Annotations: annotations}, - BookedAt: input.BookedAt, - SourceBalanceAsOf: input.SourceBalanceAsOf, - Currency: input.Currency, - Filters: input.Filters, - Amount: input.Amount, - Destination: fbo.ConsumeDestinationAccrued, - AdvancePolicy: advancePolicy, - TaxCode: input.TaxCode, - TaxBehavior: input.TaxBehavior, + ChargeID: input.ChargeID, + CustomerID: customer.CustomerID{Namespace: input.Namespace, ID: input.CustomerID}, + PostingInput: breakage.PostingInput{TransactionGroupID: groupID, Annotations: annotations}, + BookedAt: input.BookedAt, + Currency: input.Currency, + Filters: input.Filters, + Amount: input.Amount, + Destination: fbo.ConsumeDestinationAccrued, + AdvancePolicy: advancePolicy, + TaxCode: input.TaxCode, + TaxBehavior: input.TaxBehavior, }) if err != nil { return nil, err @@ -93,16 +92,15 @@ func (c *accrualCollector) collectToReceivable(ctx context.Context, input Collec groupID := ulid.Make().String() inputs, err := c.fbo.Consume(ctx, fbo.ConsumeInput{ - ChargeID: input.ChargeID, - CustomerID: customer.CustomerID{Namespace: input.Namespace, ID: input.CustomerID}, - PostingInput: breakage.PostingInput{TransactionGroupID: groupID, Annotations: annotations}, - BookedAt: input.BookedAt, - SourceBalanceAsOf: input.SourceBalanceAsOf, - Currency: input.Currency, - Filters: input.Filters, - Amount: input.Amount, - Destination: fbo.ConsumeDestinationReceivable, - AdvancePolicy: fbo.AdvancePolicyNever, + ChargeID: input.ChargeID, + CustomerID: customer.CustomerID{Namespace: input.Namespace, ID: input.CustomerID}, + PostingInput: breakage.PostingInput{TransactionGroupID: groupID, Annotations: annotations}, + BookedAt: input.BookedAt, + Currency: input.Currency, + Filters: input.Filters, + Amount: input.Amount, + Destination: fbo.ConsumeDestinationReceivable, + AdvancePolicy: fbo.AdvancePolicyNever, }) if err != nil { return nil, err diff --git a/openmeter/ledger/collector/collection_fbo_test.go b/openmeter/ledger/collector/collection_fbo_test.go index aec900e36d..866f9b2952 100644 --- a/openmeter/ledger/collector/collection_fbo_test.go +++ b/openmeter/ledger/collector/collection_fbo_test.go @@ -239,15 +239,14 @@ func TestCollectCustomerFBOReleasesBreakageInExpiryOrder(t *testing.T) { To: env.Now(), } allocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: testChargeID(1), - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: env.CurrencyReference(), - SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, - ServicePeriod: servicePeriod, - Amount: alpacadecimal.NewFromInt(15), + Namespace: env.Namespace, + ChargeID: testChargeID(1), + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: env.CurrencyReference(), + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + ServicePeriod: servicePeriod, + Amount: alpacadecimal.NewFromInt(15), }) require.NoError(t, err) require.Len(t, allocations, 1) @@ -752,13 +751,12 @@ func collectToAccruedInputForTest( settlementMode productcatalog.SettlementMode, ) CollectToAccruedInput { return CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: chargeID, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: env.CurrencyReference(), - SettlementMode: settlementMode, + Namespace: env.Namespace, + ChargeID: chargeID, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: env.CurrencyReference(), + SettlementMode: settlementMode, ServicePeriod: timeutil.ClosedPeriod{ From: env.Now().Add(-time.Hour), To: env.Now(), diff --git a/openmeter/ledger/collector/correct_test.go b/openmeter/ledger/collector/correct_test.go index 0834f69bf6..9bc67d867d 100644 --- a/openmeter/ledger/collector/correct_test.go +++ b/openmeter/ledger/collector/correct_test.go @@ -40,14 +40,13 @@ func TestCollectToReceivableAndCorrectPreservesChargeProvenance(t *testing.T) { // when: part of it covers the custom overage charge's receivable allocations, err := collector.collectToReceivable(t.Context(), CollectToReceivableInput{ - Namespace: env.Namespace, - ChargeID: spendChargeID, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: env.CurrencyReference(), - ServicePeriod: testServicePeriod(env), - Amount: alpacadecimal.NewFromInt(20), + Namespace: env.Namespace, + ChargeID: spendChargeID, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: env.CurrencyReference(), + ServicePeriod: testServicePeriod(env), + Amount: alpacadecimal.NewFromInt(20), }) require.NoError(t, err) require.Len(t, allocations, 1) @@ -124,16 +123,15 @@ func TestCorrectCollectedAccruedUsesReverseFeatureAwareCollectionOrder(t *testin chargeID := testChargeID(1) allocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: chargeID, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: env.CurrencyReference(), - SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, - ServicePeriod: servicePeriod, - Filters: ledger.CreditFilters{Version: ledger.CreditFiltersVersion1, Features: []string{"api-calls"}}, - Amount: alpacadecimal.NewFromInt(restrictedAmount + unrestrictedAmount), + Namespace: env.Namespace, + ChargeID: chargeID, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: env.CurrencyReference(), + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + ServicePeriod: servicePeriod, + Filters: ledger.CreditFilters{Version: ledger.CreditFiltersVersion1, Features: []string{"api-calls"}}, + Amount: alpacadecimal.NewFromInt(restrictedAmount + unrestrictedAmount), }) require.NoError(t, err) require.Len(t, allocations, 2) // two allocations: restricted source first, unrestricted source second. @@ -191,16 +189,15 @@ func TestCorrectCollectedAccruedReopensBreakageByReverseFeatureAwareCollectionOr chargeID := testChargeID(3) allocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: chargeID, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: env.CurrencyReference(), - SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, - ServicePeriod: servicePeriod, - Filters: ledger.CreditFilters{Version: ledger.CreditFiltersVersion1, Features: []string{"api-calls"}}, - Amount: alpacadecimal.NewFromInt(restrictedAmount + unrestrictedAmount), + Namespace: env.Namespace, + ChargeID: chargeID, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: env.CurrencyReference(), + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + ServicePeriod: servicePeriod, + Filters: ledger.CreditFilters{Version: ledger.CreditFiltersVersion1, Features: []string{"api-calls"}}, + Amount: alpacadecimal.NewFromInt(restrictedAmount + unrestrictedAmount), }) require.NoError(t, err) require.Len(t, allocations, 2) // two consumed plans should produce two release records. @@ -418,15 +415,14 @@ func TestCorrectCollectedAccruedPartiallyReversesAdvanceBackedCollection(t *test chargeID := testChargeID(1) allocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: chargeID, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: env.CurrencyReference(), - SettlementMode: productcatalog.CreditOnlySettlementMode, - ServicePeriod: servicePeriod, - Amount: alpacadecimal.NewFromInt(advanceAmount), + Namespace: env.Namespace, + ChargeID: chargeID, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: env.CurrencyReference(), + SettlementMode: productcatalog.CreditOnlySettlementMode, + ServicePeriod: servicePeriod, + Amount: alpacadecimal.NewFromInt(advanceAmount), }) require.NoError(t, err) require.Len(t, allocations, 1) // credit-only shortfall creates one advance-backed allocation. @@ -692,17 +688,16 @@ func TestCorrectFiatFundedCustomCurrencyCreditOnlyShortfall(t *testing.T) { require.NoError(t, err) allocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: spendCharge, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: customCurrencyReference, - SettlementMode: productcatalog.CreditOnlySettlementMode, - ServicePeriod: servicePeriod, - Amount: alpacadecimal.NewFromInt(120), - TaxCode: &taxCode, - TaxBehavior: &taxBehavior, + Namespace: env.Namespace, + ChargeID: spendCharge, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: customCurrencyReference, + SettlementMode: productcatalog.CreditOnlySettlementMode, + ServicePeriod: servicePeriod, + Amount: alpacadecimal.NewFromInt(120), + TaxCode: &taxCode, + TaxBehavior: &taxBehavior, }) require.NoError(t, err) require.Len(t, allocations, 2) @@ -955,29 +950,27 @@ func TestCorrectCustomCurrencyCreditOnlyShortfall_MultipleFundingSourcesDoNotCro // collections booked in independent transactions are independently // correctable. usdAllocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: usdChargeID, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: customCurrencyReference, - SettlementMode: productcatalog.CreditOnlySettlementMode, - ServicePeriod: servicePeriod, - Amount: alpacadecimal.NewFromInt(100), + Namespace: env.Namespace, + ChargeID: usdChargeID, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: customCurrencyReference, + SettlementMode: productcatalog.CreditOnlySettlementMode, + ServicePeriod: servicePeriod, + Amount: alpacadecimal.NewFromInt(100), }) require.NoError(t, err) require.Len(t, usdAllocations, 1) eurAllocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ - Namespace: env.Namespace, - ChargeID: eurChargeID, - CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: customCurrencyReference, - SettlementMode: productcatalog.CreditOnlySettlementMode, - ServicePeriod: servicePeriod, - Amount: alpacadecimal.NewFromInt(20), + Namespace: env.Namespace, + ChargeID: eurChargeID, + CustomerID: env.CustomerID.ID, + BookedAt: env.Now(), + Currency: customCurrencyReference, + SettlementMode: productcatalog.CreditOnlySettlementMode, + ServicePeriod: servicePeriod, + Amount: alpacadecimal.NewFromInt(20), }) require.NoError(t, err) require.Len(t, eurAllocations, 1) @@ -1078,7 +1071,7 @@ func TestCorrectCollectedAccruedResumesCollapsedSourceSuffix(t *testing.T) { otherFBO := fundSourceCharge(t, env, sourceC, 2, 15) allocations, err := collector.collectToAccrued(t.Context(), CollectToAccruedInput{ Namespace: env.Namespace, ChargeID: spend, CustomerID: env.CustomerID.ID, - BookedAt: env.Now(), SourceBalanceAsOf: env.Now(), Currency: env.CurrencyReference(), + BookedAt: env.Now(), Currency: env.CurrencyReference(), SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, ServicePeriod: testServicePeriod(env), Amount: alpacadecimal.NewFromInt(45), }) diff --git a/openmeter/ledger/collector/service.go b/openmeter/ledger/collector/service.go index 95cee20055..e6cf549686 100644 --- a/openmeter/ledger/collector/service.go +++ b/openmeter/ledger/collector/service.go @@ -86,34 +86,32 @@ func (c Config) Validate() error { } type CollectToAccruedInput struct { - Namespace string - ChargeID string - CustomerID string - Annotations models.Annotations - BookedAt time.Time - SourceBalanceAsOf time.Time - Currency currencies.CurrencyReference - Filters ledger.CreditFilters - SettlementMode productcatalog.SettlementMode - ServicePeriod timeutil.ClosedPeriod - Amount alpacadecimal.Decimal - TaxCode *string - TaxBehavior *ledger.TaxBehavior + Namespace string + ChargeID string + CustomerID string + Annotations models.Annotations + BookedAt time.Time + Currency currencies.CurrencyReference + Filters ledger.CreditFilters + SettlementMode productcatalog.SettlementMode + ServicePeriod timeutil.ClosedPeriod + Amount alpacadecimal.Decimal + TaxCode *string + TaxBehavior *ledger.TaxBehavior } type CorrectCollectedAccruedInput = correction.Input type CollectToReceivableInput struct { - Namespace string - ChargeID string - CustomerID string - Annotations models.Annotations - BookedAt time.Time - SourceBalanceAsOf time.Time - Currency currencies.CurrencyReference - Filters ledger.CreditFilters - ServicePeriod timeutil.ClosedPeriod - Amount alpacadecimal.Decimal + Namespace string + ChargeID string + CustomerID string + Annotations models.Annotations + BookedAt time.Time + Currency currencies.CurrencyReference + Filters ledger.CreditFilters + ServicePeriod timeutil.ClosedPeriod + Amount alpacadecimal.Decimal } func (i CollectToReceivableInput) Validate() error { @@ -134,10 +132,6 @@ func (i CollectToReceivableInput) Validate() error { errs = append(errs, errors.New("booked at is required")) } - if i.SourceBalanceAsOf.IsZero() { - errs = append(errs, errors.New("source balance as of is required")) - } - if err := i.Currency.Validate(); err != nil { errs = append(errs, fmt.Errorf("currency: %w", err)) } else if !i.Currency.IsFiat() { @@ -244,10 +238,6 @@ func (s *service) CollectToAccrued(ctx context.Context, input CollectToAccruedIn return nil, fmt.Errorf("booked at is required") } - if input.SourceBalanceAsOf.IsZero() { - return nil, fmt.Errorf("source balance as of is required") - } - return s.collector.collectToAccrued(ctx, input) } diff --git a/openmeter/ledger/fbo/README.md b/openmeter/ledger/fbo/README.md index ff13dc1ebf..12a6e33fc4 100644 --- a/openmeter/ledger/fbo/README.md +++ b/openmeter/ledger/fbo/README.md @@ -11,9 +11,8 @@ and group posting in one database transaction. Breakage releases reference consumption entry IDs assigned before posting. Any failure requires rollback. Sources are selected by priority, restricted before unrestricted credit, earliest -expiry, then stable source cursor. `SourceBalanceAsOf` is the source-selection -cutoff; `BookedAt` is the effective time of consumption. Receivable coverage never -creates advance. +expiry, then stable source cursor. `BookedAt` is the effective time for both +source selection and consumption. Receivable coverage never creates advance. `GetSpendableBalanceBuckets` reports positive capacity available at `AsOf` without reducing any later recorded balance below zero. It takes the minimum per source diff --git a/openmeter/ledger/fbo/balance_test.go b/openmeter/ledger/fbo/balance_test.go index 1ef3ddb194..f75bb663c1 100644 --- a/openmeter/ledger/fbo/balance_test.go +++ b/openmeter/ledger/fbo/balance_test.go @@ -53,14 +53,12 @@ func TestConsumptionPreservesFutureSpending(t *testing.T) { if tc.futureSpend > 0 { future := consumeInput(env, "future-usage", tc.futureSpend) future.BookedAt = at.Add(3 * time.Hour) - future.SourceBalanceAsOf = future.BookedAt consumeAndCommit(t, env, service, future) } // when: another collection is booked before that consumption. input := consumeInput(env, "earlier-usage", 10) input.BookedAt = at.Add(time.Hour) - input.SourceBalanceAsOf = input.BookedAt consumeAndCommit(t, env, service, input) // then: it collects only credit left after preserving future spending. @@ -86,13 +84,11 @@ func TestSpendableBalanceRespectsBreakageBoundary(t *testing.T) { bookExpiringCredit(t, env, breakageService, 10) future := consumeInput(env, "existing-usage", 7) future.BookedAt = env.Now().Add(5 * 24 * time.Hour) - future.SourceBalanceAsOf = future.BookedAt consumeAndCommit(t, env, service, future) // when: consumption is requested before, at, or after breakage. input := consumeInput(env, "new-usage", 10) input.BookedAt = env.Now().Add(tc.at) - input.SourceBalanceAsOf = input.BookedAt consumeAndCommit(t, env, service, input) // then: future breakage can be released, but expired credit stays unavailable. @@ -117,7 +113,6 @@ func TestSpendableBalanceSumsIndependentSourceMinima(t *testing.T) { first := consumeInput(env, "first", 8) first.BookedAt = at.Add(time.Hour) - first.SourceBalanceAsOf = first.BookedAt original := consumeAndCommit(t, env, service, first) require.Len(t, original.Transactions(), 1) tx := original.Transactions()[0] diff --git a/openmeter/ledger/fbo/consume.go b/openmeter/ledger/fbo/consume.go index 4663c4e76b..431464fa22 100644 --- a/openmeter/ledger/fbo/consume.go +++ b/openmeter/ledger/fbo/consume.go @@ -57,17 +57,16 @@ func (p AdvancePolicy) Validate() error { type ConsumeInput struct { breakage.PostingInput - CustomerID customer.CustomerID - ChargeID string - BookedAt time.Time - SourceBalanceAsOf time.Time - Currency currencies.CurrencyReference - Filters ledger.CreditFilters - Amount alpacadecimal.Decimal - Destination ConsumeDestination - AdvancePolicy AdvancePolicy - TaxCode *string - TaxBehavior *ledger.TaxBehavior + CustomerID customer.CustomerID + ChargeID string + BookedAt time.Time + Currency currencies.CurrencyReference + Filters ledger.CreditFilters + Amount alpacadecimal.Decimal + Destination ConsumeDestination + AdvancePolicy AdvancePolicy + TaxCode *string + TaxBehavior *ledger.TaxBehavior } var _ models.Validator = ConsumeInput{} @@ -90,10 +89,6 @@ func (i ConsumeInput) Validate() error { errs = append(errs, errors.New("booked at is required")) } - if i.SourceBalanceAsOf.IsZero() { - errs = append(errs, errors.New("source balance as of is required")) - } - if err := i.Currency.Validate(); err != nil { errs = append(errs, fmt.Errorf("currency: %w", err)) } else if i.Currency.IsCustom() && !i.Currency.IsResolved() { @@ -218,7 +213,7 @@ func (s *service) selectCreditsAndBuildTransactions(ctx context.Context, request CustomerID: request.CustomerID, Currency: request.Currency, Filters: request.Filters, - AsOf: request.SourceBalanceAsOf, + AsOf: request.BookedAt, }) if err != nil { return nil, nil, err diff --git a/openmeter/ledger/fbo/consume_test.go b/openmeter/ledger/fbo/consume_test.go index 07c929fae1..9c1f796048 100644 --- a/openmeter/ledger/fbo/consume_test.go +++ b/openmeter/ledger/fbo/consume_test.go @@ -180,15 +180,14 @@ func newFBOEnv(t *testing.T) (*ledgertestutils.IntegrationEnv, fbo.Service, brea func consumeInput(env *ledgertestutils.IntegrationEnv, chargeID string, amount int64) fbo.ConsumeInput { return fbo.ConsumeInput{ - CustomerID: env.CustomerID, - PostingInput: breakage.PostingInput{TransactionGroupID: ulid.Make().String()}, - ChargeID: chargeID, - BookedAt: env.Now(), - SourceBalanceAsOf: env.Now(), - Currency: env.CurrencyReference(), - Amount: alpacadecimal.NewFromInt(amount), - Destination: fbo.ConsumeDestinationAccrued, - AdvancePolicy: fbo.AdvancePolicyNever, + CustomerID: env.CustomerID, + PostingInput: breakage.PostingInput{TransactionGroupID: ulid.Make().String()}, + ChargeID: chargeID, + BookedAt: env.Now(), + Currency: env.CurrencyReference(), + Amount: alpacadecimal.NewFromInt(amount), + Destination: fbo.ConsumeDestinationAccrued, + AdvancePolicy: fbo.AdvancePolicyNever, } } diff --git a/test/credits/customcurrency_test.go b/test/credits/customcurrency_test.go index 947317cabd..6090d3dd48 100644 --- a/test/credits/customcurrency_test.go +++ b/test/credits/customcurrency_test.go @@ -728,6 +728,131 @@ func (s *CustomCurrencyCreditsSuite) TestUsageBasedCreditOnlyBackfillRespectsFea s.Empty(lineages, "new collections must not create lineage state") } +func (s *CustomCurrencyCreditsSuite) TestFlatFeeCreditThenInvoiceSelectsCreditsAtServiceStart() { + for _, tc := range []struct { + name string + grantAmount int64 + grantOffset time.Duration + previousSpend int64 + expectedAllocated float64 + expectedFiatTotal float64 + }{ + {name: "mid-period credit covers the fee", grantAmount: 20, grantOffset: -24 * time.Hour, expectedAllocated: 10}, + {name: "mid-period credit covers part of the fee", grantAmount: 4, grantOffset: -24 * time.Hour, expectedAllocated: 4, expectedFiatTotal: 3}, + {name: "credit granted at booking time is eligible", grantAmount: 20, expectedAllocated: 10}, + {name: "later collection keeps its credits", grantAmount: 10, grantOffset: -24 * time.Hour, previousSpend: 7, expectedAllocated: 3, expectedFiatTotal: 3.5}, + {name: "later collection already exhausted the grant", grantAmount: 10, grantOffset: -24 * time.Hour, previousSpend: 10, expectedFiatTotal: 5}, + {name: "later credit cannot fund earlier service", grantAmount: 20, grantOffset: 24 * time.Hour, expectedFiatTotal: 5}, + } { + s.Run(tc.name, func() { + t := s.T() + ctx := t.Context() + billingStart := datetime.MustParseTimeInLocation(t, "2026-01-01T00:00:00Z", time.UTC).AsTime() + servicePeriod := timeutil.ClosedPeriod{ + From: datetime.MustParseTimeInLocation(t, "2026-01-15T00:00:00Z", time.UTC).AsTime(), + To: datetime.MustParseTimeInLocation(t, "2026-02-01T00:00:00Z", time.UTC).AsTime(), + } + grantAt := servicePeriod.From.Add(tc.grantOffset) + clock.FreezeTime(grantAt) + defer clock.UnFreeze() + + // given: + // - custom credits are granted before collection + // - a mid-period fee retains the billing-period start as invoice_at, as for an addon change + ns := s.GetUniqueNamespace("flat-fee-credit-booking-time") + defaults := s.ProvisionDefaultTaxCodes(ctx, ns) + invoicing := s.SetupCustomInvoicing(ns) + customer := s.CreateLedgerBackedCustomer(ns, "test-subject") + _ = s.ProvisionBillingProfile(ctx, ns, invoicing.App.GetID(), billingtest.WithManualApproval()) + tokens := s.createCustomCurrency(ns, "TOKENS") + s.createCustomCurrencyCreditPurchase(ctx, customCurrencyCreditPurchaseInput{ + Namespace: ns, + Customer: customer.GetID(), + Currency: tokens, + Amount: alpacadecimal.NewFromInt(tc.grantAmount), + At: grantAt, + Name: "TOKENS grant", + Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}), + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: defaults.CreditGrantTaxCodeID, + }, + }) + + costBasis := s.newManualCostBasis(alpacadecimal.NewFromFloat(0.5)) + if tc.previousSpend > 0 { + // A later-effective fee gets collected first. Its allocation must survive + // when the delayed fee is subsequently booked at an earlier time. + laterStart := servicePeriod.From.Add(24 * time.Hour) + clock.FreezeTime(laterStart) + laterCharge := s.createCustomCurrencyFlatFeeCharge(ctx, customCurrencyFlatFeeChargeInput{ + Namespace: ns, + Customer: customer.GetID(), + Currency: tokens, + ServicePeriod: timeutil.ClosedPeriod{From: laterStart, To: servicePeriod.To}, + InvoiceAt: laterStart, + Amount: alpacadecimal.NewFromInt(tc.previousSpend), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + CostBasis: &costBasis, + Name: "Already collected TOKENS fee", + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: defaults.InvoicingTaxCodeID, + }, + }) + invoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ + Customer: customer.GetID(), + AsOf: &laterStart, + }) + s.Require().NoError(err) + s.Require().Len(invoices, 1) + + laterRealized, err := s.MustGetChargeByID(laterCharge.GetChargeID()).AsFlatFeeCharge() + s.Require().NoError(err) + s.Require().NotNil(laterRealized.Realizations.CurrentRun) + s.Equal(float64(tc.previousSpend), laterRealized.Realizations.CurrentRun.CreditRealizations.Sum().InexactFloat64()) + } + + collectionAt := servicePeriod.From.Add(48 * time.Hour) + clock.FreezeTime(collectionAt) + charge := s.createCustomCurrencyFlatFeeCharge(ctx, customCurrencyFlatFeeChargeInput{ + Namespace: ns, + Customer: customer.GetID(), + Currency: tokens, + ServicePeriod: servicePeriod, + InvoiceAt: billingStart, + Amount: alpacadecimal.NewFromInt(10), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + CostBasis: &costBasis, + Name: "Mid-period TOKENS fee", + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: defaults.InvoicingTaxCodeID, + }, + }) + + // when: + // - collection runs after both the service start and the grant + invoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ + Customer: customer.GetID(), + AsOf: lo.ToPtr(collectionAt), + }) + s.Require().NoError(err) + s.Require().Len(invoices, 1) + + // then: + // - only credit effective at service start and not already allocated is consumed + // - only uncovered custom currency becomes invoiceable fiat + realized, err := s.MustGetChargeByID(charge.GetChargeID()).AsFlatFeeCharge() + s.Require().NoError(err) + s.Require().NotNil(realized.Realizations.CurrentRun) + s.Equal(tc.expectedAllocated, realized.Realizations.CurrentRun.CreditRealizations.Sum().InexactFloat64()) + s.requireCustomCurrencyInvoiceTotals(invoices[0], tc.expectedFiatTotal, 0, tc.expectedFiatTotal) + accounts := s.mustCustomerAccounts(customer.GetID()) + s.requireAccountBalance(accounts.FBOAccount, ledger.RouteFilter{Currency: tokens.Reference()}, float64(tc.grantAmount-tc.previousSpend)-tc.expectedAllocated, "remaining TOKENS") + }) + } +} + func (s *CustomCurrencyCreditsSuite) TestFlatFeeCreditThenInvoiceAllocatesNativeCreditsBeforeSelectiveFiatCoverage() { t := s.T() ctx := t.Context()