Files
databasus/backend/internal/features/billing/controller_test.go
2026-03-27 22:02:25 +03:00

1451 lines
45 KiB
Go

package billing
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"databasus-backend/internal/config"
"databasus-backend/internal/features/audit_logs"
billing_models "databasus-backend/internal/features/billing/models"
billing_provider "databasus-backend/internal/features/billing/provider"
"databasus-backend/internal/features/databases"
"databasus-backend/internal/features/databases/databases/postgresql"
users_dto "databasus-backend/internal/features/users/dto"
users_enums "databasus-backend/internal/features/users/enums"
users_middleware "databasus-backend/internal/features/users/middleware"
users_services "databasus-backend/internal/features/users/services"
users_testing "databasus-backend/internal/features/users/testing"
workspaces_controllers "databasus-backend/internal/features/workspaces/controllers"
workspaces_models "databasus-backend/internal/features/workspaces/models"
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
"databasus-backend/internal/storage"
test_utils "databasus-backend/internal/util/testing"
"databasus-backend/internal/util/tools"
)
// ---- Category 1: Trial Subscription ----
func Test_CreateDatabase_WhenCloudEnabled_TrialSubscriptionCreated(t *testing.T) {
router, owner, _, db := setupBillingTestWithoutProvider(t)
sub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusTrial, sub.Status)
assert.Equal(t, config.GetEnv().TrialStorageGB, sub.StorageGB)
assert.Nil(t, sub.ProviderSubID)
assert.Nil(t, sub.ProviderCustomerID)
assert.WithinDuration(t, time.Now().UTC().Add(config.GetEnv().TrialDuration), sub.CurrentPeriodEnd, 5*time.Second)
}
func Test_GetSubscription_WhenTrialActive_ReturnsTrialSubscription(t *testing.T) {
router, owner, _, db := setupBillingTestWithoutProvider(t)
var sub billing_models.Subscription
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/"+db.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&sub,
)
assert.Equal(t, billing_models.StatusTrial, sub.Status)
assert.Equal(t, config.GetEnv().TrialStorageGB, sub.StorageGB)
}
// ---- Category 2: CreateSubscription Endpoint ----
func Test_CreateSubscription_ValidRequest_ReturnsCheckoutURL(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
expireActiveSubscription(t, db.ID)
var resp CreateSubscriptionResponse
test_utils.MakePostRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription",
"Bearer "+owner.Token,
CreateSubscriptionRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusOK,
&resp,
)
assert.Equal(t, "txn_mock_test_123", resp.PaddleTransactionID)
}
func Test_CreateSubscription_StorageBelowMinimum_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
expireActiveSubscription(t, db.ID)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription",
"Bearer "+owner.Token,
CreateSubscriptionRequest{DatabaseID: db.ID, StorageGB: 10},
http.StatusInternalServerError,
)
}
func Test_CreateSubscription_StorageAboveMaximum_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
expireActiveSubscription(t, db.ID)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription",
"Bearer "+owner.Token,
CreateSubscriptionRequest{DatabaseID: db.ID, StorageGB: 15000},
http.StatusInternalServerError,
)
}
func Test_CreateSubscription_WhenTrialExists_ReturnsCheckoutURL(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
// Trial is active — should allow checkout without expiring the trial yet
trialSub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusTrial, trialSub.Status)
var resp CreateSubscriptionResponse
test_utils.MakePostRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription",
"Bearer "+owner.Token,
CreateSubscriptionRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusOK,
&resp,
)
assert.Equal(t, "txn_mock_test_123", resp.PaddleTransactionID)
// Trial should still be active until paid subscription activates via webhook
stillTrial := refreshSubscription(t, trialSub.ID)
assert.Equal(t, billing_models.StatusTrial, stillTrial.Status)
}
func Test_ActivateSubscription_WhenTrialExists_ExpiresTrialAndActivatesPaid(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
// Trial is active
trialSub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusTrial, trialSub.Status)
// Activate paid subscription via webhook — should expire the trial
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
providerSubID := "sub-" + uuid.New().String()
err := billingService.ActivateSubscription(slog.Default(), billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-" + uuid.New().String(),
DatabaseID: &db.ID,
ProviderSubscriptionID: providerSubID,
ProviderCustomerID: "cus-" + uuid.New().String(),
QuantityGB: 50,
PeriodStart: &now,
PeriodEnd: &periodEnd,
})
assert.NoError(t, err)
// Verify trial was expired
expiredTrial := refreshSubscription(t, trialSub.ID)
assert.Equal(t, billing_models.StatusExpired, expiredTrial.Status)
assert.NotNil(t, expiredTrial.CanceledAt)
// Verify paid subscription is active
activeSub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusActive, activeSub.Status)
assert.Equal(t, 50, activeSub.StorageGB)
}
func Test_CreateSubscription_ActiveSubscriptionExists_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
// Activate a real paid subscription
activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
// Should block new subscription for non-trial active sub
resp := test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription",
"Bearer "+owner.Token,
CreateSubscriptionRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusInternalServerError,
)
assert.Contains(t, string(resp.Body), "Failed to create subscription")
}
func Test_CreateSubscription_UserWithoutAccess_ReturnsError(t *testing.T) {
router, _, _, db, _ := setupBillingTest(t)
otherUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription",
"Bearer "+otherUser.Token,
CreateSubscriptionRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusInternalServerError,
)
}
func Test_CreateSubscription_Unauthenticated_Returns401(t *testing.T) {
router, _, _, db, _ := setupBillingTest(t)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription",
"",
CreateSubscriptionRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusUnauthorized,
)
}
// ---- Category 3: GetSubscription Endpoint ----
func Test_GetSubscription_ActiveSubscription_ReturnsSubscription(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
activeSub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
var sub billing_models.Subscription
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/"+db.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&sub,
)
assert.Equal(t, activeSub.ID, sub.ID)
assert.Equal(t, billing_models.StatusActive, sub.Status)
assert.Equal(t, 50, sub.StorageGB)
}
func Test_GetSubscription_UserWithoutAccess_ReturnsError(t *testing.T) {
router, _, _, db, _ := setupBillingTest(t)
otherUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
test_utils.MakeGetRequest(
t, router,
"/api/v1/billing/subscription/"+db.ID.String(),
"Bearer "+otherUser.Token,
http.StatusInternalServerError,
)
}
func Test_GetSubscription_NonExistentDatabase_ReturnsError(t *testing.T) {
router, owner, _, _, _ := setupBillingTest(t)
test_utils.MakeGetRequest(
t, router,
"/api/v1/billing/subscription/"+uuid.New().String(),
"Bearer "+owner.Token,
http.StatusInternalServerError,
)
}
// ---- Category 4: ChangeStorage Endpoint ----
func Test_ChangeStorage_WhenUpgrading_ReturnsImmediateMode(t *testing.T) {
router, owner, _, db, mock := setupBillingTest(t)
activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
var resp ChangeStorageResponse
test_utils.MakePostRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/change-storage",
"Bearer "+owner.Token,
ChangeStorageRequest{DatabaseID: db.ID, StorageGB: 100},
http.StatusOK,
&resp,
)
assert.Equal(t, ChangeStorageApplyImmediate, resp.ApplyMode)
assert.Equal(t, 50, resp.CurrentGB)
assert.NotNil(t, resp.PendingGB)
assert.Equal(t, 100, *resp.PendingGB)
assert.Equal(t, 100, mock.upgradeCalledWithGB)
}
func Test_ChangeStorage_WhenDowngrading_ReturnsNextCycleMode(t *testing.T) {
router, owner, _, db, mock := setupBillingTest(t)
activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 100)
var resp ChangeStorageResponse
test_utils.MakePostRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/change-storage",
"Bearer "+owner.Token,
ChangeStorageRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusOK,
&resp,
)
assert.Equal(t, ChangeStorageApplyNextCycle, resp.ApplyMode)
assert.Equal(t, 100, resp.CurrentGB)
assert.NotNil(t, resp.PendingGB)
assert.Equal(t, 50, *resp.PendingGB)
assert.Equal(t, 50, mock.scheduleDowngradeCalledWithGB)
sub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.NotNil(t, sub.PendingStorageGB)
assert.Equal(t, 50, *sub.PendingStorageGB)
}
func Test_ChangeStorage_WhenSameAsCurrentStorage_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription/change-storage",
"Bearer "+owner.Token,
ChangeStorageRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusInternalServerError,
)
}
func Test_ChangeStorage_BelowMinimum_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription/change-storage",
"Bearer "+owner.Token,
ChangeStorageRequest{DatabaseID: db.ID, StorageGB: 5},
http.StatusInternalServerError,
)
}
func Test_ChangeStorage_WhenNoActiveSubscription_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
expireActiveSubscription(t, db.ID)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription/change-storage",
"Bearer "+owner.Token,
ChangeStorageRequest{DatabaseID: db.ID, StorageGB: 50},
http.StatusInternalServerError,
)
}
// ---- Category 5: GetPortalSession Endpoint ----
func Test_GetPortalSession_WhenSubscriptionActive_ReturnsPortalURL(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
var resp GetPortalSessionResponse
test_utils.MakePostRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/portal/"+sub.ID.String(),
"Bearer "+owner.Token,
nil,
http.StatusOK,
&resp,
)
assert.Equal(t, "https://portal.test/mock-portal", resp.PortalURL)
}
func Test_GetPortalSession_WhenSubscriptionCanceled_ReturnsPortalURL(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
err := billingService.CancelSubscription(slog.Default(), sub, makeWebhookEvent(db.ID, "evt-cancel-portal", ""))
assert.NoError(t, err)
var resp GetPortalSessionResponse
test_utils.MakePostRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/portal/"+sub.ID.String(),
"Bearer "+owner.Token,
nil,
http.StatusOK,
&resp,
)
assert.Equal(t, "https://portal.test/mock-portal", resp.PortalURL)
}
func Test_GetPortalSession_WhenSubscriptionExpired_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
sub.Status = billing_models.StatusExpired
sub.UpdatedAt = time.Now().UTC()
err := billingService.subscriptionRepository.Save(*sub)
assert.NoError(t, err)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription/portal/"+sub.ID.String(),
"Bearer "+owner.Token,
nil,
http.StatusInternalServerError,
)
}
func Test_GetPortalSession_UserWithoutAccess_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
otherUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription/portal/"+sub.ID.String(),
"Bearer "+otherUser.Token,
nil,
http.StatusInternalServerError,
)
}
// ---- Category 6: GetSubscriptionEvents Endpoint ----
func Test_GetSubscriptionEvents_AfterMultipleChanges_ReturnsAllEvents(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
_ = billingService.RecordPaymentFailed(log, sub, makeWebhookEvent(db.ID, "evt-fail-1", ""))
_ = billingService.RecordPaymentSuccess(
log,
sub,
makeWebhookEvent(db.ID, "evt-pay-1", "inv-"+uuid.New().String()[:8]),
)
var response GetSubscriptionEventsResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/events/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&response,
)
assert.GreaterOrEqual(t, len(response.Events), 3)
assert.GreaterOrEqual(t, response.Total, int64(3))
eventTypes := extractEventTypes(response.Events)
assert.Contains(t, eventTypes, billing_models.EventCreated)
assert.Contains(t, eventTypes, billing_models.EventPastDue)
assert.Contains(t, eventTypes, billing_models.EventRecoveredFromPastDue)
}
func Test_GetSubscriptionEvents_UserWithoutAccess_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
otherUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
test_utils.MakeGetRequest(
t, router,
"/api/v1/billing/subscription/events/"+sub.ID.String(),
"Bearer "+otherUser.Token,
http.StatusInternalServerError,
)
}
func Test_GetSubscriptionEvents_WithPagination_ReturnsCorrectPage(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
_ = billingService.RecordPaymentFailed(log, sub, makeWebhookEvent(db.ID, "evt-pag-fail-1", ""))
_ = billingService.RecordPaymentSuccess(
log,
sub,
makeWebhookEvent(db.ID, "evt-pag-pay-1", "inv-"+uuid.New().String()[:8]),
)
var fullResponse GetSubscriptionEventsResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/events/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&fullResponse,
)
totalEvents := fullResponse.Total
assert.GreaterOrEqual(t, totalEvents, int64(3))
var paginatedResponse GetSubscriptionEventsResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/events/"+sub.ID.String()+"?limit=2&offset=1",
"Bearer "+owner.Token,
http.StatusOK,
&paginatedResponse,
)
assert.Len(t, paginatedResponse.Events, 2)
assert.Equal(t, totalEvents, paginatedResponse.Total)
assert.Equal(t, 2, paginatedResponse.Limit)
assert.Equal(t, 1, paginatedResponse.Offset)
}
// ---- Category 7: GetInvoices Endpoint ----
func Test_GetInvoices_AfterPayment_ReturnsInvoice(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
invoiceID := "inv-test-" + uuid.New().String()[:8]
evt := makePaymentWebhookEvent(invoiceID, 50, 500)
err := billingService.RecordPaymentSuccess(log, sub, evt)
assert.NoError(t, err)
var response GetInvoicesResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/invoices/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&response,
)
assert.Len(t, response.Invoices, 1)
assert.Equal(t, int64(1), response.Total)
assert.Equal(t, int64(500), response.Invoices[0].AmountCents)
assert.Equal(t, 50, response.Invoices[0].StorageGB)
assert.Equal(t, billing_models.InvoiceStatusPaid, response.Invoices[0].Status)
assert.NotNil(t, response.Invoices[0].PaidAt)
}
func Test_GetInvoices_WhenDuplicatePayment_ReturnsOnlyOneInvoice(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
evt := makePaymentWebhookEvent("inv-dup-"+uuid.New().String()[:8], 50, 500)
_ = billingService.RecordPaymentSuccess(log, sub, evt)
_ = billingService.RecordPaymentSuccess(log, sub, evt)
var response GetInvoicesResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/invoices/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&response,
)
assert.Len(t, response.Invoices, 1)
}
func Test_GetInvoices_UserWithoutAccess_ReturnsError(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
otherUser := users_testing.CreateTestUser(users_enums.UserRoleMember)
test_utils.MakeGetRequest(
t, router,
"/api/v1/billing/subscription/invoices/"+sub.ID.String(),
"Bearer "+otherUser.Token,
http.StatusInternalServerError,
)
}
func Test_GetInvoices_WithPagination_ReturnsCorrectPage(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
for i := 0; i < 3; i++ {
invoiceID := fmt.Sprintf("inv-pag-%d-%s", i, uuid.New().String()[:8])
evt := makePaymentWebhookEvent(invoiceID, 50, int64(500+i*100))
err := billingService.RecordPaymentSuccess(log, sub, evt)
assert.NoError(t, err)
}
var fullResponse GetInvoicesResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/invoices/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&fullResponse,
)
assert.Equal(t, int64(3), fullResponse.Total)
assert.Len(t, fullResponse.Invoices, 3)
var paginatedResponse GetInvoicesResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/invoices/"+sub.ID.String()+"?limit=2&offset=1",
"Bearer "+owner.Token,
http.StatusOK,
&paginatedResponse,
)
assert.Len(t, paginatedResponse.Invoices, 2)
assert.Equal(t, int64(3), paginatedResponse.Total)
assert.Equal(t, 2, paginatedResponse.Limit)
assert.Equal(t, 1, paginatedResponse.Offset)
}
// ---- Category 8: Webhook Handlers ----
func Test_ActivateSubscription_ValidEvent_CreatesActiveSubscription(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
expireActiveSubscription(t, db.ID)
providerSubID := "sub-activate-test-" + uuid.New().String()
providerCustomerID := "cus-activate-test-" + uuid.New().String()
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
err := billingService.ActivateSubscription(log, billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-created-1",
DatabaseID: &db.ID,
ProviderSubscriptionID: providerSubID,
ProviderCustomerID: providerCustomerID,
QuantityGB: 50,
PeriodStart: &now,
PeriodEnd: &periodEnd,
})
assert.NoError(t, err)
sub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusActive, sub.Status)
assert.Equal(t, 50, sub.StorageGB)
assert.NotNil(t, sub.ProviderSubID)
assert.Equal(t, providerSubID, *sub.ProviderSubID)
assert.NotNil(t, sub.ProviderCustomerID)
assert.Equal(t, providerCustomerID, *sub.ProviderCustomerID)
}
func Test_ActivateSubscription_WhenDuplicate_SkipsIdempotently(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
expireActiveSubscription(t, db.ID)
providerSubID := "sub-idempotent-" + uuid.New().String()
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
evt := billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-dup-created",
DatabaseID: &db.ID,
ProviderSubscriptionID: providerSubID,
ProviderCustomerID: "cus-dup-test",
QuantityGB: 50,
PeriodStart: &now,
PeriodEnd: &periodEnd,
}
err1 := billingService.ActivateSubscription(log, evt)
err2 := billingService.ActivateSubscription(log, evt)
assert.NoError(t, err1)
assert.NoError(t, err2)
sub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusActive, sub.Status)
}
func Test_SyncSubscriptionFromProvider_WhenSameQuantity_StartsNewBillingCycle(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
originalStorage := sub.StorageGB
newStart := time.Now().UTC().Add(30 * 24 * time.Hour)
newEnd := newStart.Add(30 * 24 * time.Hour)
err := billingService.SyncSubscriptionFromProvider(log, sub, billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-renewed-1",
QuantityGB: originalStorage,
Status: billing_models.StatusActive,
PeriodStart: &newStart,
PeriodEnd: &newEnd,
})
assert.NoError(t, err)
updated := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusActive, updated.Status)
assert.Equal(t, originalStorage, updated.StorageGB)
assert.Nil(t, updated.PendingStorageGB)
assert.WithinDuration(t, newEnd, updated.CurrentPeriodEnd, time.Second)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventNewBillingCycleStarted)
}
func Test_SyncSubscriptionFromProvider_WhenQuantityDecreased_AppliesDowngrade(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 100)
newStart := time.Now().UTC().Add(30 * 24 * time.Hour)
newEnd := newStart.Add(30 * 24 * time.Hour)
err := billingService.SyncSubscriptionFromProvider(log, sub, billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-renewed-pending",
QuantityGB: 50,
Status: billing_models.StatusActive,
PeriodStart: &newStart,
PeriodEnd: &newEnd,
})
assert.NoError(t, err)
updated := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, 50, updated.StorageGB)
assert.Nil(t, updated.PendingStorageGB)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventDowngraded)
}
func Test_CancelSubscription_FromActive_GracePeriodAfterPeriodEnd(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
periodEnd := sub.CurrentPeriodEnd
err := billingService.CancelSubscription(log, sub, makeWebhookEvent(db.ID, "evt-cancel-active", ""))
assert.NoError(t, err)
updated := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusCanceled, updated.Status)
assert.NotNil(t, updated.CanceledAt)
assert.NotNil(t, updated.DataRetentionGracePeriodUntil)
expectedGrace := periodEnd.Add(config.GetEnv().GracePeriod)
assert.WithinDuration(t, expectedGrace, *updated.DataRetentionGracePeriodUntil, 5*time.Second)
}
func Test_CancelSubscription_FromPastDue_ImmediateGracePeriod(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
_ = billingService.RecordPaymentFailed(log, sub, makeWebhookEvent(db.ID, "evt-fail-for-cancel", "inv-fail-1"))
sub = getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusPastDue, sub.Status)
cancelTime := time.Now().UTC()
err := billingService.CancelSubscription(log, sub, makeWebhookEvent(db.ID, "evt-cancel-pastdue", ""))
assert.NoError(t, err)
updated := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusCanceled, updated.Status)
assert.NotNil(t, updated.DataRetentionGracePeriodUntil)
expectedGrace := cancelTime.Add(config.GetEnv().GracePeriod)
assert.WithinDuration(t, expectedGrace, *updated.DataRetentionGracePeriodUntil, 5*time.Second)
}
func Test_RecordPaymentSuccess_WhenActive_CreatesInvoice(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
err := billingService.RecordPaymentSuccess(
log,
sub,
makePaymentWebhookEvent("inv-active-"+uuid.New().String()[:8], 50, 1000),
)
assert.NoError(t, err)
updated := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusActive, updated.Status)
invoices := getInvoicesViaAPI(t, router, owner.Token, sub.ID)
assert.Len(t, invoices, 1)
assert.Equal(t, int64(1000), invoices[0].AmountCents)
assert.Equal(t, billing_models.InvoiceStatusPaid, invoices[0].Status)
}
func Test_RecordPaymentSuccess_WhenPastDue_RecoversToActive(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
_ = billingService.RecordPaymentFailed(log, sub, makeWebhookEvent(db.ID, "evt-fail-2", "inv-fail-2"))
sub = getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusPastDue, sub.Status)
err := billingService.RecordPaymentSuccess(
log,
sub,
makePaymentWebhookEvent("inv-recovery-"+uuid.New().String()[:8], 50, 1000),
)
assert.NoError(t, err)
updated := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusActive, updated.Status)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventRecoveredFromPastDue)
}
func Test_RecordPaymentFailed_WhenActive_SetsPastDue(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
err := billingService.RecordPaymentFailed(log, sub, makeWebhookEvent(db.ID, "evt-fail-3", "inv-fail-3"))
assert.NoError(t, err)
updated := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusPastDue, updated.Status)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventPastDue)
}
func Test_SyncSubscriptionFromProvider_WhenQuantityIncreased_ConfirmsUpgrade(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
err := billingService.SyncSubscriptionFromProvider(log, sub, billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-upgrade-confirm",
QuantityGB: 100,
Status: billing_models.StatusActive,
PeriodStart: &now,
PeriodEnd: &periodEnd,
})
assert.NoError(t, err)
updated := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, 100, updated.StorageGB)
assert.Nil(t, updated.PendingStorageGB)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventUpgraded)
}
func Test_RecordDispute_WhenCalled_CancelsAndMarksInvoiceDisputed(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
invoiceID := "inv-dispute-" + uuid.New().String()[:8]
_ = billingService.RecordPaymentSuccess(log, sub, makePaymentWebhookEvent(invoiceID, 50, 1000))
err := billingService.RecordDispute(log, billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-dispute-1",
ProviderInvoiceID: invoiceID,
})
assert.NoError(t, err)
updated := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusCanceled, updated.Status)
assert.NotNil(t, updated.CanceledAt)
assert.Nil(t, updated.DataRetentionGracePeriodUntil)
invoices := getInvoicesViaAPI(t, router, owner.Token, sub.ID)
assert.Len(t, invoices, 1)
assert.Equal(t, billing_models.InvoiceStatusDisputed, invoices[0].Status)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventDispute)
}
// ---- Category 9: Background Processing ----
func Test_ProcessExpiredSubscriptions_WhenGracePeriodEnded_MarksExpired(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
_ = billingService.CancelSubscription(log, sub, makeWebhookEvent(db.ID, "evt-cancel-bg", ""))
sub = refreshSubscription(t, sub.ID)
pastGrace := time.Now().UTC().Add(-1 * time.Hour)
sub.DataRetentionGracePeriodUntil = &pastGrace
_ = billingService.subscriptionRepository.Save(*sub)
err := billingService.processExpiredSubscriptions(log)
assert.NoError(t, err)
updated := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusExpired, updated.Status)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventExpired)
}
func Test_ProcessExpiredSubscriptions_WhenGracePeriodActive_NoChange(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
_ = billingService.CancelSubscription(log, sub, makeWebhookEvent(db.ID, "evt-cancel-bg2", ""))
sub = refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusCanceled, sub.Status)
err := billingService.processExpiredSubscriptions(log)
assert.NoError(t, err)
updated := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusCanceled, updated.Status)
}
func Test_ProcessExpiredTrials_WhenTrialEnded_MarksExpired(t *testing.T) {
router, owner, _, db := setupBillingTestWithoutProvider(t)
log := slog.Default()
sub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusTrial, sub.Status)
pastEnd := time.Now().UTC().Add(-1 * time.Hour)
sub.CurrentPeriodEnd = pastEnd
_ = billingService.subscriptionRepository.Save(*sub)
err := billingService.processExpiredTrials(log)
assert.NoError(t, err)
updated := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusExpired, updated.Status)
assert.NotNil(t, updated.CanceledAt)
events := getEventsViaAPI(t, router, owner.Token, sub.ID)
eventTypes := extractEventTypes(events)
assert.Contains(t, eventTypes, billing_models.EventExpired)
}
func Test_ProcessExpiredTrials_WhenTrialActive_NoChange(t *testing.T) {
router, owner, _, db := setupBillingTestWithoutProvider(t)
log := slog.Default()
sub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusTrial, sub.Status)
err := billingService.processExpiredTrials(log)
assert.NoError(t, err)
updated := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusTrial, updated.Status)
}
// ---- Category 10: Full Lifecycle ----
func Test_FullLifecycle_TrialToActiveToUpgradeToCancelToExpired(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
// Step 1: Trial created on DB creation
trialSub := getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusTrial, trialSub.Status)
// Step 2: Expire trial
pastEnd := time.Now().UTC().Add(-1 * time.Hour)
trialSub.CurrentPeriodEnd = pastEnd
_ = billingService.subscriptionRepository.Save(*trialSub)
_ = billingService.processExpiredTrials(log)
// Step 3: Activate paid subscription
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
assert.Equal(t, billing_models.StatusActive, sub.Status)
// Step 4: Upgrade storage
test_utils.MakePostRequest(
t, router,
"/api/v1/billing/subscription/change-storage",
"Bearer "+owner.Token,
ChangeStorageRequest{DatabaseID: db.ID, StorageGB: 100},
http.StatusOK,
)
// Step 5: Confirm upgrade via webhook
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
_ = billingService.SyncSubscriptionFromProvider(log, sub, billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-upgrade-lifecycle",
QuantityGB: 100,
Status: billing_models.StatusActive,
PeriodStart: &now,
PeriodEnd: &periodEnd,
})
sub = getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, 100, sub.StorageGB)
// Step 6: Payment success
_ = billingService.RecordPaymentSuccess(
log,
sub,
makePaymentWebhookEvent("inv-lifecycle-"+uuid.New().String()[:8], 100, 2000),
)
// Step 7: Cancel
_ = billingService.CancelSubscription(log, sub, makeWebhookEvent(db.ID, "evt-cancel-lifecycle", ""))
// Step 8: Expire
sub = refreshSubscription(t, sub.ID)
pastGrace := time.Now().UTC().Add(-1 * time.Hour)
sub.DataRetentionGracePeriodUntil = &pastGrace
_ = billingService.subscriptionRepository.Save(*sub)
_ = billingService.processExpiredSubscriptions(log)
finalSub := refreshSubscription(t, sub.ID)
assert.Equal(t, billing_models.StatusExpired, finalSub.Status)
// Step 9: Verify events via HTTP
var eventsResponse GetSubscriptionEventsResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/events/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&eventsResponse,
)
eventTypes := extractEventTypes(eventsResponse.Events)
assert.Contains(t, eventTypes, billing_models.EventCreated)
assert.Contains(t, eventTypes, billing_models.EventUpgraded)
assert.Contains(t, eventTypes, billing_models.EventCanceled)
assert.Contains(t, eventTypes, billing_models.EventExpired)
// Step 10: Verify invoice via HTTP
var invoicesResponse GetInvoicesResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/invoices/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&invoicesResponse,
)
assert.Len(t, invoicesResponse.Invoices, 1)
assert.Equal(t, billing_models.InvoiceStatusPaid, invoicesResponse.Invoices[0].Status)
}
func Test_FullLifecycle_ActiveToPastDueToRecovery(t *testing.T) {
router, owner, _, db, _ := setupBillingTest(t)
log := slog.Default()
sub := activateSubscriptionViaWebhook(t, router, owner.Token, db.ID, 50)
assert.Equal(t, billing_models.StatusActive, sub.Status)
_ = billingService.RecordPaymentFailed(log, sub, makeWebhookEvent(db.ID, "evt-fail-lc", "inv-fail-lc"))
sub = getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusPastDue, sub.Status)
_ = billingService.RecordPaymentSuccess(
log,
sub,
makePaymentWebhookEvent("inv-recovery-lc-"+uuid.New().String()[:8], 50, 1000),
)
sub = getSubscriptionViaAPI(t, router, owner.Token, db.ID)
assert.Equal(t, billing_models.StatusActive, sub.Status)
var eventsResponse GetSubscriptionEventsResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/events/"+sub.ID.String(),
"Bearer "+owner.Token,
http.StatusOK,
&eventsResponse,
)
eventTypes := extractEventTypes(eventsResponse.Events)
assert.Contains(t, eventTypes, billing_models.EventCreated)
assert.Contains(t, eventTypes, billing_models.EventPastDue)
assert.Contains(t, eventTypes, billing_models.EventRecoveredFromPastDue)
}
// ---- Private Helpers ----
type mockBillingProvider struct {
providerName billing_provider.ProviderName
createCheckoutSessionTransactionID string
createCheckoutSessionErr error
upgradeErr error
upgradeCalledWithGB int
scheduleDowngradeErr error
scheduleDowngradeCalledWithGB int
getSubscriptionResult billing_provider.ProviderSubscription
getSubscriptionErr error
createPortalSessionURL string
createPortalSessionErr error
}
func (m *mockBillingProvider) GetProviderName() billing_provider.ProviderName {
return m.providerName
}
func (m *mockBillingProvider) CreateCheckoutSession(
_ *slog.Logger,
_ billing_provider.CheckoutRequest,
) (string, error) {
return m.createCheckoutSessionTransactionID, m.createCheckoutSessionErr
}
func (m *mockBillingProvider) UpgradeQuantityWithSurcharge(_ *slog.Logger, _ string, quantityGB int) error {
m.upgradeCalledWithGB = quantityGB
return m.upgradeErr
}
func (m *mockBillingProvider) ScheduleQuantityDowngradeFromNextBillingCycle(
_ *slog.Logger,
_ string,
quantityGB int,
) error {
m.scheduleDowngradeCalledWithGB = quantityGB
return m.scheduleDowngradeErr
}
func (m *mockBillingProvider) GetSubscription(_ *slog.Logger, _ string) (billing_provider.ProviderSubscription, error) {
return m.getSubscriptionResult, m.getSubscriptionErr
}
func (m *mockBillingProvider) CreatePortalSession(_ *slog.Logger, _, _ string) (string, error) {
return m.createPortalSessionURL, m.createPortalSessionErr
}
func newMockProvider() *mockBillingProvider {
mock := &mockBillingProvider{
providerName: "mock",
createCheckoutSessionTransactionID: "txn_mock_test_123",
createPortalSessionURL: "https://portal.test/mock-portal",
}
billingService.SetBillingProvider(mock)
return mock
}
func enableCloud(t *testing.T) {
t.Helper()
config.GetEnv().IsCloud = true
t.Cleanup(func() {
config.GetEnv().IsCloud = false
})
}
func createBillingTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
v1 := router.Group("/api/v1")
protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))
workspaces_controllers.GetWorkspaceController().RegisterRoutes(protected.(*gin.RouterGroup))
workspaces_controllers.GetMembershipController().RegisterRoutes(protected.(*gin.RouterGroup))
databases.GetDatabaseController().RegisterRoutes(protected.(*gin.RouterGroup))
GetBillingController().RegisterRoutes(protected.(*gin.RouterGroup))
SetupDependencies()
audit_logs.SetupDependencies()
return router
}
func setupBillingTestWithoutProvider(
t *testing.T,
) (*gin.Engine, *users_dto.SignInResponseDTO, *workspaces_models.Workspace, *databases.Database) {
t.Helper()
router := createBillingTestRouter()
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
workspace := workspaces_testing.CreateTestWorkspace("billing-test-"+uuid.New().String()[:8], owner, router)
// DB creation validates read-only user in cloud mode, so create DB first, then enable cloud
db := createTestDatabaseForBilling(owner.Token, workspace.ID, router)
enableCloud(t)
t.Cleanup(func() {
cleanupBillingTestData(db.ID)
databases.RemoveTestDatabase(db)
workspaces_testing.RemoveTestWorkspace(workspace, router)
})
return router, owner, workspace, db
}
func setupBillingTest(
t *testing.T,
) (*gin.Engine, *users_dto.SignInResponseDTO, *workspaces_models.Workspace, *databases.Database, *mockBillingProvider) {
t.Helper()
router, owner, workspace, db := setupBillingTestWithoutProvider(t)
mock := newMockProvider()
return router, owner, workspace, db, mock
}
func createTestDatabaseForBilling(token string, workspaceID uuid.UUID, router *gin.Engine) *databases.Database {
env := config.GetEnv()
port, err := strconv.Atoi(env.TestPostgres16Port)
if err != nil {
panic(fmt.Sprintf("failed to parse TEST_POSTGRES_16_PORT: %v", err))
}
dbName := "testdb"
request := databases.Database{
Name: "billing-test-" + uuid.New().String()[:8],
WorkspaceID: &workspaceID,
Type: databases.DatabaseTypePostgres,
Postgresql: &postgresql.PostgresqlDatabase{
Version: tools.PostgresqlVersion16,
Host: env.TestLocalhost,
Port: port,
Username: "testuser",
Password: "testpassword",
Database: &dbName,
CpuCount: 1,
},
}
w := workspaces_testing.MakeAPIRequest(router, "POST", "/api/v1/databases/create", "Bearer "+token, request)
if w.Code != http.StatusCreated {
panic(fmt.Sprintf("failed to create database for billing test. Status: %d, Body: %s", w.Code, w.Body.String()))
}
var db databases.Database
if err := json.Unmarshal(w.Body.Bytes(), &db); err != nil {
panic(err)
}
return &db
}
func activateSubscriptionViaWebhook(
t *testing.T,
router *gin.Engine,
token string,
databaseID uuid.UUID,
storageGB int,
) *billing_models.Subscription {
t.Helper()
expireActiveSubscription(t, databaseID)
providerSubID := "sub-" + uuid.New().String()
providerCustomerID := "cus-" + uuid.New().String()
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
err := billingService.ActivateSubscription(slog.Default(), billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-" + uuid.New().String(),
DatabaseID: &databaseID,
ProviderSubscriptionID: providerSubID,
ProviderCustomerID: providerCustomerID,
QuantityGB: storageGB,
PeriodStart: &now,
PeriodEnd: &periodEnd,
})
if err != nil {
t.Fatalf("activateSubscriptionViaWebhook: %v", err)
}
return getSubscriptionViaAPI(t, router, token, databaseID)
}
func expireActiveSubscription(t *testing.T, databaseID uuid.UUID) {
t.Helper()
subs, err := billingService.subscriptionRepository.FindByDatabaseIDAndStatuses(
databaseID,
[]billing_models.SubscriptionStatus{
billing_models.StatusTrial,
billing_models.StatusActive,
billing_models.StatusPastDue,
},
)
if err != nil || len(subs) == 0 {
return
}
for _, sub := range subs {
sub.Status = billing_models.StatusExpired
sub.UpdatedAt = time.Now().UTC()
if saveErr := billingService.subscriptionRepository.Save(*sub); saveErr != nil {
t.Fatalf("expireActiveSubscription: save failed: %v", saveErr)
}
}
}
func getSubscriptionViaAPI(
t *testing.T,
router *gin.Engine,
token string,
databaseID uuid.UUID,
) *billing_models.Subscription {
t.Helper()
var sub billing_models.Subscription
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/"+databaseID.String(),
"Bearer "+token,
http.StatusOK,
&sub,
)
return &sub
}
func getEventsViaAPI(
t *testing.T,
router *gin.Engine,
token string,
subscriptionID uuid.UUID,
) []*billing_models.SubscriptionEvent {
t.Helper()
var response GetSubscriptionEventsResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/events/"+subscriptionID.String(),
"Bearer "+token,
http.StatusOK,
&response,
)
return response.Events
}
func getInvoicesViaAPI(
t *testing.T,
router *gin.Engine,
token string,
subscriptionID uuid.UUID,
) []*billing_models.Invoice {
t.Helper()
var response GetInvoicesResponse
test_utils.MakeGetRequestAndUnmarshal(
t, router,
"/api/v1/billing/subscription/invoices/"+subscriptionID.String(),
"Bearer "+token,
http.StatusOK,
&response,
)
return response.Invoices
}
func refreshSubscription(t *testing.T, subID uuid.UUID) *billing_models.Subscription {
t.Helper()
sub, err := billingService.subscriptionRepository.FindByID(subID)
if err != nil || sub == nil {
t.Fatalf("refreshSubscription: not found: %v", err)
}
return sub
}
func makeWebhookEvent(databaseID uuid.UUID, providerEventID, providerInvoiceID string) billing_models.WebhookEvent {
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
return billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: providerEventID,
DatabaseID: &databaseID,
ProviderInvoiceID: providerInvoiceID,
PeriodStart: &now,
PeriodEnd: &periodEnd,
}
}
func makePaymentWebhookEvent(providerInvoiceID string, quantityGB int, amountCents int64) billing_models.WebhookEvent {
now := time.Now().UTC()
periodEnd := now.Add(30 * 24 * time.Hour)
return billing_models.WebhookEvent{
RequestID: uuid.New(),
ProviderEventID: "evt-" + uuid.New().String()[:8],
ProviderSubscriptionID: "sub-test-123",
ProviderInvoiceID: providerInvoiceID,
QuantityGB: quantityGB,
AmountCents: amountCents,
PeriodStart: &now,
PeriodEnd: &periodEnd,
}
}
func cleanupBillingTestData(databaseID uuid.UUID) {
db := storage.GetDb()
db.Exec(
`DELETE FROM invoices WHERE subscription_id IN (SELECT id FROM subscriptions WHERE database_id = ?)`,
databaseID,
)
db.Exec(
`DELETE FROM subscription_events WHERE subscription_id IN (SELECT id FROM subscriptions WHERE database_id = ?)`,
databaseID,
)
db.Exec(`DELETE FROM subscriptions WHERE database_id = ?`, databaseID)
}
func extractEventTypes(events []*billing_models.SubscriptionEvent) []billing_models.SubscriptionEventType {
types := make([]billing_models.SubscriptionEventType, len(events))
for i, e := range events {
types[i] = e.Type
}
return types
}