diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ccdc4dbe7..44e6d624a3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -729,6 +729,8 @@ jobs: image: $DEPOT_IMAGE_URL sink-worker: image: $DEPOT_IMAGE_URL + billing-worker: + image: $DEPOT_IMAGE_URL EOF cat e2e/docker-compose.override.yaml @@ -749,6 +751,8 @@ jobs: build: .. sink-worker: build: .. + billing-worker: + build: .. EOF cat e2e/docker-compose.override.yaml @@ -794,15 +798,35 @@ jobs: - name: Wait for worker to become ready run: | - curl --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30000/healthz + curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30000/healthz + curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30001/healthz docker ps - - name: Run tests + - name: Run base tests + env: + OPENMETER_ADDRESS: http://localhost:38888 + TZ: UTC + run: | + nix develop --impure .#ci -c make -C e2e test-base + + - name: Restart Docker Compose infra for credits-disabled tests + run: | + docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml down -v + docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml -f docker-compose.credits-disabled.yaml up -d + working-directory: e2e + + - name: Wait for worker to become ready with credits disabled + run: | + curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30000/healthz + curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30001/healthz + docker ps + + - name: Run credits-disabled tests env: OPENMETER_ADDRESS: http://localhost:38888 TZ: UTC run: | - nix develop --impure .#ci -c go test -v -count=1 -timeout 3m ./e2e/ + nix develop --impure .#ci -c make -C e2e test-credits-disabled - name: Cleanup Docker Compose run: docker compose -f docker-compose.infra.yaml -f docker-compose.openmeter.yaml -f docker-compose.override.yaml down -v diff --git a/e2e/Makefile b/e2e/Makefile index 03f9ef7c7f..09b94b15ac 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -8,6 +8,7 @@ OPENMETER_ADDRESS ?= http://localhost:38888 BENCHTIME ?= 20x # Number of times to repeat the whole benchmark (feed >1 into benchstat for variance). COUNT ?= 1 +OPENMETER_E2E_EXTRA_COMPOSE_FILES ?= .PHONY: bench-governance bench-governance: ## Benchmark governance query latency (1x1 baseline + 10/50/100 diagonal) @@ -23,16 +24,43 @@ bench-governance-matrix: ## Benchmark governance query latency (full 3x3 custome .PHONY: test-local test-local: ## Run tests against local openmeter + $(call print-target) + $(MAKE) test-local-base + $(MAKE) test-local-credits-disabled + +.PHONY: test-local-credits-disabled +test-local-credits-disabled: ## Run credits-disabled e2e tests against local openmeter $(call print-target) $(MAKE) env-local-down - $(MAKE) env-local-up + set -e; trap '$(MAKE) env-local-down' EXIT; \ + OPENMETER_E2E_EXTRA_COMPOSE_FILES="-f docker-compose.credits-disabled.yaml" $(MAKE) env-local-up; \ + $(MAKE) wait-local; \ + $(MAKE) test-credits-disabled - # wait for sink-worker to be ready - curl --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30000/healthz +.PHONY: test-local-base +test-local-base: ## Run default e2e tests against local openmeter + $(call print-target) + $(MAKE) env-local-down + set -e; trap '$(MAKE) env-local-down' EXIT; \ + $(MAKE) env-local-up; \ + $(MAKE) wait-local; \ + $(MAKE) test-base - TZ=UTC OPENMETER_ADDRESS=http://localhost:38888 go test -count=1 -v ./... +.PHONY: test-credits-disabled +test-credits-disabled: ## Run e2e tests that require credits.enabled=false + $(call print-target) + TZ=UTC OPENMETER_ADDRESS=$(OPENMETER_ADDRESS) go test -count=1 -v ./creditsdisabled - $(MAKE) env-local-down +.PHONY: test-base +test-base: ## Run the default e2e test package + $(call print-target) + TZ=UTC OPENMETER_ADDRESS=$(OPENMETER_ADDRESS) go test -count=1 -v ./ + +.PHONY: wait-local +wait-local: + $(call print-target) + curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30000/healthz + curl --fail --retry 10 --retry-max-time 120 --retry-all-errors http://localhost:30001/healthz .PHONY: env-local-down env-local-down: @@ -52,6 +80,7 @@ env-local-up: -f docker-compose.debug-ports.yaml \ -f docker-compose.openmeter.yaml \ -f docker-compose.openmeter-local.yaml \ + $(OPENMETER_E2E_EXTRA_COMPOSE_FILES) \ up -d --build --force-recreate .PHONY: help diff --git a/e2e/billinginvoices_v3_test.go b/e2e/billinginvoices_v3_test.go index 2714eaefbe..5c469222eb 100644 --- a/e2e/billinginvoices_v3_test.go +++ b/e2e/billinginvoices_v3_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "slices" "testing" "time" @@ -20,7 +21,7 @@ import ( // - Create a meter and feature for usage-based billing (v3) // - Create and publish a plan with a unit rate card backed by the feature (v3) // - Create a subscription for the customer (v3) -// - Advance the gathering invoice via the v1 InvoicePendingLinesAction to create a standard invoice +// - Create a charge-backed pending line and wait for charges to advance it into a standard invoice // - List the customer's invoices via the v1 SDK; separate standard from gathering // - GET the standard invoice via the v3 endpoint and assert the response shape // - GET the gathering invoice via the v3 endpoint → 404 (gathering invoices are not exposed) @@ -135,15 +136,16 @@ func TestV3GetBillingInvoice(t *testing.T) { PaymentTerm: lo.ToPtr(api.PricePaymentTermInAdvance), })) + invoiceAt := now.Add(time.Hour) lineResp, err := v1.CreatePendingInvoiceLineWithResponse(t.Context(), customerID, api.InvoicePendingLineCreateInput{ Currency: "USD", Lines: []api.InvoicePendingLineCreate{ { Name: uniqueKey("inv_gathering_line_name"), - InvoiceAt: now.Add(-10 * time.Hour), + InvoiceAt: invoiceAt, Period: api.Period{ From: now.Add(-24 * time.Hour), - To: now.Add(time.Hour), + To: invoiceAt, }, Price: &price, }, @@ -176,7 +178,7 @@ func TestV3GetBillingInvoice(t *testing.T) { InvoiceAt: now.Add(-10 * time.Hour), Period: api.Period{ From: now.Add(-24 * time.Hour), - To: now.Add(time.Hour), + To: now.Add(-2 * time.Hour), }, Price: &price, }, @@ -186,17 +188,24 @@ func TestV3GetBillingInvoice(t *testing.T) { require.Equal(t, http.StatusCreated, lineResp.StatusCode(), "line: %s", string(lineResp.Body)) require.NotNil(t, lineResp.JSON201) - resp, err := v1.InvoicePendingLinesActionWithResponse(t.Context(), api.InvoicePendingLinesActionInput{ - CustomerId: customerID, - ProgressiveBillingOverride: lo.ToPtr(true), - AsOf: lo.ToPtr(now.Add(-1 * time.Hour)), - }) - require.NoError(t, err) - require.Equal(t, http.StatusCreated, resp.StatusCode(), "advance: %s", string(resp.Body)) - require.NotNil(t, resp.JSON201) - require.NotEmpty(t, *resp.JSON201, "expected at least one standard invoice to be created") - - invoiceID = (*resp.JSON201)[0].Id + ctx := t.Context() + customers := api.InvoiceListParamsCustomers{customerID} + assert.EventuallyWithT(t, func(c *assert.CollectT) { + listResp, err := v1.ListInvoicesWithResponse(ctx, &api.ListInvoicesParams{ + Customers: &customers, + PageSize: lo.ToPtr(api.PaginationPageSize(100)), + }) + require.NoError(c, err) + require.Equal(c, http.StatusOK, listResp.StatusCode(), "list invoices: %s", string(listResp.Body)) + require.NotNil(c, listResp.JSON200) + + standardInvoiceIdx := slices.IndexFunc(listResp.JSON200.Items, func(inv api.Invoice) bool { + return inv.Status != api.InvoiceStatusGathering + }) + require.NotEqual(c, -1, standardInvoiceIdx, "expected charges to advance a pending line into a standard invoice") + + invoiceID = listResp.JSON200.Items[standardInvoiceIdx].Id + }, time.Minute, time.Second) require.NotEmpty(t, invoiceID) }) @@ -212,10 +221,10 @@ func TestV3GetBillingInvoice(t *testing.T) { require.NotNil(t, listResp.JSON200) require.NotEmpty(t, listResp.JSON200.Items, "expected at least one invoice for customer %s (key: %s)", customerID, customerKey) - _, foundStandard := lo.Find(listResp.JSON200.Items, func(inv api.Invoice) bool { + standardInvoiceIdx := slices.IndexFunc(listResp.JSON200.Items, func(inv api.Invoice) bool { return inv.Status != api.InvoiceStatusGathering }) - require.True(t, foundStandard, "expected at least one non-gathering invoice in the list") + require.NotEqual(t, -1, standardInvoiceIdx, "expected at least one non-gathering invoice in the list") }) t.Run("Should return the invoice via v3 GET", func(t *testing.T) { diff --git a/e2e/config.yaml b/e2e/config.yaml index a2803b647d..822d1927b6 100644 --- a/e2e/config.yaml +++ b/e2e/config.yaml @@ -41,12 +41,12 @@ postgres: url: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable autoMigrate: migration -# Credits configuration - disabled for e2e tests. -# Test cases in productcatalog_test.go (TestSettlementMode) depend on this setting -# to verify that credit_only settlement mode is rejected when credits are disabled. +# Credits are enabled for the default e2e suite so billing flows run in the same +# mode as production-like credit deployments. Credits-disabled compatibility +# tests run from e2e/creditsdisabled with CREDITS_* env overrides. credits: - enabled: false - enableCreditThenInvoice: false + enabled: true + enableCreditThenInvoice: true notification: lock: diff --git a/e2e/creditsdisabled/README.md b/e2e/creditsdisabled/README.md new file mode 100644 index 0000000000..65d12338c0 --- /dev/null +++ b/e2e/creditsdisabled/README.md @@ -0,0 +1,10 @@ +# Credits Disabled E2E Tests + +This package contains temporary pre-credit to credit upgrade compatibility tests. + +The default e2e suite runs with credits enabled. Tests in this package exercise +behavior that must remain valid for installations that started before credits +were enabled, or that still run with credits disabled during the upgrade window. + +Remove this package once credits are fully ready and credits-disabled deployments +are no longer supported by the e2e contract. diff --git a/e2e/ledger_backfill_test.go b/e2e/creditsdisabled/ledger_backfill_test.go similarity index 97% rename from e2e/ledger_backfill_test.go rename to e2e/creditsdisabled/ledger_backfill_test.go index 2846b17073..ff26844ac3 100644 --- a/e2e/ledger_backfill_test.go +++ b/e2e/creditsdisabled/ledger_backfill_test.go @@ -1,4 +1,4 @@ -package e2e +package creditsdisabled import ( "bytes" @@ -21,8 +21,8 @@ func TestLedgerBackfillAccountsJob(t *testing.T) { pool := initE2EPostgresPool(t) suffix := strings.ToLower(ulid.Make().String()) - customerA := CreateCustomerWithSubject(t, client, "ledger-backfill-"+suffix+"-a", "ledger-backfill-subject-"+suffix+"-a") - customerB := CreateCustomerWithSubject(t, client, "ledger-backfill-"+suffix+"-b", "ledger-backfill-subject-"+suffix+"-b") + customerA := createCustomerWithSubject(t, client, "ledger-backfill-"+suffix+"-a", "ledger-backfill-subject-"+suffix+"-a") + customerB := createCustomerWithSubject(t, client, "ledger-backfill-"+suffix+"-b", "ledger-backfill-subject-"+suffix+"-b") customerIDs := []string{customerA.Id, customerB.Id} namespace := getCustomerNamespace(t, pool, customerA.Id) @@ -112,7 +112,7 @@ func runJobsBackfillAccounts(t *testing.T, args ...string) string { base = append(base, args...) cmd := exec.CommandContext(t.Context(), "docker", base...) - cmd.Dir = "." + cmd.Dir = ".." var out bytes.Buffer cmd.Stdout = &out diff --git a/e2e/creditsdisabled/settlement_mode_test.go b/e2e/creditsdisabled/settlement_mode_test.go new file mode 100644 index 0000000000..b386e0dcae --- /dev/null +++ b/e2e/creditsdisabled/settlement_mode_test.go @@ -0,0 +1,296 @@ +package creditsdisabled + +import ( + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/client/go" +) + +const creditsDisabledDetail = "credits are not enabled on this deployment of OpenMeter" + +// TestSettlementMode verifies the credit_only settlement mode guard and that +// credit_then_invoice is accepted and round-tripped correctly when credits are +// disabled. +func TestSettlementMode(t *testing.T) { + client := initClient(t) + + ctx := t.Context() + + rc := api.RateCard{} + require.NoError(t, rc.FromRateCardFlatFee(api.RateCardFlatFee{ + Key: gofakeit.Numerify("ratecard_####"), + Name: "flat", + Type: api.RateCardFlatFeeTypeFlatFee, + Price: &api.FlatPriceWithPaymentTerm{ + Amount: "100", + Type: api.FlatPriceWithPaymentTermType("flat"), + }, + })) + + defaultPhase := api.PlanPhase{ + Key: "default", + Name: "Default Phase", + RateCards: []api.RateCard{rc}, + } + + t.Run("Should reject a plan with credit_only settlement mode when credit is disabled", func(t *testing.T) { + res, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ + Key: gofakeit.Numerify("test_plan_settlement_credit_only_####"), + Name: "Credit Only Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) + require.NotNil(t, res.ApplicationproblemJSON400) + assert.Contains(t, res.ApplicationproblemJSON400.Detail, creditsDisabledDetail) + }) + + t.Run("Should accept a plan with credit_then_invoice settlement mode and return it in the response", func(t *testing.T) { + res, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ + Key: gofakeit.Numerify("test_plan_settlement_credit_then_invoice_####"), + Name: "Credit Then Invoice Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + require.Equal(t, 201, res.StatusCode(), "received the following body: %s", res.Body) + require.NotNil(t, res.JSON201) + assert.Equal(t, lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), res.JSON201.SettlementMode) + }) + + t.Run("Should reject a custom subscription with credit_only settlement mode when credit is disabled", func(t *testing.T) { + customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ + Key: lo.ToPtr(gofakeit.Numerify("customer_####")), + Name: gofakeit.Name(), + Currency: lo.ToPtr(api.CurrencyCode("USD")), + PrimaryEmail: lo.ToPtr("testcustomer@example.com"), + }) + require.NoError(t, err) + + create := api.SubscriptionCreate{} + err = create.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ + CustomerId: lo.ToPtr(customer.JSON201.Id), + CustomPlan: api.CustomPlanInput{ + Name: "Credit Only Custom Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), + Phases: []api.PlanPhase{defaultPhase}, + }, + }) + require.NoError(t, err) + + res, err := client.CreateSubscriptionWithResponse(ctx, create) + require.NoError(t, err) + assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) + assert.Contains(t, string(res.Body), creditsDisabledDetail) + }) + + t.Run("Should reject a custom subscription change with credit_only settlement mode when credit is disabled", func(t *testing.T) { + customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ + Key: lo.ToPtr(gofakeit.Numerify("customer_####")), + Name: gofakeit.Name(), + Currency: lo.ToPtr(api.CurrencyCode("USD")), + PrimaryEmail: lo.ToPtr("testcustomer@example.com"), + }) + require.NoError(t, err) + + create := api.SubscriptionCreate{} + require.NoError(t, create.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ + CustomerId: lo.ToPtr(customer.JSON201.Id), + CustomPlan: api.CustomPlanInput{ + Name: "Credit Only Custom Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }, + })) + + subscription, err := client.CreateSubscriptionWithResponse(ctx, create) + require.NoError(t, err) + require.Equal(t, 201, subscription.StatusCode(), "received the following body: %s", subscription.Body) + + ct := &api.SubscriptionTiming{} + require.NoError(t, ct.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) + + req := api.SubscriptionChange{} + err = req.FromCustomSubscriptionChange(api.CustomSubscriptionChange{ + Timing: *ct, + CustomPlan: api.CustomPlanInput{ + Name: "Credit Only Custom Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), + Phases: []api.PlanPhase{defaultPhase}, + }, + }) + require.NoError(t, err) + + res, err := client.ChangeSubscriptionWithResponse(ctx, subscription.JSON201.Id, req) + require.NoError(t, err) + assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) + assert.Contains(t, string(res.Body), creditsDisabledDetail) + }) + + t.Run("Should reject a plan update with credit_only settlement mode when credit is disabled", func(t *testing.T) { + createRes, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ + Key: gofakeit.Numerify("test_plan_settlement_update_credit_only_####"), + Name: "Credit Only Plan Update Test", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + require.Equal(t, 201, createRes.StatusCode(), "received the following body: %s", createRes.Body) + require.NotNil(t, createRes.JSON201) + + res, err := client.UpdatePlanWithResponse(ctx, createRes.JSON201.Id, api.PlanReplaceUpdate{ + Name: "Credit Only Plan Updated", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) + require.NotNil(t, res.ApplicationproblemJSON400) + assert.Contains(t, res.ApplicationproblemJSON400.Detail, creditsDisabledDetail) + }) + + t.Run("Should accept a plan update with credit_then_invoice settlement mode and return it in the response", func(t *testing.T) { + createRes, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ + Key: gofakeit.Numerify("test_plan_settlement_update_cti_####"), + Name: "Update Test Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + require.Equal(t, 201, createRes.StatusCode(), "received the following body: %s", createRes.Body) + require.NotNil(t, createRes.JSON201) + + res, err := client.UpdatePlanWithResponse(ctx, createRes.JSON201.Id, api.PlanReplaceUpdate{ + Name: "Update Test Plan Updated", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + require.Equal(t, 200, res.StatusCode(), "received the following body: %s", res.Body) + require.NotNil(t, res.JSON200) + assert.Equal(t, lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), res.JSON200.SettlementMode) + }) + + t.Run("Should reject a plan-based subscription with credit_only settlement mode when credit is disabled", func(t *testing.T) { + customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ + Key: lo.ToPtr(gofakeit.Numerify("customer_####")), + Name: gofakeit.Name(), + Currency: lo.ToPtr(api.CurrencyCode("USD")), + PrimaryEmail: lo.ToPtr("testcustomer@example.com"), + }) + require.NoError(t, err) + + plan, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ + Key: gofakeit.Numerify("test_plan_settlement_credit_then_invoice_####"), + Name: "Credit Only Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + + _, err = client.PublishPlanWithResponse(ctx, plan.JSON201.Id) + require.NoError(t, err) + + ct := &api.SubscriptionTiming{} + require.NoError(t, ct.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) + + create := api.SubscriptionCreate{} + err = create.FromPlanSubscriptionCreate(api.PlanSubscriptionCreate{ + Timing: ct, + CustomerId: lo.ToPtr(customer.JSON201.Id), + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), + Plan: api.PlanReferenceInput{ + Key: plan.JSON201.Key, + Version: lo.ToPtr(1), + }, + }) + require.NoError(t, err) + + res, err := client.CreateSubscriptionWithResponse(ctx, create) + require.NoError(t, err) + assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) + assert.Contains(t, string(res.Body), creditsDisabledDetail) + }) + + t.Run("Should reject a plan-based subscription change with credit_only settlement mode when credit is disabled", func(t *testing.T) { + customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ + Key: lo.ToPtr(gofakeit.Numerify("customer_####")), + Name: gofakeit.Name(), + Currency: lo.ToPtr(api.CurrencyCode("USD")), + PrimaryEmail: lo.ToPtr("testcustomer@example.com"), + }) + require.NoError(t, err) + + plan, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ + Key: gofakeit.Numerify("test_plan_settlement_credit_then_invoice_####"), + Name: "Credit Only Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }) + require.NoError(t, err) + + _, err = client.PublishPlanWithResponse(ctx, plan.JSON201.Id) + require.NoError(t, err) + + create := api.SubscriptionCreate{} + require.NoError(t, create.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ + CustomerId: lo.ToPtr(customer.JSON201.Id), + CustomPlan: api.CustomPlanInput{ + Name: "Credit Only Custom Plan", + Currency: "USD", + BillingCadence: "P1M", + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), + Phases: []api.PlanPhase{defaultPhase}, + }, + })) + + subscription, err := client.CreateSubscriptionWithResponse(ctx, create) + require.NoError(t, err) + require.Equal(t, 201, subscription.StatusCode(), "received the following body: %s", subscription.Body) + + ct := &api.SubscriptionTiming{} + require.NoError(t, ct.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) + + req := api.SubscriptionChange{} + err = req.FromPlanSubscriptionChange(api.PlanSubscriptionChange{ + Timing: *ct, + SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), + Plan: api.PlanReferenceInput{ + Key: plan.JSON201.Key, + Version: lo.ToPtr(1), + }, + }) + require.NoError(t, err) + + res, err := client.ChangeSubscriptionWithResponse(ctx, subscription.JSON201.Id, req) + require.Nil(t, err) + assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) + assert.Contains(t, string(res.Body), creditsDisabledDetail) + }) +} diff --git a/e2e/creditsdisabled/setup_test.go b/e2e/creditsdisabled/setup_test.go new file mode 100644 index 0000000000..bcfaad321a --- /dev/null +++ b/e2e/creditsdisabled/setup_test.go @@ -0,0 +1,60 @@ +package creditsdisabled + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/client/go" +) + +func initClient(t testing.TB) *api.ClientWithResponses { + t.Helper() + + address := os.Getenv("OPENMETER_ADDRESS") + if address == "" { + t.Skip("OPENMETER_ADDRESS not set") + } + + client, err := api.NewClientWithResponses(address) + require.NoError(t, err) + + return client +} + +func createCustomerWithSubject(t *testing.T, client *api.ClientWithResponses, customerKey string, subjectKey string) *api.Customer { + t.Helper() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + resp, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Name: fmt.Sprintf("Test Customer %s", customerKey), + Currency: lo.ToPtr("USD"), + Key: lo.ToPtr(customerKey), + PrimaryEmail: lo.ToPtr(fmt.Sprintf("test-%s@test.com", customerKey)), + UsageAttribution: &api.CustomerUsageAttribution{ + SubjectKeys: []string{subjectKey}, + }, + }) + + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode(), "Invalid status code [response_body=%s]", string(resp.Body)) + require.NotNil(t, resp.JSON201) + require.NotNil(t, resp.JSON201.UsageAttribution) + + require.Equal(t, []string{subjectKey}, resp.JSON201.UsageAttribution.SubjectKeys) + + subjectResp, err := client.UpsertSubjectWithResponse(ctx, api.UpsertSubjectJSONRequestBody{ + api.SubjectUpsert{Key: subjectKey}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, subjectResp.StatusCode(), "Invalid status code [response_body=%s]", string(subjectResp.Body)) + + return resp.JSON201 +} diff --git a/e2e/docker-compose.credits-disabled.yaml b/e2e/docker-compose.credits-disabled.yaml new file mode 100644 index 0000000000..aa17581470 --- /dev/null +++ b/e2e/docker-compose.credits-disabled.yaml @@ -0,0 +1,15 @@ +services: + openmeter: + environment: + CREDITS_ENABLED: "false" + CREDITS_ENABLECREDITTHENINVOICE: "false" + + sink-worker: + environment: + CREDITS_ENABLED: "false" + CREDITS_ENABLECREDITTHENINVOICE: "false" + + billing-worker: + environment: + CREDITS_ENABLED: "false" + CREDITS_ENABLECREDITTHENINVOICE: "false" diff --git a/e2e/docker-compose.openmeter-local.yaml b/e2e/docker-compose.openmeter-local.yaml index fbc352ef53..2757c7a852 100644 --- a/e2e/docker-compose.openmeter-local.yaml +++ b/e2e/docker-compose.openmeter-local.yaml @@ -8,3 +8,6 @@ services: sink-worker: build: .. + + billing-worker: + build: .. diff --git a/e2e/docker-compose.openmeter.yaml b/e2e/docker-compose.openmeter.yaml index e2b212438c..b2db8b966c 100644 --- a/e2e/docker-compose.openmeter.yaml +++ b/e2e/docker-compose.openmeter.yaml @@ -43,3 +43,22 @@ services: volumes: - ./config.yaml:/etc/openmeter/config.yaml - ./logs/sink-worker:/var/log/openmeter + + billing-worker: + command: openmeter-billing-worker --config /etc/openmeter/config.yaml + restart: always + pull_policy: always + depends_on: + kafka: + condition: service_healthy + clickhouse: + condition: service_healthy + postgres: + condition: service_healthy + openmeter: + condition: service_healthy + ports: + - "127.0.0.1:30001:10000" + volumes: + - ./config.yaml:/etc/openmeter/config.yaml + - ./logs/billing-worker:/var/log/openmeter diff --git a/e2e/ledger_accounts_test.go b/e2e/ledger_accounts_test.go new file mode 100644 index 0000000000..24ce7267fc --- /dev/null +++ b/e2e/ledger_accounts_test.go @@ -0,0 +1,159 @@ +package e2e + +import ( + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/oklog/ulid/v2" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/ledger" +) + +func TestNewCustomerHasLedgerAccounts(t *testing.T) { + client := initClient(t) + pool := initE2EPostgresPool(t) + + suffix := strings.ToLower(ulid.Make().String()) + customer := CreateCustomerWithSubject(t, client, "ledger-accounts-"+suffix, "ledger-accounts-subject-"+suffix) + + namespace := getCustomerNamespace(t, pool, customer.Id) + customerMappings := getCustomerAccountMappings(t, pool, namespace, customer.Id) + + require.Len(t, customerMappings, len(ledger.CustomerAccountTypes), "unexpected customer account mapping count") + for _, accountType := range ledger.CustomerAccountTypes { + accountID, ok := customerMappings[accountType] + require.True(t, ok, "missing customer mapping for type=%s", accountType) + require.NotEmpty(t, accountID, "empty customer account id for type=%s", accountType) + } + + businessAccounts := getBusinessAccountIDs(t, pool, namespace) + require.Len(t, businessAccounts, len(ledger.BusinessAccountTypes)) + for _, accountType := range ledger.BusinessAccountTypes { + accountID, ok := businessAccounts[accountType] + require.True(t, ok, "missing business account mapping for type=%s", accountType) + require.NotEmpty(t, accountID, "empty business account id for type=%s", accountType) + } +} + +func initE2EPostgresPool(t *testing.T) *pgxpool.Pool { + t.Helper() + + dsn := os.Getenv("OPENMETER_E2E_POSTGRES_URL") + if dsn == "" { + address := os.Getenv("OPENMETER_ADDRESS") + if !strings.Contains(address, "localhost:38888") && !strings.Contains(address, "127.0.0.1:38888") { + t.Skipf("ledger account e2e requires OPENMETER_E2E_POSTGRES_URL or local compose stack at localhost:38888, got %q", address) + } + + dsn = "postgres://postgres:postgres@127.0.0.1:35432/postgres?sslmode=disable" + } + + pool, err := pgxpool.New(t.Context(), dsn) + require.NoError(t, err) + + t.Cleanup(pool.Close) + + require.NoError(t, pool.Ping(t.Context())) + + return pool +} + +func getCustomerNamespace(t *testing.T, pool *pgxpool.Pool, customerID string) string { + t.Helper() + + var namespace string + err := pool.QueryRow( + t.Context(), + `SELECT namespace FROM customers WHERE id = $1`, + customerID, + ).Scan(&namespace) + require.NoError(t, err) + + return namespace +} + +func getCustomerAccountMappings(t *testing.T, pool *pgxpool.Pool, namespace string, customerID string) map[ledger.AccountType]string { + t.Helper() + + rows, err := pool.Query( + t.Context(), + ` +SELECT lca.account_type, lca.account_id, la.id +FROM ledger_customer_accounts lca +JOIN ledger_accounts la + ON la.namespace = lca.namespace + AND la.id = lca.account_id + AND la.account_type = lca.account_type +WHERE lca.namespace = $1 + AND lca.customer_id = $2 +`, + namespace, + customerID, + ) + require.NoError(t, err) + defer rows.Close() + + result := map[ledger.AccountType]string{} + for rows.Next() { + var accountType string + var accountID string + var linkedAccountID string + + require.NoError(t, rows.Scan(&accountType, &accountID, &linkedAccountID)) + require.Equal(t, accountID, linkedAccountID, "customer mapping account id must match linked ledger account") + + typed := ledger.AccountType(accountType) + require.Empty(t, typed.Validate(), "invalid customer account type returned: %s", accountType) + require.NotContains(t, result, typed, "duplicate customer mapping for type=%s", accountType) + + result[typed] = accountID + } + + require.NoError(t, rows.Err()) + + return result +} + +func getBusinessAccountIDs(t *testing.T, pool *pgxpool.Pool, namespace string) map[ledger.AccountType]string { + t.Helper() + + accountTypes := make([]string, 0, len(ledger.BusinessAccountTypes)) + for _, accountType := range ledger.BusinessAccountTypes { + accountTypes = append(accountTypes, string(accountType)) + } + + rows, err := pool.Query( + t.Context(), + ` +SELECT account_type, id +FROM ledger_accounts +WHERE namespace = $1 + AND account_type = ANY($2) +`, + namespace, + accountTypes, + ) + require.NoError(t, err) + defer rows.Close() + + result := make(map[ledger.AccountType]string, len(ledger.BusinessAccountTypes)) + for rows.Next() { + var accountType string + var accountID string + + require.NoError(t, rows.Scan(&accountType, &accountID)) + + typed := ledger.AccountType(accountType) + require.Empty(t, typed.Validate(), "invalid business account type returned: %s", accountType) + require.NotContains(t, result, typed, "duplicate business account type returned: %s", accountType) + + result[typed] = accountID + } + + require.NoError(t, rows.Err()) + + return result +} diff --git a/e2e/productcatalog_test.go b/e2e/productcatalog_test.go index 34fdba821c..cd2acafe0e 100644 --- a/e2e/productcatalog_test.go +++ b/e2e/productcatalog_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/brianvoe/gofakeit/v6" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1182,286 +1181,3 @@ func TestPlan(t *testing.T) { require.True(t, res.JSON200.Entitlements[PlanFeatureKey].HasAccess) }) } - -// TestSettlementMode verifies the credit_only settlement mode guard and that -// credit_then_invoice is accepted and round-tripped correctly. credit.enabled -// defaults to false in the e2e environment, so credit_only must be rejected. -func TestSettlementMode(t *testing.T) { - client := initClient(t) - - ctx := t.Context() - - rc := api.RateCard{} - require.NoError(t, rc.FromRateCardFlatFee(api.RateCardFlatFee{ - Key: gofakeit.Numerify("ratecard_####"), - Name: "flat", - Type: api.RateCardFlatFeeTypeFlatFee, - Price: &api.FlatPriceWithPaymentTerm{ - Amount: "100", - Type: api.FlatPriceWithPaymentTermType("flat"), - }, - })) - - // Minimal single-phase plan body reused across sub-tests. - defaultPhase := api.PlanPhase{ - Key: "default", - Name: "Default Phase", - RateCards: []api.RateCard{rc}, - } - - t.Run("Should reject a plan with credit_only settlement mode when credit is disabled", func(t *testing.T) { - res, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ - Key: gofakeit.Numerify("test_plan_settlement_credit_only_####"), - Name: "Credit Only Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) - require.NotNil(t, res.ApplicationproblemJSON400) - assert.Contains(t, res.ApplicationproblemJSON400.Detail, "credits are not enabled on this deployment of OpenMeter") - }) - - t.Run("Should accept a plan with credit_then_invoice settlement mode and return it in the response", func(t *testing.T) { - res, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ - Key: gofakeit.Numerify("test_plan_settlement_credit_then_invoice_####"), - Name: "Credit Then Invoice Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - require.Equal(t, 201, res.StatusCode(), "received the following body: %s", res.Body) - require.NotNil(t, res.JSON201) - assert.Equal(t, lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), res.JSON201.SettlementMode) - }) - - t.Run("Should reject a custom subscription with credit_only settlement mode when credit is disabled", func(t *testing.T) { - customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ - Key: lo.ToPtr(gofakeit.Numerify("customer_####")), - Name: gofakeit.Name(), - Currency: lo.ToPtr(api.CurrencyCode("USD")), - PrimaryEmail: lo.ToPtr("testcustomer@example.com"), - }) - require.NoError(t, err) - - create := api.SubscriptionCreate{} - err = create.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ - CustomerId: lo.ToPtr(customer.JSON201.Id), - CustomPlan: api.CustomPlanInput{ - Name: "Credit Only Custom Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), - Phases: []api.PlanPhase{defaultPhase}, - }, - }) - require.NoError(t, err) - - res, err := client.CreateSubscriptionWithResponse(ctx, create) - require.NoError(t, err) - assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) - }) - - t.Run("Should reject a custom subscription change with credit_only settlement mode when credit is disabled", func(t *testing.T) { - customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ - Key: lo.ToPtr(gofakeit.Numerify("customer_####")), - Name: gofakeit.Name(), - Currency: lo.ToPtr(api.CurrencyCode("USD")), - PrimaryEmail: lo.ToPtr("testcustomer@example.com"), - }) - require.NoError(t, err) - - create := api.SubscriptionCreate{} - require.NoError(t, create.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ - CustomerId: lo.ToPtr(customer.JSON201.Id), - CustomPlan: api.CustomPlanInput{ - Name: "Credit Only Custom Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }, - })) - - subscription, err := client.CreateSubscriptionWithResponse(ctx, create) - require.NoError(t, err) - require.Equal(t, 201, subscription.StatusCode(), "received the following body: %s", subscription.Body) - - ct := &api.SubscriptionTiming{} - require.NoError(t, ct.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) - - req := api.SubscriptionChange{} - err = req.FromCustomSubscriptionChange(api.CustomSubscriptionChange{ - Timing: *ct, - CustomPlan: api.CustomPlanInput{ - Name: "Credit Only Custom Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), - Phases: []api.PlanPhase{defaultPhase}, - }, - }) - require.NoError(t, err) - - res, err := client.ChangeSubscriptionWithResponse(ctx, subscription.JSON201.Id, req) - require.NoError(t, err) - assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) - }) - - t.Run("Should reject a plan update with credit_only settlement mode when credit is disabled", func(t *testing.T) { - // First create a valid plan with allowed settlement mode - createRes, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ - Key: gofakeit.Numerify("test_plan_settlement_update_credit_only_####"), - Name: "Credit Only Plan Update Test", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - require.Equal(t, 201, createRes.StatusCode(), "received the following body: %s", createRes.Body) - require.NotNil(t, createRes.JSON201) - - // Then try to update it with credit_only mode (should fail) - res, err := client.UpdatePlanWithResponse(ctx, createRes.JSON201.Id, api.PlanReplaceUpdate{ - Name: "Credit Only Plan Updated", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) - require.NotNil(t, res.ApplicationproblemJSON400) - assert.Contains(t, res.ApplicationproblemJSON400.Detail, "credits are not enabled on this deployment of OpenMeter") - }) - - t.Run("Should accept a plan update with credit_then_invoice settlement mode and return it in the response", func(t *testing.T) { - createRes, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ - Key: gofakeit.Numerify("test_plan_settlement_update_cti_####"), - Name: "Update Test Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - require.Equal(t, 201, createRes.StatusCode(), "received the following body: %s", createRes.Body) - require.NotNil(t, createRes.JSON201) - - res, err := client.UpdatePlanWithResponse(ctx, createRes.JSON201.Id, api.PlanReplaceUpdate{ - Name: "Update Test Plan Updated", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - require.Equal(t, 200, res.StatusCode(), "received the following body: %s", res.Body) - require.NotNil(t, res.JSON200) - assert.Equal(t, lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), res.JSON200.SettlementMode) - }) - - t.Run("Should reject a plan-based subscription with credit_only settlement mode when credit is disabled", func(t *testing.T) { - customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ - Key: lo.ToPtr(gofakeit.Numerify("customer_####")), - Name: gofakeit.Name(), - Currency: lo.ToPtr(api.CurrencyCode("USD")), - PrimaryEmail: lo.ToPtr("testcustomer@example.com"), - }) - require.NoError(t, err) - - plan, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ - Key: gofakeit.Numerify("test_plan_settlement_credit_then_invoice_####"), - Name: "Credit Only Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - - _, err = client.PublishPlanWithResponse(ctx, plan.JSON201.Id) - require.NoError(t, err) - - ct := &api.SubscriptionTiming{} - require.NoError(t, ct.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) - - create := api.SubscriptionCreate{} - err = create.FromPlanSubscriptionCreate(api.PlanSubscriptionCreate{ - Timing: ct, - CustomerId: lo.ToPtr(customer.JSON201.Id), - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), - Plan: api.PlanReferenceInput{ - Key: plan.JSON201.Key, - Version: lo.ToPtr(1), - }, - }) - require.NoError(t, err) - - res, err := client.CreateSubscriptionWithResponse(ctx, create) - require.NoError(t, err) - assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) - assert.Contains(t, string(res.Body), "credits are not enabled on this deployment of OpenMeter") - }) - - t.Run("Should reject a plan-based subscription change with credit_only settlement mode when credit is disabled", func(t *testing.T) { - customer, err := client.CreateCustomerWithResponse(ctx, api.CustomerCreate{ - Key: lo.ToPtr(gofakeit.Numerify("customer_####")), - Name: gofakeit.Name(), - Currency: lo.ToPtr(api.CurrencyCode("USD")), - PrimaryEmail: lo.ToPtr("testcustomer@example.com"), - }) - require.NoError(t, err) - - plan, err := client.CreatePlanWithResponse(ctx, api.PlanCreate{ - Key: gofakeit.Numerify("test_plan_settlement_credit_then_invoice_####"), - Name: "Credit Only Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }) - require.NoError(t, err) - - _, err = client.PublishPlanWithResponse(ctx, plan.JSON201.Id) - require.NoError(t, err) - - create := api.SubscriptionCreate{} - require.NoError(t, create.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ - CustomerId: lo.ToPtr(customer.JSON201.Id), - CustomPlan: api.CustomPlanInput{ - Name: "Credit Only Custom Plan", - Currency: "USD", - BillingCadence: "P1M", - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditThenInvoice), - Phases: []api.PlanPhase{defaultPhase}, - }, - })) - - subscription, err := client.CreateSubscriptionWithResponse(ctx, create) - require.NoError(t, err) - require.Equal(t, 201, subscription.StatusCode(), "received the following body: %s", subscription.Body) - - ct := &api.SubscriptionTiming{} - require.NoError(t, ct.FromSubscriptionTimingEnum(api.SubscriptionTimingEnumImmediate)) - - req := api.SubscriptionChange{} - err = req.FromPlanSubscriptionChange(api.PlanSubscriptionChange{ - Timing: *ct, - SettlementMode: lo.ToPtr(api.BillingSettlementModeCreditOnly), - Plan: api.PlanReferenceInput{ - Key: plan.JSON201.Key, - Version: lo.ToPtr(1), - }, - }) - require.NoError(t, err) - - res, err := client.ChangeSubscriptionWithResponse(ctx, subscription.JSON201.Id, req) - require.Nil(t, err) - assert.Equal(t, 400, res.StatusCode(), "received the following body: %s", res.Body) - assert.Contains(t, string(res.Body), "credits are not enabled on this deployment of OpenMeter") - }) -} diff --git a/quickstart/config.yaml b/quickstart/config.yaml index 92e6e4cdaa..dc15158f10 100644 --- a/quickstart/config.yaml +++ b/quickstart/config.yaml @@ -30,6 +30,10 @@ postgres: url: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable autoMigrate: migration # Runs migrations as part of the service startup, valid values are: ent, migration, false +credits: + enabled: true + enableCreditThenInvoice: true + meters: # Sample meter to count API requests - slug: api_requests_total # Unique identifier for the meter