Leon4gr45's picture
Upload folder using huggingface_hub (part 3)
1c4c66b verified
Raw
History Blame Contribute Delete
44.4 kB
package service
import (
"context"
"fmt"
"log/slog"
"slices"
"testing"
"time"
"github.com/invopop/gobl/currency"
"github.com/oklog/ulid/v2"
"github.com/samber/lo"
"github.com/samber/mo"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/openmeter/billing/charges"
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
chargesmeta "github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
chargepayment "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
chargestestutils "github.com/openmeterio/openmeter/openmeter/billing/charges/testutils"
"github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased"
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
"github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync"
"github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/adapter"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/openmeter/meter"
"github.com/openmeterio/openmeter/openmeter/productcatalog"
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
"github.com/openmeterio/openmeter/openmeter/productcatalog/plan"
productcatalogsubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription"
"github.com/openmeterio/openmeter/openmeter/subscription"
"github.com/openmeterio/openmeter/openmeter/subscription/patch"
subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow"
"github.com/openmeterio/openmeter/openmeter/testutils"
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/datetime"
"github.com/openmeterio/openmeter/pkg/featuregate"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/pagination"
"github.com/openmeterio/openmeter/pkg/timeutil"
billingtest "github.com/openmeterio/openmeter/test/billing"
)
type SuiteBase struct {
billingtest.BaseSuite
billingtest.SubscriptionMixin
Service *Service
Adapter subscriptionsync.Adapter
Charges charges.Service
Ledger ledger.Ledger
Namespace string
Customer *customer.Customer
APIRequestsTotalFeature feature.Feature
}
func (s *SuiteBase) SetupSuite() {
s.BaseSuite.SetupSuite()
s.SubscriptionMixin.SetupSuite(s.T(), s.GetSubscriptionMixInDependencies())
adapter, err := adapter.New(adapter.Config{
Client: s.DBClient,
})
s.NoError(err)
s.Adapter = adapter
service, err := New(Config{
BillingService: s.BillingService,
Logger: slog.Default(),
Tracer: noop.NewTracerProvider().Tracer("test"),
SubscriptionSyncAdapter: adapter,
SubscriptionService: s.SubscriptionService,
FeatureGate: featuregate.NewFeatureGateChecker(featuregate.NewNoop(), featuregate.Flags{
featuregate.CtxKeyCredits: string(featuregate.CtxKeyCredits),
}, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}),
})
s.NoError(err)
s.Service = service
}
func (s *SuiteBase) setupChargesService(config chargestestutils.Config) {
s.T().Helper()
stack, err := chargestestutils.NewServices(s.T(), config)
s.NoError(err)
s.Charges = stack.ChargesService
service, err := New(Config{
BillingService: s.BillingService,
ChargesService: s.Charges,
Logger: slog.Default(),
Tracer: noop.NewTracerProvider().Tracer("test"),
SubscriptionSyncAdapter: s.Adapter,
SubscriptionService: s.SubscriptionService,
FeatureGate: featuregate.NewFeatureGateChecker(featuregate.NewNoop(), featuregate.Flags{
featuregate.CtxKeyCredits: string(featuregate.CtxKeyCredits),
}, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}),
})
s.NoError(err)
s.Service = service
}
func (s *SuiteBase) BeforeTest(suiteName, testName string) {
s.beforeTest(s.T().Context(), suiteName, testName)
}
func (s *SuiteBase) beforeTest(ctx context.Context, suiteName, testName string) {
s.Namespace = fmt.Sprintf("t-%s-%s-%s", suiteName, testName, ulid.Make().String())
appSandbox := s.InstallSandboxApp(s.T(), s.Namespace)
s.ProvisionBillingProfile(ctx, s.Namespace, appSandbox.GetID())
s.ProvisionDefaultTaxCodes(ctx, s.Namespace)
apiRequestsTotalMeterSlug := "api-requests-total"
apiRequestsTotalMeterID := ulid.Make().String()
testMeter := meter.Meter{
ManagedResource: models.ManagedResource{
ID: apiRequestsTotalMeterID,
NamespacedModel: models.NamespacedModel{
Namespace: s.Namespace,
},
ManagedModel: models.ManagedModel{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: "API Requests Total",
},
Key: apiRequestsTotalMeterSlug,
Aggregation: meter.MeterAggregationSum,
EventType: "test",
ValueProperty: lo.ToPtr("$.value"),
}
err := s.MeterAdapter.ReplaceMeters(ctx, []meter.Meter{testMeter})
s.NoError(err, "Replacing meters must not return error")
apiRequestsTotalFeatureKey := "api-requests-total"
apiRequestsTotalFeature, err := s.FeatureService.CreateFeature(ctx, feature.CreateFeatureInputs{
Namespace: s.Namespace,
Name: "api-requests-total",
Key: apiRequestsTotalFeatureKey,
MeterID: lo.ToPtr(apiRequestsTotalMeterID),
})
s.NoError(err)
s.APIRequestsTotalFeature = apiRequestsTotalFeature
customerEntity := s.CreateTestCustomer(s.Namespace, "test")
require.NotNil(s.T(), customerEntity)
require.NotEmpty(s.T(), customerEntity.ID)
s.Customer = customerEntity
}
func (s *SuiteBase) AfterTest(suiteName, testName string) {
s.afterTest(s.T().Context(), suiteName, testName)
}
func (s *SuiteBase) afterTest(ctx context.Context, suiteName, testName string) {
clock.UnFreeze()
clock.ResetTime()
err := s.MeterAdapter.ReplaceMeters(ctx, []meter.Meter{})
s.NoError(err, "Replacing meters must not return error")
s.MockStreamingConnector.Reset()
s.Service.featureFlags = FeatureFlags{}
}
func (s *SuiteBase) gatheringInvoice(ctx context.Context, namespace string, customerID string) billing.GatheringInvoice {
s.T().Helper()
invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{
Namespaces: []string{namespace},
Customers: []string{customerID},
Page: pagination.Page{
PageSize: 10,
PageNumber: 1,
},
Expand: billing.GatheringInvoiceExpands{
billing.GatheringInvoiceExpandLines,
billing.GatheringInvoiceExpandAvailableActions,
},
})
s.NoError(err)
s.Len(invoices.Items, 1, "expected 1 gathering invoice")
return invoices.Items[0]
}
func (s *SuiteBase) expectNoGatheringInvoice(ctx context.Context, namespace string, customerID string) {
s.T().Helper()
invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{
Namespaces: []string{namespace},
Customers: []string{customerID},
Page: pagination.Page{
PageSize: 10,
PageNumber: 1,
},
Expand: billing.GatheringInvoiceExpands{},
})
s.NoError(err)
if len(invoices.Items) > 0 {
for _, invoice := range invoices.Items {
s.DebugDumpInvoice(fmt.Sprintf("unexpected gathering invoice[%s]", invoice.ID), invoice)
}
}
s.Len(invoices.Items, 0)
}
func (s *SuiteBase) enableProrating() {
s.Service.featureFlags.EnableFlatFeeInAdvanceProrating = true
s.Service.featureFlags.EnableFlatFeeInArrearsProrating = true
}
func (s *SuiteBase) getGatheringLineByChildID(invoice billing.GatheringInvoice, childID string) *billing.GatheringLine {
s.T().Helper()
for idx, line := range invoice.Lines.OrEmpty() {
if line.ChildUniqueReferenceID != nil && *line.ChildUniqueReferenceID == childID {
return &invoice.Lines.OrEmpty()[idx]
}
}
s.Failf("line not found", "line with child id %s not found", childID)
return nil
}
func (s *SuiteBase) getStandardLineByChildID(invoice billing.StandardInvoice, childID string) *billing.StandardLine {
s.T().Helper()
for _, line := range invoice.Lines.OrEmpty() {
if line.ChildUniqueReferenceID != nil && *line.ChildUniqueReferenceID == childID {
return line
}
}
s.Failf("line not found", "line with child id %s not found", childID)
return nil
}
func (s *SuiteBase) expectNoLineWithChildID(invoice billing.GenericInvoiceReader, childID string) {
s.T().Helper()
for _, line := range invoice.GetGenericLines().OrEmpty() {
if line.GetChildUniqueReferenceID() != nil && *line.GetChildUniqueReferenceID() == childID {
s.Failf("line found", "line with child id %s found", childID)
}
}
}
func (s *SuiteBase) timingImmediate() subscription.Timing {
return subscription.Timing{
Enum: lo.ToPtr(subscription.TimingImmediate),
}
}
func (s *SuiteBase) mustParseTime(t string) time.Time {
s.T().Helper()
return lo.Must(time.Parse(time.RFC3339, t))
}
func (s *SuiteBase) testContext() context.Context {
s.T().Helper()
return s.T().Context()
}
func (s *SuiteBase) getPhaseByKey(t *testing.T, subsView subscription.SubscriptionView, key string) subscription.SubscriptionPhaseView {
for _, phase := range subsView.Phases {
if phase.SubscriptionPhase.Key == key {
return phase
}
}
t.Fatalf("phase with key %s not found", key)
return subscription.SubscriptionPhaseView{}
}
type expectedLine struct {
Matcher lineMatcher
Qty mo.Option[float64]
Price mo.Option[*productcatalog.Price]
Periods []timeutil.ClosedPeriod
InvoiceAt mo.Option[[]time.Time]
Charge mo.Option[chargeExpects]
AdditionalChecks func(line billing.GenericInvoiceLine)
}
type chargeExpects struct {
Status string
SettlementMode productcatalog.SettlementMode
}
func (s *SuiteBase) expectLines(invoice billing.GenericInvoiceReader, subscriptionID string, expectedLines []expectedLine) {
s.T().Helper()
lines := invoice.GetGenericLines()
if lines.IsAbsent() {
s.Failf("lines not found", "lines not found for invoice %s", invoice.GetID())
}
existingLineChildIDs := lo.Map(lines.OrEmpty(), func(line billing.GenericInvoiceLine, _ int) string {
return lo.FromPtrOr(line.GetChildUniqueReferenceID(), line.GetID())
})
expectedLineIDs := lo.Flatten(lo.Map(expectedLines, func(expectedLine expectedLine, _ int) []string {
return expectedLine.Matcher.ChildIDs(subscriptionID)
}))
s.ElementsMatch(expectedLineIDs, existingLineChildIDs)
for _, expectedLine := range expectedLines {
childIDs := expectedLine.Matcher.ChildIDs(subscriptionID)
for idx, childID := range childIDs {
line, found := lo.Find(lines.OrEmpty(), func(line billing.GenericInvoiceLine) bool {
return lo.FromPtrOr(line.GetChildUniqueReferenceID(), line.GetID()) == childID
})
s.Truef(found, "line not found with child id %s", childID)
s.NotNil(line)
if expectedLine.Qty.IsPresent() {
lineQuantityAccessor, ok := line.(billing.QuantityAccessor)
if !ok {
s.Failf("line is not a quantity accessor", "line is not a quantity accessor with child id %s", childID)
}
lineQuantity := lineQuantityAccessor.GetQuantity()
if lineQuantity == nil {
s.Failf("line quantity not found", "line quantity not found with child id %s", childID)
} else {
s.Equal(expectedLine.Qty.OrEmpty(), lineQuantity.InexactFloat64(), "%s: quantity", childID)
}
}
if expectedLine.Price.IsPresent() {
expectedPrice := expectedLine.Price.OrEmpty()
actualPrice := line.GetPrice()
s.Truef(expectedPrice.Equal(actualPrice), "%s: price expected %v, got %v", childID, expectedPrice, actualPrice)
}
s.Equal(expectedLine.Periods[idx].From, line.GetServicePeriod().From, "%s: period start", childID)
s.Equal(expectedLine.Periods[idx].To, line.GetServicePeriod().To, "%s: period end", childID)
if expectedLine.InvoiceAt.IsPresent() {
invoiceAtAccessor, ok := line.(billing.InvoiceAtAccessor)
if !ok {
s.Failf("line is not a invoice at accessor", "line is not a invoice at accessor with child id %s", childID)
}
invoiceAt := invoiceAtAccessor.GetInvoiceAt()
s.Equal(expectedLine.InvoiceAt.OrEmpty()[idx], invoiceAt, "%s: invoice at", childID)
}
if expectedLine.AdditionalChecks != nil {
expectedLine.AdditionalChecks(line)
}
}
}
}
type expectedCharge struct {
Matcher lineMatcher
Type chargesmeta.ChargeType
Status string
Price *productcatalog.Price
Periods []timeutil.ClosedPeriod
FullServicePeriods []timeutil.ClosedPeriod
BillingPeriods []timeutil.ClosedPeriod
InvoiceAt []*time.Time
GatheringLines []expectedChargeGatheringLine
Realizations []expectedChargeRealization
}
type expectedChargeGatheringLine struct {
LineMatcher lineMatcher
Period timeutil.ClosedPeriod
Price *productcatalog.Price
InvoiceAt *time.Time
}
type expectedChargeRealization struct {
LineMatcher lineMatcher
Period timeutil.ClosedPeriod
Status billing.StandardInvoiceStatus
IsVoided bool
BookedAt time.Time
Price *productcatalog.Price
Totals totals.Totals
}
type actualChargeGatheringLine struct {
Period timeutil.ClosedPeriod
Price *productcatalog.Price
InvoiceAt time.Time
}
type chargeRealizationKey struct {
Period timeutil.ClosedPeriod
Status billing.StandardInvoiceStatus
IsVoided bool
}
type actualChargeLedgerTransactionGroup struct {
ID string
Label string
}
func (s *SuiteBase) assertCharges(ctx context.Context, subsView subscription.SubscriptionView, expectedCharges []expectedCharge) {
s.T().Helper()
subscriptionID := subsView.Subscription.NamespacedID
res, err := s.Charges.ListCharges(ctx, charges.ListChargesInput{
Namespace: subscriptionID.Namespace,
SubscriptionIDs: []string{subscriptionID.ID},
IncludeDeleted: true,
Expands: chargesmeta.Expands{
chargesmeta.ExpandRealizations,
chargesmeta.ExpandDeletedRealizations,
},
})
s.NoError(err)
expectedChargeIDs := lo.Flatten(lo.Map(expectedCharges, func(expectedCharge expectedCharge, _ int) []string {
s.Require().NotNil(expectedCharge.Matcher, "expected charge matcher")
return expectedCharge.Matcher.ChildIDs(subscriptionID.ID)
}))
actualChargeIDs := lo.Map(res.Items, func(charge charges.Charge, _ int) string {
uniqueReferenceID, err := charge.GetUniqueReferenceID()
s.NoError(err)
s.Require().NotNil(uniqueReferenceID, "charge %s child unique reference id", charge.GetID())
return *uniqueReferenceID
})
s.Len(res.Items, len(expectedChargeIDs))
s.ElementsMatch(expectedChargeIDs, actualChargeIDs)
for _, expectedCharge := range expectedCharges {
s.Require().NotNil(expectedCharge.Matcher, "expected charge matcher")
childIDs := expectedCharge.Matcher.ChildIDs(subscriptionID.ID)
s.Require().NotEmpty(expectedCharge.Type, "expected charge type")
s.Require().NotEmpty(expectedCharge.Status, "expected charge status")
s.Require().Len(expectedCharge.Periods, len(childIDs), "expected charge periods")
if len(expectedCharge.InvoiceAt) > 0 {
s.Require().Len(expectedCharge.InvoiceAt, len(childIDs), "expected charge invoice at")
}
if len(expectedCharge.FullServicePeriods) > 0 {
s.Require().Len(expectedCharge.FullServicePeriods, len(childIDs), "expected charge full service periods")
}
if len(expectedCharge.BillingPeriods) > 0 {
s.Require().Len(expectedCharge.BillingPeriods, len(childIDs), "expected charge billing periods")
}
for idx, childID := range childIDs {
charge, found := lo.Find(res.Items, func(charge charges.Charge) bool {
uniqueReferenceID, err := charge.GetUniqueReferenceID()
s.NoError(err)
return uniqueReferenceID != nil && *uniqueReferenceID == childID
})
s.Require().Truef(found, "charge not found with child unique reference id %s", childID)
s.assertCharge(ctx, charge, subsView, childID, childIDs, expectedCharge, idx)
}
}
}
func (s *SuiteBase) assertCharge(ctx context.Context, charge charges.Charge, subsView subscription.SubscriptionView, childID string, expectedChargeIDs []string, expectedCharge expectedCharge, idx int) {
s.T().Helper()
chargeID, err := charge.GetChargeID()
s.NoError(err)
s.Equal(expectedCharge.Type, charge.Type(), "%s: type", childID)
phaseKey, itemKey, _ := s.chargeExpectedLineMatcherParts(expectedCharge.Matcher)
phase := s.getPhaseByKey(s.T(), subsView, phaseKey)
switch charge.Type() {
case chargesmeta.ChargeTypeUsageBased:
usageBasedCharge, err := charge.AsUsageBasedCharge()
s.NoError(err)
baseIntent := usageBasedCharge.Intent.GetBaseIntent()
subscription := usageBasedCharge.Intent.GetSubscription()
s.Equal(expectedCharge.Status, string(usageBasedCharge.Status), "%s: status", childID)
s.Equal(subsView.Subscription.SettlementMode, usageBasedCharge.Intent.GetSettlementMode(), "%s: settlement mode", childID)
s.Equal(s.Customer.ID, usageBasedCharge.Intent.GetCustomerID(), "%s: customer id", childID)
s.Equal(subsView.Subscription.Currency, usageBasedCharge.Intent.GetCurrency(), "%s: currency", childID)
s.Equal(expectedCharge.Periods[idx], baseIntent.ServicePeriod, "%s: service period", childID)
if len(expectedCharge.FullServicePeriods) > 0 {
s.Equal(expectedCharge.FullServicePeriods[idx], baseIntent.FullServicePeriod, "%s: full service period", childID)
}
if len(expectedCharge.BillingPeriods) > 0 {
s.Equal(expectedCharge.BillingPeriods[idx], baseIntent.BillingPeriod, "%s: billing period", childID)
}
if expectedCharge.Price != nil {
s.Truef(expectedCharge.Price.Equal(&baseIntent.Price), "%s: price expected %v, got %v", childID, expectedCharge.Price, baseIntent.Price)
}
if len(expectedCharge.InvoiceAt) > idx && expectedCharge.InvoiceAt[idx] != nil {
s.Equal(*expectedCharge.InvoiceAt[idx], baseIntent.InvoiceAt, "%s: invoice at", childID)
}
s.Require().NotNil(subscription, "%s: subscription", childID)
s.Equal(subsView.Subscription.ID, subscription.SubscriptionID, "%s: subscription id", childID)
s.Equal(phase.SubscriptionPhase.ID, subscription.PhaseID, "%s: phase id", childID)
// Deleted charges can reference soft-deleted subscription items, and subscription views never
// contain soft-deleted items.
item, itemFound := s.subscriptionItemByID(phase, itemKey, subscription.ItemID, childID, usageBasedCharge.DeletedAt != nil)
expectedFeatureKey := itemKey
if itemFound {
expectedFeatureKey = lo.FromPtrOr(item.Spec.RateCard.AsMeta().FeatureKey, itemKey)
}
s.Equal(expectedFeatureKey, baseIntent.FeatureKey, "%s: feature key", childID)
case chargesmeta.ChargeTypeFlatFee:
flatFeeCharge, err := charge.AsFlatFeeCharge()
s.NoError(err)
baseIntent := flatFeeCharge.Intent.GetBaseIntent()
subscription := flatFeeCharge.Intent.GetSubscription()
s.Equal(expectedCharge.Status, string(flatFeeCharge.Status), "%s: status", childID)
s.Equal(subsView.Subscription.SettlementMode, flatFeeCharge.Intent.GetSettlementMode(), "%s: settlement mode", childID)
s.Equal(s.Customer.ID, flatFeeCharge.Intent.GetCustomerID(), "%s: customer id", childID)
s.Equal(subsView.Subscription.Currency, flatFeeCharge.Intent.GetCurrency(), "%s: currency", childID)
s.Equal(expectedCharge.Periods[idx], baseIntent.ServicePeriod, "%s: service period", childID)
if len(expectedCharge.FullServicePeriods) > 0 {
s.Equal(expectedCharge.FullServicePeriods[idx], baseIntent.FullServicePeriod, "%s: full service period", childID)
}
if len(expectedCharge.BillingPeriods) > 0 {
s.Equal(expectedCharge.BillingPeriods[idx], baseIntent.BillingPeriod, "%s: billing period", childID)
}
if expectedCharge.Price != nil {
expectedFlatPrice, err := expectedCharge.Price.AsFlat()
s.NoError(err)
require.Equal(s.T(), expectedFlatPrice.Amount.InexactFloat64(), baseIntent.AmountBeforeProration.InexactFloat64(), fmt.Sprintf("%s: amount before proration", childID))
}
if len(expectedCharge.InvoiceAt) > idx && expectedCharge.InvoiceAt[idx] != nil {
s.Equal(*expectedCharge.InvoiceAt[idx], baseIntent.InvoiceAt, "%s: invoice at", childID)
}
s.Require().NotNil(subscription, "%s: subscription", childID)
s.Equal(subsView.Subscription.ID, subscription.SubscriptionID, "%s: subscription id", childID)
s.Equal(phase.SubscriptionPhase.ID, subscription.PhaseID, "%s: phase id", childID)
// Deleted charges can reference soft-deleted subscription items, and subscription views never
// contain soft-deleted items.
if item, itemFound := s.subscriptionItemByID(phase, itemKey, subscription.ItemID, childID, flatFeeCharge.DeletedAt != nil); itemFound {
s.Equal(item.SubscriptionItem.ID, subscription.ItemID, "%s: item id", childID)
}
default:
s.Failf("unsupported charge type", "charge %s has unsupported type %s", chargeID.ID, charge.Type())
}
s.assertChargePaymentLedgerTransactions(ctx, charge, childID)
s.assertChargeGatheringLines(ctx, charge, subsView.Subscription.ID, childID, expectedChargeIDs, expectedCharge.Periods[idx], expectedCharge.Price, expectedCharge.GatheringLines)
expectedRealizations := s.expectedRealizationsForCharge(subsView.Subscription.ID, childID, expectedChargeIDs, expectedCharge.Periods[idx], expectedCharge.Realizations)
actualRealizations := s.chargeRealizations(ctx, charge)
expectedRealizationKeys := lo.Map(expectedRealizations, func(realization expectedChargeRealization, _ int) chargeRealizationKey {
return chargeRealizationKey{
Period: realization.Period,
Status: realization.Status,
IsVoided: realization.IsVoided,
}
})
actualRealizationKeys := lo.Map(actualRealizations, func(realization actualChargeRealization, _ int) chargeRealizationKey {
return chargeRealizationKey{
Period: realization.Period,
Status: realization.Status,
IsVoided: realization.IsVoided,
}
})
s.ElementsMatch(expectedRealizationKeys, actualRealizationKeys, "%s: realizations", childID)
remainingActualRealizations := slices.Clone(actualRealizations)
for _, expectedRealization := range expectedRealizations {
actualRealization, idx, found := lo.FindIndexOf(remainingActualRealizations, func(realization actualChargeRealization) bool {
return realization.Period == expectedRealization.Period &&
realization.Status == expectedRealization.Status &&
realization.IsVoided == expectedRealization.IsVoided
})
if !found {
s.Failf("realization not found", "realization not found for charge %s with status %s and period %s", childID, expectedRealization.Status, expectedRealization.Period)
continue
}
remainingActualRealizations = slices.Delete(remainingActualRealizations, idx, idx+1)
expectedPrice := expectedRealization.Price
if expectedPrice == nil {
expectedPrice = expectedCharge.Price
}
if expectedPrice != nil {
s.Truef(expectedPrice.Equal(actualRealization.Price), "%s: realization price expected %v, got %v", childID, expectedPrice, actualRealization.Price)
}
if !expectedRealization.Totals.IsZero() {
s.Truef(expectedRealization.Totals.Equal(actualRealization.Totals), "%s: realization totals expected %v, got %v", childID, expectedRealization.Totals, actualRealization.Totals)
}
s.assertChargeRealizationLedgerTransactions(ctx, chargeID.Namespace, childID, expectedRealization, actualRealization)
}
}
func (s *SuiteBase) assertChargeRealizationLedgerTransactions(ctx context.Context, namespace string, childID string, expectedRealization expectedChargeRealization, actualRealization actualChargeRealization) {
s.T().Helper()
s.Require().False(expectedRealization.BookedAt.IsZero(), "%s: realization booked_at", childID)
if len(actualRealization.LedgerTransactionGroups) == 0 {
s.Require().False(expectedChargeRealizationRequiresLedgerTransactionGroups(expectedRealization), "%s: realization ledger transaction groups", childID)
return
}
for _, transactionGroup := range actualRealization.LedgerTransactionGroups {
s.assertLedgerTransactionGroupBookedAt(ctx, namespace, transactionGroup.ID, expectedRealization.BookedAt, fmt.Sprintf("%s: %s", childID, transactionGroup.Label))
}
}
func expectedChargeRealizationRequiresLedgerTransactionGroups(expectedRealization expectedChargeRealization) bool {
if expectedRealization.Totals.IsZero() {
return false
}
switch expectedRealization.Status.ShortStatus() {
case "draft", "gathering", "delete":
return false
default:
return true
}
}
func (s *SuiteBase) assertChargePaymentLedgerTransactions(ctx context.Context, charge charges.Charge, childID string) {
s.T().Helper()
chargeID, err := charge.GetChargeID()
s.NoError(err)
switch charge.Type() {
case chargesmeta.ChargeTypeFlatFee:
flatFeeCharge, err := charge.AsFlatFeeCharge()
s.NoError(err)
runs := slices.Clone(flatFeeCharge.Realizations.PriorRuns)
if flatFeeCharge.Realizations.CurrentRun != nil {
runs = append(runs, *flatFeeCharge.Realizations.CurrentRun)
}
for _, run := range runs {
s.assertPaymentLedgerTransactions(ctx, chargeID.Namespace, childID, run.Payment)
}
case chargesmeta.ChargeTypeUsageBased:
usageBasedCharge, err := charge.AsUsageBasedCharge()
s.NoError(err)
for _, run := range usageBasedCharge.Realizations {
s.assertPaymentLedgerTransactions(ctx, chargeID.Namespace, childID, run.Payment)
}
}
}
func (s *SuiteBase) assertPaymentLedgerTransactions(ctx context.Context, namespace string, childID string, payment *chargepayment.Invoiced) {
s.T().Helper()
if payment == nil {
return
}
if payment.Authorized != nil && payment.Authorized.TransactionGroupID != "" {
s.assertLedgerTransactionGroupBookedAt(ctx, namespace, payment.Authorized.TransactionGroupID, payment.Authorized.Time, fmt.Sprintf("%s: payment authorization", childID))
}
if payment.Settled != nil && payment.Settled.TransactionGroupID != "" {
s.assertLedgerTransactionGroupBookedAt(ctx, namespace, payment.Settled.TransactionGroupID, payment.Settled.Time, fmt.Sprintf("%s: payment settlement", childID))
}
}
func (s *SuiteBase) assertLedgerTransactionGroupBookedAt(ctx context.Context, namespace string, groupID string, expectedBookedAt time.Time, label string) {
s.T().Helper()
s.Require().NotEmpty(groupID, "%s: ledger transaction group id", label)
s.Require().False(expectedBookedAt.IsZero(), "%s: expected booked_at", label)
s.Require().NotNil(s.Ledger, "%s: ledger service", label)
group, err := s.Ledger.GetTransactionGroup(ctx, models.NamespacedID{
Namespace: namespace,
ID: groupID,
})
s.NoError(err, "%s: get ledger transaction group %s", label, groupID)
transactions := group.Transactions()
s.Require().NotEmpty(transactions, "%s: ledger transaction group %s transactions", label, groupID)
for _, transaction := range transactions {
s.Truef(
transaction.BookedAt().UTC().Equal(expectedBookedAt.UTC()),
"%s: transaction %s in group %s booked_at expected %s, got %s",
label,
transaction.ID().ID,
groupID,
expectedBookedAt.UTC(),
transaction.BookedAt().UTC(),
)
}
}
func (s *SuiteBase) subscriptionItemByID(phase subscription.SubscriptionPhaseView, itemKey string, itemID string, childID string, allowMissing bool) (subscription.SubscriptionItemView, bool) {
s.T().Helper()
item, found := lo.Find(phase.ItemsByKey[itemKey], func(item subscription.SubscriptionItemView) bool {
return item.SubscriptionItem.ID == itemID
})
if !allowMissing {
s.Require().Truef(found, "%s: subscription item id %s belongs to phase %s item %s", childID, itemID, phase.SubscriptionPhase.Key, itemKey)
}
return item, found
}
func (s *SuiteBase) assertChargeGatheringLines(ctx context.Context, charge charges.Charge, subscriptionID string, childID string, expectedChargeIDs []string, chargePeriod timeutil.ClosedPeriod, chargePrice *productcatalog.Price, expectedGatheringLines []expectedChargeGatheringLine) {
s.T().Helper()
expectedLines := s.expectedGatheringLinesForCharge(subscriptionID, childID, expectedChargeIDs, chargePeriod, expectedGatheringLines)
actualLines := s.gatheringChargeLines(ctx, charge)
expectedKeys := lo.Map(expectedLines, func(line expectedChargeGatheringLine, _ int) timeutil.ClosedPeriod {
return line.Period
})
actualKeys := lo.Map(actualLines, func(line actualChargeGatheringLine, _ int) timeutil.ClosedPeriod {
return line.Period
})
s.ElementsMatch(expectedKeys, actualKeys, "%s: gathering lines", childID)
remainingActualLines := slices.Clone(actualLines)
for _, expectedLine := range expectedLines {
actualLine, idx, found := lo.FindIndexOf(remainingActualLines, func(line actualChargeGatheringLine) bool {
return line.Period == expectedLine.Period
})
if !found {
s.Failf("gathering line not found", "gathering line not found for charge %s with period %s", childID, expectedLine.Period)
continue
}
remainingActualLines = slices.Delete(remainingActualLines, idx, idx+1)
expectedPrice := expectedLine.Price
if expectedPrice == nil {
expectedPrice = chargePrice
}
if expectedPrice != nil {
s.Truef(expectedPrice.Equal(actualLine.Price), "%s: gathering line price expected %v, got %v", childID, expectedPrice, actualLine.Price)
}
if expectedLine.InvoiceAt != nil {
s.Equal(*expectedLine.InvoiceAt, actualLine.InvoiceAt, "%s: gathering line invoice at", childID)
}
}
}
func (s *SuiteBase) expectedGatheringLinesForCharge(subscriptionID string, childID string, expectedChargeIDs []string, chargePeriod timeutil.ClosedPeriod, expectedGatheringLines []expectedChargeGatheringLine) []expectedChargeGatheringLine {
s.T().Helper()
return lo.FilterMap(expectedGatheringLines, func(line expectedChargeGatheringLine, _ int) (expectedChargeGatheringLine, bool) {
if line.LineMatcher == nil {
s.Require().Len(expectedChargeIDs, 1, "%s: gathering line matcher is required when a charge expectation expands to multiple charges", childID)
if lo.IsEmpty(line.Period) {
line.Period = chargePeriod
}
return line, true
}
gatheringLineChildIDs := line.LineMatcher.ChildIDs(subscriptionID)
matchingChargeIDs := lo.Intersect(gatheringLineChildIDs, expectedChargeIDs)
s.Require().NotEmpty(matchingChargeIDs, "%s: gathering line matcher must belong to the charge expectation", childID)
if len(expectedChargeIDs) == 1 {
s.Require().Contains(gatheringLineChildIDs, childID, "%s: gathering line matcher must match the charge", childID)
}
if lo.IsEmpty(line.Period) {
line.Period = chargePeriod
}
return line, lo.Contains(gatheringLineChildIDs, childID)
})
}
func (s *SuiteBase) expectedRealizationsForCharge(subscriptionID string, childID string, expectedChargeIDs []string, chargePeriod timeutil.ClosedPeriod, expectedRealizations []expectedChargeRealization) []expectedChargeRealization {
s.T().Helper()
return lo.FilterMap(expectedRealizations, func(realization expectedChargeRealization, _ int) (expectedChargeRealization, bool) {
if realization.LineMatcher == nil {
s.Require().Len(expectedChargeIDs, 1, "%s: realization matcher is required when a charge expectation expands to multiple charges", childID)
if lo.IsEmpty(realization.Period) {
realization.Period = chargePeriod
}
return realization, true
}
realizationChildIDs := realization.LineMatcher.ChildIDs(subscriptionID)
matchingChargeIDs := lo.Intersect(realizationChildIDs, expectedChargeIDs)
s.Require().NotEmpty(matchingChargeIDs, "%s: realization matcher must belong to the charge expectation", childID)
if len(expectedChargeIDs) == 1 {
s.Require().Contains(realizationChildIDs, childID, "%s: realization matcher must match the charge", childID)
}
if lo.IsEmpty(realization.Period) {
realization.Period = chargePeriod
}
return realization, lo.Contains(realizationChildIDs, childID)
})
}
func (s *SuiteBase) chargeRealizations(ctx context.Context, charge charges.Charge) []actualChargeRealization {
s.T().Helper()
chargeID, err := charge.GetChargeID()
s.NoError(err)
var out []actualChargeRealization
switch charge.Type() {
case chargesmeta.ChargeTypeUsageBased:
usageBasedCharge, err := charge.AsUsageBasedCharge()
s.NoError(err)
for _, run := range usageBasedCharge.Realizations {
if run.DeletedAt != nil {
continue
}
if run.InvoiceID == nil || run.LineID == nil {
continue
}
realization := s.standardLineChargeRealization(ctx, billing.InvoiceID{
Namespace: chargeID.Namespace,
ID: *run.InvoiceID,
}, *run.LineID)
realization.IsVoided = run.IsVoidedBillingHistory()
realization.LedgerTransactionGroups = s.usageBasedRunLedgerTransactionGroups(run)
out = append(out, realization)
}
case chargesmeta.ChargeTypeFlatFee:
flatFeeCharge, err := charge.AsFlatFeeCharge()
s.NoError(err)
runs := flatFeeCharge.Realizations.PriorRuns
if flatFeeCharge.Realizations.CurrentRun != nil {
runs = append(runs, *flatFeeCharge.Realizations.CurrentRun)
}
for _, run := range runs {
if run.DeletedAt != nil {
continue
}
if run.InvoiceID == nil || run.LineID == nil {
continue
}
realization := s.standardLineChargeRealization(ctx, billing.InvoiceID{
Namespace: chargeID.Namespace,
ID: *run.InvoiceID,
}, *run.LineID)
realization.IsVoided = run.IsVoidedBillingHistory()
realization.LedgerTransactionGroups = s.flatFeeRunLedgerTransactionGroups(run)
out = append(out, realization)
}
}
return out
}
func (s *SuiteBase) usageBasedRunLedgerTransactionGroups(run usagebased.RealizationRun) []actualChargeLedgerTransactionGroup {
s.T().Helper()
out := make([]actualChargeLedgerTransactionGroup, 0, len(run.CreditsAllocated)+1)
for _, realization := range run.CreditsAllocated {
if realization.LedgerTransaction.TransactionGroupID == "" {
continue
}
out = append(out, actualChargeLedgerTransactionGroup{
ID: realization.LedgerTransaction.TransactionGroupID,
Label: fmt.Sprintf("usage-based credit realization %s", realization.ID),
})
}
if run.InvoiceUsage != nil && run.InvoiceUsage.LedgerTransaction != nil && run.InvoiceUsage.LedgerTransaction.TransactionGroupID != "" {
out = append(out, actualChargeLedgerTransactionGroup{
ID: run.InvoiceUsage.LedgerTransaction.TransactionGroupID,
Label: fmt.Sprintf("usage-based invoice usage %s", run.InvoiceUsage.ID),
})
}
return out
}
func (s *SuiteBase) flatFeeRunLedgerTransactionGroups(run flatfee.RealizationRun) []actualChargeLedgerTransactionGroup {
s.T().Helper()
out := make([]actualChargeLedgerTransactionGroup, 0, len(run.CreditRealizations)+1)
for _, realization := range run.CreditRealizations {
if realization.LedgerTransaction.TransactionGroupID == "" {
continue
}
out = append(out, actualChargeLedgerTransactionGroup{
ID: realization.LedgerTransaction.TransactionGroupID,
Label: fmt.Sprintf("flat fee credit realization %s", realization.ID),
})
}
if run.AccruedUsage != nil && run.AccruedUsage.LedgerTransaction != nil && run.AccruedUsage.LedgerTransaction.TransactionGroupID != "" {
out = append(out, actualChargeLedgerTransactionGroup{
ID: run.AccruedUsage.LedgerTransaction.TransactionGroupID,
Label: fmt.Sprintf("flat fee accrued usage %s", run.AccruedUsage.ID),
})
}
return out
}
type actualChargeRealization struct {
Period timeutil.ClosedPeriod
Status billing.StandardInvoiceStatus
IsVoided bool
Price *productcatalog.Price
Totals totals.Totals
LedgerTransactionGroups []actualChargeLedgerTransactionGroup
}
func (s *SuiteBase) standardLineChargeRealization(ctx context.Context, invoiceID billing.InvoiceID, lineID string) actualChargeRealization {
s.T().Helper()
invoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{
Invoice: invoiceID,
Expand: billing.StandardInvoiceExpandAll,
})
s.NoError(err)
line := invoice.Lines.GetByID(lineID)
s.Require().NotNil(line, "standard invoice line %s", lineID)
return actualChargeRealization{
Period: line.Period,
Status: invoice.Status,
Price: line.GetPrice(),
Totals: line.Totals,
}
}
func (s *SuiteBase) gatheringChargeLines(ctx context.Context, charge charges.Charge) []actualChargeGatheringLine {
s.T().Helper()
chargeID, err := charge.GetChargeID()
s.NoError(err)
customerID, err := charge.GetCustomerID()
s.NoError(err)
invoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{
Namespaces: []string{chargeID.Namespace},
Customers: []string{customerID.ID},
Page: pagination.Page{
PageSize: 100,
PageNumber: 1,
},
Expand: billing.GatheringInvoiceExpands{
billing.GatheringInvoiceExpandLines,
},
})
s.NoError(err)
var out []actualChargeGatheringLine
for _, invoice := range invoices.Items {
for _, line := range invoice.Lines.OrEmpty() {
if line.ChargeID == nil || *line.ChargeID != chargeID.ID {
continue
}
out = append(out, actualChargeGatheringLine{
Period: line.ServicePeriod,
Price: line.GetPrice(),
InvoiceAt: line.GetInvoiceAt(),
})
}
}
return out
}
type lineMatcher interface {
ChildIDs(subsID string) []string
}
func (s *SuiteBase) chargeExpectedLineMatcherParts(matcher lineMatcher) (string, string, int) {
s.T().Helper()
switch matcher := matcher.(type) {
case recurringLineMatcher:
return matcher.PhaseKey, matcher.ItemKey, matcher.Version
case oneTimeLineMatcher:
return matcher.PhaseKey, matcher.ItemKey, matcher.Version
default:
s.T().Fatalf("charge assertion does not support matcher type %T", matcher)
return "", "", 0
}
}
type recurringLineMatcher struct {
PhaseKey string
ItemKey string
Version int
PeriodMin int
PeriodMax int
}
func (m recurringLineMatcher) ChildIDs(subsID string) []string {
out := []string{}
for periodID := m.PeriodMin; periodID <= m.PeriodMax; periodID++ {
out = append(out, fmt.Sprintf("%s/%s/%s/v[%d]/period[%d]", subsID, m.PhaseKey, m.ItemKey, m.Version, periodID))
}
return out
}
type oneTimeLineMatcher struct {
PhaseKey string
ItemKey string
Version int
}
func (m oneTimeLineMatcher) ChildIDs(subsID string) []string {
return []string{fmt.Sprintf("%s/%s/%s/v[%d]", subsID, m.PhaseKey, m.ItemKey, m.Version)}
}
func (s *SuiteBase) phaseMeta(key string, duration string) productcatalog.PhaseMeta {
out := productcatalog.PhaseMeta{
Key: key,
Name: key,
}
if duration != "" {
out.Duration = lo.ToPtr(datetime.MustParseDuration(s.T(), duration))
}
return out
}
func (s *SuiteBase) enableProgressiveBilling() {
s.updateProfile(func(profile *billing.Profile) {
profile.WorkflowConfig.Invoicing.ProgressiveBilling = true
})
}
func (s *SuiteBase) updateProfile(modify func(profile *billing.Profile)) {
defaultProfile, err := s.BillingService.GetDefaultProfile(s.T().Context(), billing.GetDefaultProfileInput{
Namespace: s.Namespace,
})
s.NoError(err)
modify(defaultProfile)
defaultProfile.AppReferences = nil
_, err = s.BillingService.UpdateProfile(s.T().Context(), billing.UpdateProfileInput(defaultProfile.BaseProfile))
s.NoError(err)
}
type subscriptionAddItem struct {
PhaseKey string
ItemKey string
Price *productcatalog.Price
BillingCadence *datetime.ISODuration
FeatureKey string
TaxConfig *productcatalog.TaxConfig
}
func (i subscriptionAddItem) AsPatch() subscription.Patch {
var rc productcatalog.RateCard
meta := productcatalog.RateCardMeta{
Name: i.ItemKey,
Key: i.ItemKey,
Price: i.Price,
FeatureKey: lo.EmptyableToPtr(i.FeatureKey),
TaxConfig: i.TaxConfig,
}
switch {
case i.Price == nil:
rc = &productcatalog.FlatFeeRateCard{
RateCardMeta: meta,
BillingCadence: i.BillingCadence,
}
case i.Price.Type() == productcatalog.FlatPriceType:
rc = &productcatalog.FlatFeeRateCard{
RateCardMeta: meta,
BillingCadence: i.BillingCadence,
}
default:
rc = &productcatalog.UsageBasedRateCard{
RateCardMeta: meta,
BillingCadence: *i.BillingCadence,
}
}
return patch.PatchAddItem{
PhaseKey: i.PhaseKey,
ItemKey: i.ItemKey,
CreateInput: subscription.SubscriptionItemSpec{
CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{
PhaseKey: i.PhaseKey,
ItemKey: i.ItemKey,
RateCard: rc,
},
},
},
}
}
func (s *SuiteBase) generatePeriods(startStr, endStr string, cadenceStr string, n int) []timeutil.ClosedPeriod { //nolint: unparam
start := testutils.GetRFC3339Time(s.T(), startStr)
end := testutils.GetRFC3339Time(s.T(), endStr)
cadence := datetime.MustParseDuration(s.T(), cadenceStr)
out := []timeutil.ClosedPeriod{}
for n != 0 {
out = append(out, timeutil.ClosedPeriod{
From: start,
To: end,
})
start, _ = cadence.AddTo(start)
end, _ = cadence.AddTo(end)
n--
}
return out
}
// populateChildIDsFromParents copies over the child ID from the parent line, if it's not already set
// as line splitting doesn't set the child ID on child lines to prevent conflicts if multiple split lines
// end up on a single invoice.
func (s *SuiteBase) populateChildIDsFromParents(invoice billing.GenericInvoice) {
genericLinesOption := invoice.GetGenericLines()
if genericLinesOption.IsAbsent() {
s.Failf("lines not found", "lines not found for invoice %s", invoice.GetID())
}
genericLines := genericLinesOption.OrEmpty()
for idx, line := range genericLines {
if line.GetChildUniqueReferenceID() == nil && line.GetSplitLineGroupID() != nil {
invoiceLine := line.AsInvoiceLine()
switch invoiceLine.Type() {
case billing.InvoiceLineTypeStandard:
stdInvoiceLine, err := invoiceLine.AsStandardLine()
s.NoError(err)
line.SetChildUniqueReferenceID(stdInvoiceLine.SplitLineHierarchy.Group.UniqueReferenceID)
case billing.InvoiceLineTypeGathering:
splitLineGroupID := line.GetSplitLineGroupID()
if splitLineGroupID == nil {
s.Failf("split line group id not found", "split line group id not found for line %s", line.GetID())
return
}
splitLineGroup, err := s.BillingAdapter.GetSplitLineGroup(s.T().Context(), billing.GetSplitLineGroupInput{
Namespace: s.Namespace,
ID: *splitLineGroupID,
})
s.NoError(err)
line.SetChildUniqueReferenceID(splitLineGroup.Group.UniqueReferenceID)
default:
s.Failf("unexpected line type", "unexpected line type %s for line %s", invoiceLine.Type(), line.GetID())
}
}
genericLines[idx] = line
}
err := invoice.SetLines(genericLines)
s.NoError(err)
}
func (s *SuiteBase) createSubscriptionFromPlanPhases(phases []productcatalog.Phase) subscription.SubscriptionView {
planInput := plan.CreatePlanInput{
NamespacedModel: models.NamespacedModel{
Namespace: s.Namespace,
},
Plan: productcatalog.Plan{
PlanMeta: productcatalog.PlanMeta{
Name: "Test Plan",
Key: "test-plan",
Version: 1,
Currency: currency.USD,
BillingCadence: datetime.MustParseDuration(s.T(), "P1M"),
ProRatingConfig: productcatalog.ProRatingConfig{
Enabled: true,
Mode: productcatalog.ProRatingModeProratePrices,
},
},
Phases: phases,
},
}
return s.createSubscriptionFromPlan(planInput)
}
func (s *SuiteBase) createSubscriptionFromPlan(planInput plan.CreatePlanInput) subscription.SubscriptionView {
return s.createSubscriptionFromPlanAt(planInput, clock.Now())
}
func (s *SuiteBase) createSubscriptionFromPlanAt(planInput plan.CreatePlanInput, startAt time.Time) subscription.SubscriptionView {
ctx := s.T().Context()
plan, err := s.PlanService.CreatePlan(ctx, planInput)
s.NoError(err)
subscriptionPlan, err := s.SubscriptionPlanAdapter.GetVersion(ctx, s.Namespace, productcatalogsubscription.PlanRefInput{
Key: plan.Key,
Version: lo.ToPtr(1),
})
s.NoError(err)
subsView, err := s.SubscriptionWorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: lo.ToPtr(startAt),
},
Name: "subs-1",
},
Namespace: s.Namespace,
CustomerID: s.Customer.ID,
}, subscriptionPlan)
s.NoError(err)
s.NotNil(subsView)
return subsView
}