package chargeadapter_test import ( "testing" "time" "github.com/alpacahq/alpacadecimal" "github.com/oklog/ulid/v2" "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter" lineageservice "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/service" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" chargeusagebased "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" "github.com/openmeterio/openmeter/openmeter/billing/models/totals" ledgertransactiondb "github.com/openmeterio/openmeter/openmeter/ent/db/ledgertransaction" enttx "github.com/openmeterio/openmeter/openmeter/ent/tx" "github.com/openmeterio/openmeter/openmeter/ledger" "github.com/openmeterio/openmeter/openmeter/ledger/chargeadapter" ledgercollector "github.com/openmeterio/openmeter/openmeter/ledger/collector" "github.com/openmeterio/openmeter/openmeter/ledger/recognizer" ledgertestutils "github.com/openmeterio/openmeter/openmeter/ledger/testutils" "github.com/openmeterio/openmeter/openmeter/ledger/transactions" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/timeutil" ) func TestOnUsageBasedCreditsOnlyUsageAccrued(t *testing.T) { t.Run("credit_only advances uncovered amount", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) realizations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: env.newCreditsOnlyCharge(), Run: env.newRun(), BookedAt: env.Now(), AmountToAllocate: alpacadecimal.NewFromInt(30), }) require.NoError(t, err) require.Len(t, realizations, 1) require.True(t, realizations[0].Amount.Equal(alpacadecimal.NewFromInt(30))) require.True(t, env.sumBalance(t, env.unknownReceivableSubAccountForFeature(t, "api_requests")).Equal(alpacadecimal.NewFromInt(-30))) require.True(t, env.sumBalance(t, env.unknownFboSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.unknownAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(30))) }) t.Run("credit_only collects from funded balances first", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) priorityOne := env.fundPriority(t, 1, 20) realizations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: env.newCreditsOnlyCharge(), Run: env.newRun(), BookedAt: env.Now(), AmountToAllocate: alpacadecimal.NewFromInt(30), }) require.NoError(t, err) require.Len(t, realizations, 2) require.True(t, realizations[0].Amount.Equal(alpacadecimal.NewFromInt(20))) require.True(t, realizations[1].Amount.Equal(alpacadecimal.NewFromInt(10))) require.True(t, env.sumBalance(t, priorityOne).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.unknownReceivableSubAccountForFeature(t, "api_requests")).Equal(alpacadecimal.NewFromInt(-10))) require.True(t, env.sumBalance(t, env.creditAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(20))) require.True(t, env.sumBalance(t, env.unknownAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(10))) }) t.Run("credit_then_invoice collects available credits", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) priorityOne := env.fundPriority(t, 1, 20) charge := env.newCharge(productcatalog.CreditThenInvoiceSettlementMode) editUsageBasedBaseLayerForTest(t, &charge, func(intent *chargeusagebased.IntentMutableFields) { intent.InvoiceAt = env.Now().Add(24 * time.Hour) }) run := env.newRun() realizations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: charge, Run: run, BookedAt: run.ServicePeriodTo, AmountToAllocate: alpacadecimal.NewFromInt(30), }) require.NoError(t, err) require.Len(t, realizations, 1) require.True(t, realizations[0].Amount.Equal(alpacadecimal.NewFromInt(20))) require.True(t, env.sumBalance(t, priorityOne).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.unknownReceivableSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.creditAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(20))) require.True(t, env.sumBalance(t, env.unknownAccruedSubAccount(t)).Equal(alpacadecimal.Zero)) for _, bookedAt := range env.transactionBookedAtTimes(t, realizations[0].LedgerTransaction.TransactionGroupID) { requireLedgerBookedAtEqual(t, run.ServicePeriodTo, bookedAt) requireLedgerBookedAtNotEqual(t, charge.Intent.GetEffectiveInvoiceAt(), bookedAt) } }) t.Run("tracks charge references on transactions", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) charge := env.newCreditsOnlyCharge() subscription := meta.SubscriptionReference{ SubscriptionID: "subscription-01JABCDEF0123456789ABCDEF", PhaseID: "phase-01JABCDEF0123456789ABCDEF", ItemID: "item-01JABCDEF0123456789ABCDEF", } setUsageBasedSubscriptionForTest(t, &charge, subscription) realizations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: charge, Run: env.newRun(), BookedAt: env.Now(), AmountToAllocate: alpacadecimal.NewFromInt(30), }) require.NoError(t, err) require.Len(t, realizations, 1) transactionAnnotations := env.transactionAnnotations(t, realizations[0].LedgerTransaction.TransactionGroupID) require.NotEmpty(t, transactionAnnotations) for _, annotations := range transactionAnnotations { require.Equal(t, charge.ID, annotations[ledger.AnnotationChargeID]) require.Equal(t, env.Namespace, annotations[ledger.AnnotationChargeNamespace]) require.Equal(t, subscription.SubscriptionID, annotations[ledger.AnnotationSubscriptionID]) require.Equal(t, subscription.PhaseID, annotations[ledger.AnnotationSubscriptionPhaseID]) require.Equal(t, subscription.ItemID, annotations[ledger.AnnotationSubscriptionItemID]) require.Equal(t, charge.State.FeatureID, annotations[ledger.AnnotationFeatureID]) } }) t.Run("zero amount is rejected by input validation", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) realizations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: env.newCreditsOnlyCharge(), Run: env.newRun(), BookedAt: env.Now(), AmountToAllocate: alpacadecimal.Zero, }) require.Error(t, err) require.Nil(t, realizations) require.Contains(t, err.Error(), "amount to allocate must be positive") }) t.Run("booked at is required", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) realizations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: env.newCreditsOnlyCharge(), Run: env.newRun(), BookedAt: time.Time{}, AmountToAllocate: alpacadecimal.NewFromInt(30), }) require.ErrorContains(t, err, "booked at is required") require.Nil(t, realizations) }) } func TestOnUsageBasedCreditsOnlyUsageAccruedCorrection(t *testing.T) { t.Run("credit_only reverses advance-backed accrual", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) run := env.newRun() allocations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: env.newCreditsOnlyCharge(), Run: run, BookedAt: env.Now(), AmountToAllocate: alpacadecimal.NewFromInt(30), }) require.NoError(t, err) require.Len(t, allocations, 1) run.CreditsAllocated = env.realizationsFromAllocations(allocations) currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). WithCode(env.Currency). Build() require.NoError(t, err) correctionsRequest, err := run.CreditsAllocated.CreateCorrectionRequest(alpacadecimal.NewFromInt(-30), currency) require.NoError(t, err) corrections, err := env.handler.OnCreditsOnlyUsageAccruedCorrection(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedCorrectionInput{ Charge: env.newCreditsOnlyCharge(), Run: run, BookedAt: env.Now(), Corrections: correctionsRequest, }) require.NoError(t, err) require.Len(t, corrections, 1) require.True(t, corrections[0].Amount.Equal(alpacadecimal.NewFromInt(-30))) require.True(t, env.sumBalance(t, env.unknownReceivableSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.unknownFboSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.unknownAccruedSubAccount(t)).Equal(alpacadecimal.Zero)) }) t.Run("credit_then_invoice reverses accrual", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) _ = env.fundPriority(t, 1, 20) run := env.newRun() allocations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: run, BookedAt: env.Now(), AmountToAllocate: alpacadecimal.NewFromInt(20), }) require.NoError(t, err) require.Len(t, allocations, 1) run.CreditsAllocated = env.realizationsFromAllocations(allocations) currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). WithCode(env.Currency). Build() require.NoError(t, err) correctionsRequest, err := run.CreditsAllocated.CreateCorrectionRequest(alpacadecimal.NewFromInt(-20), currency) require.NoError(t, err) corrections, err := env.handler.OnCreditsOnlyUsageAccruedCorrection(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedCorrectionInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: run, BookedAt: env.Now(), Corrections: correctionsRequest, }) require.NoError(t, err) require.Len(t, corrections, 1) require.True(t, corrections[0].Amount.Equal(alpacadecimal.NewFromInt(-20))) require.True(t, env.sumBalance(t, env.unknownReceivableSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.unknownFboSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.unknownAccruedSubAccount(t)).Equal(alpacadecimal.Zero)) }) t.Run("credit_then_invoice reverses recognized earnings in the same correction", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) priorityOne := env.fundPriority(t, 1, 20) charge := env.newCharge(productcatalog.CreditThenInvoiceSettlementMode) run := env.newRun() allocations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: charge, Run: run, BookedAt: env.Now(), AmountToAllocate: alpacadecimal.NewFromInt(20), }) require.NoError(t, err) require.Len(t, allocations, 1) run.CreditsAllocated = env.realizationsFromAllocations(allocations) env.createInitialLineages(t, charge.ID, run.CreditsAllocated) recognitionGroupID := env.recognizeCreditAccrued(t, alpacadecimal.NewFromInt(20)) require.True(t, env.sumBalance(t, env.creditAccruedSubAccount(t)).Equal(alpacadecimal.Zero)) require.Equal(t, float64(20), env.sumBalance(t, env.creditEarningsSubAccount(t)).InexactFloat64()) currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). WithCode(env.Currency). Build() require.NoError(t, err) correctionsRequest, err := run.CreditsAllocated.CreateCorrectionRequest(alpacadecimal.NewFromInt(-20), currency) require.NoError(t, err) corrections, err := env.handler.OnCreditsOnlyUsageAccruedCorrection(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedCorrectionInput{ Charge: charge, Run: run, BookedAt: env.Now(), Corrections: correctionsRequest, LineageSegmentsByRealization: env.assertRecognizedSegments(t, run.CreditsAllocated, recognitionGroupID), }) require.NoError(t, err) require.Len(t, corrections, 1) require.True(t, corrections[0].Amount.Equal(alpacadecimal.NewFromInt(-20))) require.True(t, env.sumBalance(t, priorityOne).Equal(alpacadecimal.NewFromInt(20))) require.True(t, env.sumBalance(t, env.creditAccruedSubAccount(t)).Equal(alpacadecimal.Zero)) require.Equal(t, float64(0), env.sumBalance(t, env.creditEarningsSubAccount(t)).InexactFloat64()) }) t.Run("credit_only reverses recognized earnings in the same correction", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) priorityOne := env.fundPriority(t, 1, 20) charge := env.newCreditsOnlyCharge() run := env.newRun() allocations, err := env.handler.OnCreditsOnlyUsageAccrued(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedInput{ Charge: charge, Run: run, BookedAt: env.Now(), AmountToAllocate: alpacadecimal.NewFromInt(20), }) require.NoError(t, err) require.Len(t, allocations, 1) run.CreditsAllocated = env.realizationsFromAllocations(allocations) env.createInitialLineages(t, charge.ID, run.CreditsAllocated) recognitionGroupID := env.recognizeCreditAccrued(t, alpacadecimal.NewFromInt(20)) require.True(t, env.sumBalance(t, env.creditAccruedSubAccount(t)).Equal(alpacadecimal.Zero)) require.Equal(t, float64(20), env.sumBalance(t, env.creditEarningsSubAccount(t)).InexactFloat64()) currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). WithCode(env.Currency). Build() require.NoError(t, err) correctionsRequest, err := run.CreditsAllocated.CreateCorrectionRequest(alpacadecimal.NewFromInt(-20), currency) require.NoError(t, err) corrections, err := env.handler.OnCreditsOnlyUsageAccruedCorrection(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedCorrectionInput{ Charge: charge, Run: run, BookedAt: env.Now(), Corrections: correctionsRequest, LineageSegmentsByRealization: env.assertRecognizedSegments(t, run.CreditsAllocated, recognitionGroupID), }) require.NoError(t, err) require.Len(t, corrections, 1) require.True(t, corrections[0].Amount.Equal(alpacadecimal.NewFromInt(-20))) require.True(t, env.sumBalance(t, priorityOne).Equal(alpacadecimal.NewFromInt(20))) require.True(t, env.sumBalance(t, env.creditAccruedSubAccount(t)).Equal(alpacadecimal.Zero)) require.Equal(t, float64(0), env.sumBalance(t, env.creditEarningsSubAccount(t)).InexactFloat64()) }) t.Run("booked at is required", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) corrections, err := env.handler.OnCreditsOnlyUsageAccruedCorrection(t.Context(), chargeusagebased.CreditsOnlyUsageAccruedCorrectionInput{ Charge: env.newCreditsOnlyCharge(), Run: env.newRun(), BookedAt: time.Time{}, }) require.ErrorContains(t, err, "booked at is required") require.Nil(t, corrections) }) } func TestOnUsageBasedInvoiceUsageAccrued(t *testing.T) { t.Run("credit_then_invoice zero amount returns empty reference", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnInvoiceUsageAccrued(t.Context(), chargeusagebased.OnInvoiceUsageAccruedInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRun(), ServicePeriod: timeutil.ClosedPeriod{From: env.Now().Add(-time.Hour), To: env.Now()}, BookedAt: env.Now(), Amount: alpacadecimal.Zero, }) require.NoError(t, err) require.Empty(t, ref.TransactionGroupID) }) t.Run("credit_then_invoice books invoice usage at service period end", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) servicePeriod := timeutil.ClosedPeriod{ From: env.Now().Add(-2 * time.Hour), To: env.Now().Add(-time.Hour), } charge := env.newCharge(productcatalog.CreditThenInvoiceSettlementMode) editUsageBasedBaseLayerForTest(t, &charge, func(intent *chargeusagebased.IntentMutableFields) { intent.InvoiceAt = servicePeriod.To.Add(24 * time.Hour) }) ref, err := env.handler.OnInvoiceUsageAccrued(t.Context(), chargeusagebased.OnInvoiceUsageAccruedInput{ Charge: charge, Run: env.newRunWithLine("line-1"), ServicePeriod: servicePeriod, BookedAt: servicePeriod.To, Amount: alpacadecimal.NewFromInt(30), }) require.NoError(t, err) require.NotEmpty(t, ref.TransactionGroupID) for _, bookedAt := range env.transactionBookedAtTimes(t, ref.TransactionGroupID) { requireLedgerBookedAtEqual(t, servicePeriod.To, bookedAt) requireLedgerBookedAtNotEqual(t, charge.Intent.GetEffectiveInvoiceAt(), bookedAt) } }) t.Run("booked at is required", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnInvoiceUsageAccrued(t.Context(), chargeusagebased.OnInvoiceUsageAccruedInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRun(), ServicePeriod: timeutil.ClosedPeriod{From: env.Now().Add(-time.Hour), To: env.Now()}, BookedAt: time.Time{}, Amount: alpacadecimal.NewFromInt(30), }) require.ErrorContains(t, err, "booked at is required") require.Empty(t, ref.TransactionGroupID) }) } func TestOnUsageBasedPaymentAuthorized(t *testing.T) { t.Run("credit_then_invoice stages receivable funding from invoice usage", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) total := alpacadecimal.NewFromInt(40) _, err := env.handler.OnInvoiceUsageAccrued(t.Context(), chargeusagebased.OnInvoiceUsageAccruedInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRunWithLine("line-1"), ServicePeriod: timeutil.ClosedPeriod{From: env.Now().Add(-time.Hour), To: env.Now()}, BookedAt: env.Now(), Amount: total, }) require.NoError(t, err) charge := env.newCharge(productcatalog.CreditThenInvoiceSettlementMode) editUsageBasedBaseLayerForTest(t, &charge, func(intent *chargeusagebased.IntentMutableFields) { intent.InvoiceAt = env.Now().Add(-24 * time.Hour) }) eventTime := env.Now().Add(15 * time.Minute) clock.FreezeTime(eventTime) defer clock.UnFreeze() ref, err := env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ Charge: charge, Run: env.newRunWithInvoiceUsage("line-1", total), EventAt: env.Now(), }) require.NoError(t, err) require.NotEmpty(t, ref.TransactionGroupID) // Authorization only moves the receivable between status buckets. require.True(t, env.sumBalance(t, env.receivableSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.NewFromInt(-40))) require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.Zero)) // No revenue recognition happens here anymore. require.True(t, env.sumBalance(t, env.invoiceAccruedSubAccount(t)).Equal(alpacadecimal.NewFromInt(40))) require.True(t, env.sumBalance(t, env.invoiceEarningsSubAccount(t)).Equal(alpacadecimal.Zero)) for _, bookedAt := range env.transactionBookedAtTimes(t, ref.TransactionGroupID) { requireLedgerBookedAtEqual(t, eventTime, bookedAt) requireLedgerBookedAtNotEqual(t, charge.Intent.GetEffectiveInvoiceAt(), bookedAt) } }) t.Run("zero invoice usage is a no-op", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRunWithInvoiceUsage("line-1", alpacadecimal.Zero), EventAt: env.Now(), }) require.NoError(t, err) require.Empty(t, ref.TransactionGroupID) }) t.Run("event at is required", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRunWithInvoiceUsage("line-1", alpacadecimal.NewFromInt(10)), EventAt: time.Time{}, }) require.ErrorContains(t, err, "event at is required") require.Empty(t, ref.TransactionGroupID) }) } func TestOnUsageBasedPaymentSettled(t *testing.T) { t.Run("credit_then_invoice settles authorized receivable from wash", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) total := alpacadecimal.NewFromInt(25) _, err := env.handler.OnInvoiceUsageAccrued(t.Context(), chargeusagebased.OnInvoiceUsageAccruedInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRunWithLine("line-1"), ServicePeriod: timeutil.ClosedPeriod{From: env.Now().Add(-time.Hour), To: env.Now()}, BookedAt: env.Now(), Amount: total, }) require.NoError(t, err) authorizedCharge := env.newCharge(productcatalog.CreditThenInvoiceSettlementMode) editUsageBasedBaseLayerForTest(t, &authorizedCharge, func(intent *chargeusagebased.IntentMutableFields) { intent.InvoiceAt = env.Now().Add(-24 * time.Hour) }) _, err = env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ Charge: authorizedCharge, Run: env.newRunWithInvoiceUsage("line-1", total), EventAt: env.Now(), }) require.NoError(t, err) settledCharge := env.newCharge(productcatalog.CreditThenInvoiceSettlementMode) editUsageBasedBaseLayerForTest(t, &settledCharge, func(intent *chargeusagebased.IntentMutableFields) { intent.InvoiceAt = env.Now().Add(-48 * time.Hour) }) eventTime := env.Now().Add(30 * time.Minute) clock.FreezeTime(eventTime) defer clock.UnFreeze() ref, err := env.handler.OnPaymentSettled(t.Context(), chargeusagebased.OnPaymentSettledInput{ Charge: settledCharge, Run: env.newRunWithAuthorizedPayment("line-1", total), EventAt: eventTime, }) require.NoError(t, err) require.NotEmpty(t, ref.TransactionGroupID) require.True(t, env.sumBalance(t, env.unknownReceivableSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.authorizedReceivableSubAccount(t)).Equal(alpacadecimal.Zero)) require.True(t, env.sumBalance(t, env.washSubAccount(t)).Equal(alpacadecimal.NewFromInt(-25))) for _, bookedAt := range env.transactionBookedAtTimes(t, ref.TransactionGroupID) { requireLedgerBookedAtEqual(t, eventTime, bookedAt) requireLedgerBookedAtNotEqual(t, settledCharge.Intent.GetEffectiveInvoiceAt(), bookedAt) } }) t.Run("zero invoice usage is a no-op", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentSettled(t.Context(), chargeusagebased.OnPaymentSettledInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRunWithAuthorizedPaymentAndInvoiceUsage("line-1", alpacadecimal.NewFromInt(1), alpacadecimal.Zero), EventAt: env.Now(), }) require.NoError(t, err) require.Empty(t, ref.TransactionGroupID) }) t.Run("event at is required", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentSettled(t.Context(), chargeusagebased.OnPaymentSettledInput{ Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), Run: env.newRunWithAuthorizedPayment("line-1", alpacadecimal.NewFromInt(10)), EventAt: time.Time{}, }) require.ErrorContains(t, err, "event at is required") require.Empty(t, ref.TransactionGroupID) }) } type usageBasedHandlerTestEnv struct { *ledgertestutils.IntegrationEnv handler chargeusagebased.Handler lineage lineage.Service recognizer recognizer.Service } func newUsageBasedHandlerTestEnv(t *testing.T) *usageBasedHandlerTestEnv { base := ledgertestutils.NewIntegrationEnv(t, "chargeadapter-usagebased") collectorService, err := ledgercollector.NewService(ledgercollector.Config{ Ledger: base.Deps.HistoricalLedger, Dependencies: transactions.ResolverDependencies{ AccountService: base.Deps.ResolversService, AccountCatalog: base.Deps.AccountService, BalanceQuerier: base.Deps.HistoricalLedger, }, AccountLocker: base.Deps.AccountService, TransactionManager: enttx.NewCreator(base.DB), }) require.NoError(t, err) lineageAdapter, err := lineageadapter.New(lineageadapter.Config{ Client: base.DB, }) require.NoError(t, err) lineageService, err := lineageservice.New(lineageservice.Config{ Adapter: lineageAdapter, }) require.NoError(t, err) deps := transactions.ResolverDependencies{ AccountService: base.Deps.ResolversService, AccountCatalog: base.Deps.AccountService, BalanceQuerier: base.Deps.HistoricalLedger, } recognizerService, err := recognizer.NewService(recognizer.Config{ Ledger: base.Deps.HistoricalLedger, Dependencies: deps, Lineage: lineageService, TransactionManager: enttx.NewCreator(base.DB), }) require.NoError(t, err) return &usageBasedHandlerTestEnv{ IntegrationEnv: base, handler: chargeadapter.NewUsageBasedHandler(base.Deps.HistoricalLedger, transactions.ResolverDependencies{ AccountService: base.Deps.ResolversService, AccountCatalog: base.Deps.AccountService, BalanceQuerier: base.Deps.HistoricalLedger, }, collectorService), lineage: lineageService, recognizer: recognizerService, } } func (e *usageBasedHandlerTestEnv) newCreditsOnlyCharge() chargeusagebased.Charge { return e.newCharge(productcatalog.CreditOnlySettlementMode) } func (e *usageBasedHandlerTestEnv) newCharge(settlementMode productcatalog.SettlementMode) chargeusagebased.Charge { now := time.Now().UTC() featureID := "feature-api-requests" servicePeriod := timeutil.ClosedPeriod{ From: now.Add(-time.Hour), To: now, } return chargeusagebased.Charge{ ChargeBase: chargeusagebased.ChargeBase{ ManagedResource: meta.ManagedResource{ NamespacedModel: models.NamespacedModel{ Namespace: e.Namespace, }, ManagedModel: models.ManagedModel{ CreatedAt: now, UpdatedAt: now, }, ID: "usage-based-charge", }, Intent: chargeusagebased.Intent{ Intent: meta.Intent{ ManagedBy: billing.SystemManagedLine, CustomerID: e.CustomerID.ID, Currency: currencyx.Code("USD"), TaxConfig: productcatalog.TaxCodeConfig{ TaxCodeID: testChargeTaxCodeID, }, }, IntentMutableFields: chargeusagebased.IntentMutableFields{ IntentMutableFields: meta.IntentMutableFields{ Name: "Usage based", ServicePeriod: servicePeriod, BillingPeriod: servicePeriod, }, InvoiceAt: now, Price: *productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromInt(1)}), }, FeatureKey: "api_requests", SettlementMode: settlementMode, }.AsOverridableIntent(), Status: chargeusagebased.StatusActiveRealizationProcessing, State: chargeusagebased.State{ FeatureID: featureID, RatingEngine: chargeusagebased.RatingEngineDelta, }, }, } } func (e *usageBasedHandlerTestEnv) newRun() chargeusagebased.RealizationRun { now := time.Now().UTC() featureID := "feature-api-requests" return chargeusagebased.RealizationRun{ RealizationRunBase: chargeusagebased.RealizationRunBase{ ID: chargeusagebased.RealizationRunID(models.NamespacedID{ Namespace: e.Namespace, ID: "run-1", }), ManagedModel: models.ManagedModel{ CreatedAt: now, UpdatedAt: now, }, Type: chargeusagebased.RealizationRunTypeFinalRealization, InitialType: chargeusagebased.RealizationRunTypeFinalRealization, StoredAtLT: now, ServicePeriodTo: now, MeteredQuantity: alpacadecimal.NewFromInt(30), FeatureID: featureID, Totals: totals.Totals{ Amount: alpacadecimal.NewFromInt(30), CreditsTotal: alpacadecimal.NewFromInt(30), Total: alpacadecimal.Zero, }, }, } } func (e *usageBasedHandlerTestEnv) newRunWithLine(lineID string) chargeusagebased.RealizationRun { run := e.newRun() run.LineID = &lineID return run } func (e *usageBasedHandlerTestEnv) newRunWithInvoiceUsage(lineID string, total alpacadecimal.Decimal) chargeusagebased.RealizationRun { run := e.newRunWithLine(lineID) run.InvoiceUsage = &invoicedusage.AccruedUsage{ ServicePeriod: e.newCharge(productcatalog.CreditThenInvoiceSettlementMode).Intent.GetEffectiveServicePeriod(), Totals: totals.Totals{ Amount: total, Total: total, }, } return run } func (e *usageBasedHandlerTestEnv) newRunWithAuthorizedPayment(lineID string, total alpacadecimal.Decimal) chargeusagebased.RealizationRun { return e.newRunWithAuthorizedPaymentAndInvoiceUsage(lineID, total, total) } func (e *usageBasedHandlerTestEnv) newRunWithAuthorizedPaymentAndInvoiceUsage(lineID string, paymentAmount, invoiceUsageTotal alpacadecimal.Decimal) chargeusagebased.RealizationRun { run := e.newRunWithInvoiceUsage(lineID, invoiceUsageTotal) run.Payment = &payment.Invoiced{ Payment: payment.Payment{ NamespacedID: models.NamespacedID{ Namespace: e.Namespace, ID: "payment-1", }, ManagedModel: models.ManagedModel{ CreatedAt: e.Now(), UpdatedAt: e.Now(), }, Base: payment.Base{ ServicePeriod: run.InvoiceUsage.ServicePeriod, Status: payment.StatusAuthorized, Amount: paymentAmount, Authorized: &ledgertransaction.TimedGroupReference{ GroupReference: ledgertransaction.GroupReference{ TransactionGroupID: "authorized-group", }, Time: e.Now(), }, }, }, LineID: lineID, InvoiceID: "invoice-1", } return run } func (e *usageBasedHandlerTestEnv) fundPriority(t *testing.T, priority int, amount int64) ledger.SubAccount { t.Helper() costBasis := alpacadecimal.Zero subAccount, err := e.CustomerAccounts.FBOAccount.GetSubAccountForRoute(t.Context(), ledger.CustomerFBORouteParams{ Currency: e.Currency, CostBasis: &costBasis, CreditPriority: priority, }) require.NoError(t, err) inputs, err := transactions.ResolveTransactions( t.Context(), transactions.ResolverDependencies{ AccountService: e.Deps.ResolversService, AccountCatalog: e.Deps.AccountService, BalanceQuerier: e.Deps.HistoricalLedger, }, transactions.ResolutionScope{ CustomerID: e.CustomerID, Namespace: e.Namespace, }, transactions.IssueCustomerReceivableTemplate{ At: e.Now(), Amount: alpacadecimal.NewFromInt(amount), Currency: e.Currency, CostBasis: &costBasis, CreditPriority: &priority, }, transactions.AuthorizeCustomerReceivablePaymentTemplate{ At: e.Now(), Amount: alpacadecimal.NewFromInt(amount), Currency: e.Currency, CostBasis: &costBasis, }, transactions.SettleCustomerReceivableFromPaymentTemplate{ At: e.Now(), Amount: alpacadecimal.NewFromInt(amount), Currency: e.Currency, CostBasis: &costBasis, }, ) require.NoError(t, err) _, err = e.Deps.HistoricalLedger.CommitGroup(t.Context(), transactions.GroupInputs( e.Namespace, nil, inputs..., )) require.NoError(t, err) return subAccount } func (e *usageBasedHandlerTestEnv) creditAccruedSubAccount(t *testing.T) ledger.SubAccount { zeroCostBasis := alpacadecimal.Zero taxCodeID := testChargeTaxCodeID return e.AccruedSubAccountWithCostBasisAndTaxCode(t, &zeroCostBasis, &taxCodeID) } func (e *usageBasedHandlerTestEnv) unknownAccruedSubAccount(t *testing.T) ledger.SubAccount { taxCodeID := testChargeTaxCodeID return e.AccruedSubAccountWithCostBasisAndTaxCode(t, nil, &taxCodeID) } func (e *usageBasedHandlerTestEnv) unknownReceivableSubAccount(t *testing.T) ledger.SubAccount { return e.ReceivableSubAccountWithCostBasis(t, nil) } func (e *usageBasedHandlerTestEnv) unknownReceivableSubAccountForFeature(t *testing.T, featureKey string) ledger.SubAccount { t.Helper() subAccount, err := e.CustomerAccounts.ReceivableAccount.GetSubAccountForRoute(t.Context(), ledger.CustomerReceivableRouteParams{ Currency: e.Currency, CostBasis: nil, Features: []string{featureKey}, TransactionAuthorizationStatus: ledger.TransactionAuthorizationStatusOpen, }) require.NoError(t, err) return subAccount } func (e *usageBasedHandlerTestEnv) authorizedReceivableSubAccount(t *testing.T) ledger.SubAccount { return e.ReceivableSubAccountWithCostBasisAndStatus(t, testInvoiceCostBasis(), ledger.TransactionAuthorizationStatusAuthorized) } func (e *usageBasedHandlerTestEnv) receivableSubAccount(t *testing.T) ledger.SubAccount { return e.ReceivableSubAccountWithCostBasis(t, testInvoiceCostBasis()) } func (e *usageBasedHandlerTestEnv) washSubAccount(t *testing.T) ledger.SubAccount { return e.WashSubAccountWithCostBasis(t, testInvoiceCostBasis()) } func (e *usageBasedHandlerTestEnv) invoiceAccruedSubAccount(t *testing.T) ledger.SubAccount { taxCodeID := testChargeTaxCodeID return e.AccruedSubAccountWithCostBasisAndTaxCode(t, testInvoiceCostBasis(), &taxCodeID) } func (e *usageBasedHandlerTestEnv) invoiceEarningsSubAccount(t *testing.T) ledger.SubAccount { taxCodeID := testChargeTaxCodeID return e.EarningsSubAccountWithCostBasisAndTaxCode(t, testInvoiceCostBasis(), &taxCodeID) } func (e *usageBasedHandlerTestEnv) creditEarningsSubAccount(t *testing.T) ledger.SubAccount { zeroCostBasis := alpacadecimal.Zero taxCodeID := testChargeTaxCodeID return e.EarningsSubAccountWithCostBasisAndTaxCode(t, &zeroCostBasis, &taxCodeID) } func (e *usageBasedHandlerTestEnv) unknownFboSubAccount(t *testing.T) ledger.SubAccount { subAccount, err := e.CustomerAccounts.FBOAccount.GetSubAccountForRoute(t.Context(), ledger.CustomerFBORouteParams{ Currency: e.Currency, CreditPriority: ledger.DefaultCustomerFBOPriority, }) require.NoError(t, err) return subAccount } func (e *usageBasedHandlerTestEnv) sumBalance(t *testing.T, subAccount ledger.SubAccount) alpacadecimal.Decimal { return e.SumBalance(t, subAccount) } func (e *usageBasedHandlerTestEnv) transactionAnnotations(t *testing.T, groupID string) []models.Annotations { t.Helper() transactions, 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) out := make([]models.Annotations, 0, len(transactions)) for _, tx := range transactions { out = append(out, tx.Annotations) } return out } func (e *usageBasedHandlerTestEnv) transactionBookedAtTimes(t *testing.T, groupID string) []time.Time { t.Helper() transactions, 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) out := make([]time.Time, 0, len(transactions)) for _, tx := range transactions { out = append(out, tx.BookedAt) } return out } func (e *usageBasedHandlerTestEnv) recognizeCreditAccrued(t *testing.T, amount alpacadecimal.Decimal) string { t.Helper() result, err := e.recognizer.RecognizeEarnings(t.Context(), recognizer.RecognizeEarningsInput{ CustomerID: e.CustomerID, At: e.Now(), Currency: e.Currency, }) require.NoError(t, err) require.True(t, result.RecognizedAmount.Equal(amount), "recognized=%s expected=%s", result.RecognizedAmount, amount) return result.LedgerGroupID } func (e *usageBasedHandlerTestEnv) createInitialLineages(t *testing.T, chargeID string, realizations creditrealization.Realizations) { t.Helper() e.ensureCharge(t, chargeID) err := e.lineage.CreateInitialLineages(t.Context(), lineage.CreateInitialLineagesInput{ Namespace: e.Namespace, ChargeID: chargeID, CustomerID: e.CustomerID.ID, Currency: e.Currency, Realizations: realizations, }) require.NoError(t, err) } func (e *usageBasedHandlerTestEnv) activeSegmentsByRealization(t *testing.T, realizations creditrealization.Realizations) lineage.ActiveSegmentsByRealizationID { t.Helper() ids := make([]string, 0, len(realizations)) for _, realization := range realizations { ids = append(ids, realization.ID) } segments, err := e.lineage.LoadActiveSegmentsByRealizationID(t.Context(), e.Namespace, ids) require.NoError(t, err) return segments } func (e *usageBasedHandlerTestEnv) assertRecognizedSegments(t *testing.T, realizations creditrealization.Realizations, recognitionGroupID string) lineage.ActiveSegmentsByRealizationID { t.Helper() segmentsByRealization := e.activeSegmentsByRealization(t, realizations) for _, realization := range realizations { segments := segmentsByRealization[realization.ID] require.Len(t, segments, 1) segment := segments[0] require.Equal(t, creditrealization.LineageSegmentStateEarningsRecognized, segment.State) require.True(t, segment.Amount.Equal(realization.Amount), "segment=%s expected=%s", segment.Amount, realization.Amount) require.NotNil(t, segment.BackingTransactionGroupID) require.Equal(t, recognitionGroupID, *segment.BackingTransactionGroupID) require.NotNil(t, segment.SourceState) require.Equal(t, creditrealization.LineageSegmentStateRealCredit, *segment.SourceState) require.Nil(t, segment.SourceBackingTransactionGroupID) } return segmentsByRealization } func (e *usageBasedHandlerTestEnv) ensureCharge(t *testing.T, chargeID string) { t.Helper() _, err := e.DB.Charge.Create(). SetID(chargeID). SetNamespace(e.Namespace). SetType(meta.ChargeTypeUsageBased). Save(t.Context()) require.NoError(t, err) } func (e *usageBasedHandlerTestEnv) realizationsFromAllocations(allocations creditrealization.CreateAllocationInputs) creditrealization.Realizations { now := time.Now().UTC() out := make(creditrealization.Realizations, 0, len(allocations)) for i, allocation := range allocations.AsCreateInputs() { allocation.ID = ulid.Make().String() out = append(out, creditrealization.Realization{ NamespacedModel: models.NamespacedModel{ Namespace: e.Namespace, }, ManagedModel: models.ManagedModel{ CreatedAt: now, UpdatedAt: now, }, CreateInput: allocation, SortHint: i, }) } return out }