package billing import ( "context" "errors" "fmt" "os" "testing" "time" "aigw/internal/controlplane" "github.com/stripe/stripe-go/v86" ) func TestNormalizeBillingProfile(t *testing.T) { input := UpdateBillingProfileInput{TenantID: " tenant ", LegalName: " Example Limited ", BillingEmail: "BILLING@EXAMPLE.TEST", AddressLine1: " 1 Queen Street ", City: " Auckland ", PostalCode: "1010", Country: "nz"} result, err := normalizeBillingProfile(input) if err != nil { t.Fatal(err) } if result.TenantID != "tenant" || result.LegalName != "Example Limited" || result.BillingEmail != "billing@example.test" || result.Country != "NZ" { t.Fatalf("unexpected normalized profile: %+v", result) } for _, invalid := range []UpdateBillingProfileInput{ {TenantID: "tenant", LegalName: "Example", BillingEmail: "not-an-email", AddressLine1: "1 Street", City: "Auckland", PostalCode: "1010", Country: "NZ"}, {TenantID: "tenant", LegalName: "Example", BillingEmail: "billing@example.test", AddressLine1: "1 Street", City: "Auckland", PostalCode: "1010", Country: "New Zealand"}, {TenantID: "tenant", BillingEmail: "billing@example.test", AddressLine1: "1 Street", City: "Auckland", PostalCode: "1010", Country: "NZ"}, } { if _, err := normalizeBillingProfile(invalid); !errors.Is(err, ErrInvalidBillingProfile) { t.Fatalf("error = %v, want invalid billing profile", err) } } } func TestBillingProfileStripeSynchronizationPostgres(t *testing.T) { databaseURL := os.Getenv("AIGW_TEST_DATABASE_URL") if databaseURL == "" { t.Skip("AIGW_TEST_DATABASE_URL is not set") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := controlplane.MigrateDatabase(ctx, databaseURL); err != nil { t.Fatal(err) } service, err := New(ctx, Options{DatabaseURL: databaseURL, Currency: "usd", StripeEnabled: true, StripeAPIKey: "rk_test_placeholder"}) if err != nil { t.Fatal(err) } t.Cleanup(service.Close) suffix := time.Now().UnixNano() var tenantID string if err := service.db.QueryRow(ctx, `INSERT INTO tenants (slug,name) VALUES ($1,'Billing profile integration') RETURNING id::text`, fmt.Sprintf("billing-profile-%d", suffix)).Scan(&tenantID); err != nil { t.Fatal(err) } t.Cleanup(func() { cleanupCtx := context.Background() for _, query := range []string{ `DELETE FROM tenant_billing_profiles WHERE tenant_id=$1`, `DELETE FROM stripe_customers WHERE tenant_id=$1`, `DELETE FROM tenants WHERE id=$1`, } { if _, cleanupErr := service.db.Exec(cleanupCtx, query, tenantID); cleanupErr != nil { t.Errorf("cleanup billing profile: %v", cleanupErr) } } }) customerID := fmt.Sprintf("cus_profile_%d", suffix) createCalls, updateCalls := 0, 0 service.createStripeCustomer = func(_ context.Context, params *stripe.CustomerCreateParams) (*stripe.Customer, error) { createCalls++ if params.IdempotencyKey == nil || *params.IdempotencyKey != "aigw_customer_"+tenantID || params.Address == nil || *params.Address.Country != "NZ" || *params.Name != "Example Limited" || params.Metadata["aigw_tenant_id"] != tenantID { t.Fatalf("unexpected Stripe create params: %+v", params) } return &stripe.Customer{ID: customerID}, nil } service.updateStripeCustomer = func(_ context.Context, id string, params *stripe.CustomerUpdateParams) (*stripe.Customer, error) { updateCalls++ if id != customerID || params.Address == nil || *params.Address.PostalCode != "1010" { t.Fatalf("unexpected Stripe update: id=%s params=%+v", id, params) } return &stripe.Customer{ID: customerID}, nil } input := UpdateBillingProfileInput{TenantID: tenantID, LegalName: "Example Limited", BillingEmail: "billing@example.test", AddressLine1: "1 Queen Street", City: "Auckland", Region: "Auckland", PostalCode: "1010", Country: "nz"} profile, err := service.UpdateBillingProfile(ctx, input) if err != nil { t.Fatal(err) } if createCalls != 1 || updateCalls != 0 || !profile.Configured || !profile.StripeCustomerConfigured || profile.StripeSyncStatus != "synced" || profile.StripeSyncedAt == nil { t.Fatalf("unexpected first synchronization: create=%d update=%d profile=%+v", createCalls, updateCalls, profile) } input.LegalName = "Example API Limited" profile, err = service.UpdateBillingProfile(ctx, input) if err != nil { t.Fatal(err) } if createCalls != 1 || updateCalls != 1 || profile.LegalName != "Example API Limited" || profile.StripeSyncStatus != "synced" { t.Fatalf("unexpected update synchronization: create=%d update=%d profile=%+v", createCalls, updateCalls, profile) } service.updateStripeCustomer = func(context.Context, string, *stripe.CustomerUpdateParams) (*stripe.Customer, error) { return nil, errors.New("temporary Stripe outage") } input.City = "Wellington" if _, err := service.UpdateBillingProfile(ctx, input); !errors.Is(err, ErrBillingProfileSync) { t.Fatalf("error = %v, want Stripe sync error", err) } profile, err = service.GetBillingProfile(ctx, tenantID) if err != nil { t.Fatal(err) } if profile.City != "Wellington" || profile.StripeSyncStatus != "failed" || profile.StripeSyncError == "" { t.Fatalf("failed synchronization did not preserve local profile: %+v", profile) } service.updateStripeCustomer = func(context.Context, string, *stripe.CustomerUpdateParams) (*stripe.Customer, error) { return &stripe.Customer{ID: customerID}, nil } if _, err := service.ensureStripeCustomer(ctx, tenantID); err != nil { t.Fatal(err) } profile, err = service.GetBillingProfile(ctx, tenantID) if err != nil { t.Fatal(err) } if profile.StripeSyncStatus != "synced" || profile.StripeSyncError != "" { t.Fatalf("profile did not recover after retry: %+v", profile) } }