@@ -5,10 +5,12 @@ import (
55 "time"
66
77 "github.com/oklog/ulid/v2"
8+ "github.com/samber/lo"
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/require"
1011
1112 "github.com/openmeterio/openmeter/openmeter/app"
13+ "github.com/openmeterio/openmeter/openmeter/billing"
1214 "github.com/openmeterio/openmeter/openmeter/customer"
1315 customeradapter "github.com/openmeterio/openmeter/openmeter/customer/adapter"
1416 entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
@@ -19,7 +21,10 @@ import (
1921 customersubjectsdb "github.com/openmeterio/openmeter/openmeter/ent/db/customersubjects"
2022 "github.com/openmeterio/openmeter/openmeter/testutils"
2123 "github.com/openmeterio/openmeter/pkg/clock"
24+ "github.com/openmeterio/openmeter/pkg/datetime"
25+ "github.com/openmeterio/openmeter/pkg/filter"
2226 "github.com/openmeterio/openmeter/pkg/models"
27+ "github.com/openmeterio/openmeter/pkg/pagination"
2328)
2429
2530type fixture struct {
@@ -358,6 +363,156 @@ func TestDeleteCustomer(t *testing.T) {
358363 })
359364}
360365
366+ func TestListCustomersBillingProfileIDFilter (t * testing.T ) {
367+ // The BillingProfileID filter targets the customer's *effective* billing
368+ // profile = COALESCE(override.billing_profile_id, namespace_default_profile.id).
369+ // A customer is "pinned" when it has a live override with a non-null
370+ // billing_profile_id; it is "unpinned" (resolves to the default) when it has
371+ // no live override OR an override with a NULL billing_profile_id.
372+
373+ env := newTestEnv (t )
374+ ns := ulid .Make ().String ()
375+
376+ appID := env .seedApp (ns )
377+ defaultProfileID := env .seedBillingProfile (ns , appID , true )
378+ otherProfileID := env .seedBillingProfile (ns , appID , false )
379+
380+ // Customers covering every override state.
381+ pinnedToDefault := env .seedCustomer (ns , "pinned-to-default" )
382+ env .seedBillingCustomerOverride (ns , pinnedToDefault , & defaultProfileID )
383+
384+ pinnedToOther := env .seedCustomer (ns , "pinned-to-other" )
385+ env .seedBillingCustomerOverride (ns , pinnedToOther , & otherProfileID )
386+
387+ nullOverride := env .seedCustomer (ns , "null-override" )
388+ env .seedBillingCustomerOverride (ns , nullOverride , nil )
389+
390+ noOverride := env .seedCustomer (ns , "no-override" )
391+
392+ t .Run ("DefaultProfileIDReturnsPinnedAndUnpinned" , func (t * testing.T ) {
393+ // given:
394+ // - one default and one non-default billing profile in the namespace
395+ // - customers pinned to default, pinned to other, with a null override,
396+ // and with no override at all
397+ // when:
398+ // - filtering by the default profile id
399+ // then:
400+ // - customers explicitly pinned to the default are returned, plus the
401+ // unpinned customers (null override and no override) that fall back to
402+ // the default; the customer pinned to a different profile is excluded.
403+ got := env .listCustomerIDsByBillingProfileID (t , ns , defaultProfileID )
404+
405+ require .ElementsMatch (t , []string {pinnedToDefault , nullOverride , noOverride }, got )
406+ })
407+
408+ t .Run ("NonDefaultProfileIDReturnsOnlyExplicitlyPinned" , func (t * testing.T ) {
409+ // given:
410+ // - the same customer set as above
411+ // when:
412+ // - filtering by a non-default profile id
413+ // then:
414+ // - only the customer explicitly pinned to that profile is returned; the
415+ // unpinned (fallback-to-default) customers are excluded.
416+ got := env .listCustomerIDsByBillingProfileID (t , ns , otherProfileID )
417+
418+ require .ElementsMatch (t , []string {pinnedToOther }, got )
419+ })
420+ }
421+
422+ // listCustomerIDsByBillingProfileID runs ListCustomers with an eq filter on the
423+ // billing profile id and returns the matched customer ids.
424+ func (e * testEnv ) listCustomerIDsByBillingProfileID (t * testing.T , ns , profileID string ) []string {
425+ t .Helper ()
426+
427+ res , err := e .adapter .ListCustomers (t .Context (), customer.ListCustomersInput {
428+ Namespace : ns ,
429+ Page : pagination.Page {PageNumber : 1 , PageSize : 1000 },
430+ BillingProfileID : & filter.FilterULID {
431+ FilterString : filter.FilterString {Eq : lo .ToPtr (profileID )},
432+ },
433+ })
434+ require .NoError (t , err , "listing customers must not fail" )
435+
436+ return lo .Map (res .Items , func (c customer.Customer , _ int ) string { return c .ID })
437+ }
438+
439+ // seedApp creates a bare App row usable as the tax/invoicing/payment app for a
440+ // billing profile and returns its id.
441+ func (e * testEnv ) seedApp (namespace string ) string {
442+ e .t .Helper ()
443+
444+ appRow , err := e .db .App .Create ().
445+ SetNamespace (namespace ).
446+ SetName ("billing-app" ).
447+ SetType (app .AppTypeStripe ).
448+ SetStatus (app .AppStatusReady ).
449+ Save (e .t .Context ())
450+ require .NoError (e .t , err , "seeding billing app must not fail" )
451+
452+ return appRow .ID
453+ }
454+
455+ // seedBillingProfile creates a billing profile (optionally the namespace default)
456+ // wired to the given app for tax/invoicing/payment plus its required workflow
457+ // config, and returns the profile id.
458+ func (e * testEnv ) seedBillingProfile (namespace , appID string , isDefault bool ) string {
459+ e .t .Helper ()
460+ ctx := e .t .Context ()
461+
462+ wc , err := e .db .BillingWorkflowConfig .Create ().
463+ SetNamespace (namespace ).
464+ SetCollectionAlignment (billing .AlignmentKindSubscription ).
465+ SetLineCollectionPeriod (datetime .ISODurationString ("P1D" )).
466+ SetInvoiceAutoAdvance (true ).
467+ SetInvoiceDraftPeriod (datetime .ISODurationString ("P1D" )).
468+ SetInvoiceDueAfter (datetime .ISODurationString ("P1D" )).
469+ SetInvoiceCollectionMethod (billing .CollectionMethodChargeAutomatically ).
470+ SetInvoiceProgressiveBilling (false ).
471+ Save (ctx )
472+ require .NoError (e .t , err , "seeding billing workflow config must not fail" )
473+
474+ profile , err := e .db .BillingProfile .Create ().
475+ SetNamespace (namespace ).
476+ SetName ("billing-profile" ).
477+ SetWorkflowConfig (wc ).
478+ SetTaxAppID (appID ).
479+ SetInvoicingAppID (appID ).
480+ SetPaymentAppID (appID ).
481+ SetSupplierName ("test-supplier" ).
482+ SetDefault (isDefault ).
483+ Save (ctx )
484+ require .NoError (e .t , err , "seeding billing profile must not fail" )
485+
486+ return profile .ID
487+ }
488+
489+ // seedCustomer creates a bare customer in the namespace and returns its id.
490+ func (e * testEnv ) seedCustomer (namespace , name string ) string {
491+ e .t .Helper ()
492+
493+ cust , err := e .db .Customer .Create ().
494+ SetNamespace (namespace ).
495+ SetName (name ).
496+ Save (e .t .Context ())
497+ require .NoError (e .t , err , "seeding customer must not fail" )
498+
499+ return cust .ID
500+ }
501+
502+ // seedBillingCustomerOverride creates a billing customer override for the
503+ // customer. A nil profileID stores a NULL billing_profile_id (the customer is
504+ // unpinned but still has an override row).
505+ func (e * testEnv ) seedBillingCustomerOverride (namespace , customerID string , profileID * string ) {
506+ e .t .Helper ()
507+
508+ _ , err := e .db .BillingCustomerOverride .Create ().
509+ SetNamespace (namespace ).
510+ SetCustomerID (customerID ).
511+ SetNillableBillingProfileID (profileID ).
512+ Save (e .t .Context ())
513+ require .NoError (e .t , err , "seeding billing customer override must not fail" )
514+ }
515+
361516// --- assertion helpers ---
362517
363518func assertCustomerDeletedAt (t * testing.T , db * entdb.Client , ns , id string , want time.Time ) {
0 commit comments