mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
1451 lines
45 KiB
Go
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
|
|
}
|