260 lines
7.6 KiB
Go
260 lines
7.6 KiB
Go
package billing
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
func openStripeMockPool(t *testing.T) (*pgxpool.Pool, context.Context, context.CancelFunc) {
|
||
|
|
t.Helper()
|
||
|
|
dsn := os.Getenv("DATABASE_URL")
|
||
|
|
if dsn == "" {
|
||
|
|
t.Skip("DATABASE_URL not set")
|
||
|
|
}
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||
|
|
pg, err := pgxpool.New(ctx, dsn)
|
||
|
|
if err != nil {
|
||
|
|
cancel()
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() { pg.Close() })
|
||
|
|
return pg, ctx, cancel
|
||
|
|
}
|
||
|
|
|
||
|
|
func seedStripeMockCompany(t *testing.T, pg *pgxpool.Pool, ctx context.Context) uuid.UUID {
|
||
|
|
t.Helper()
|
||
|
|
companyID := uuid.New()
|
||
|
|
_, err := pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "stripe-mock-"+companyID.String()[:8])
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() {
|
||
|
|
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
|
|
defer cleanupCancel()
|
||
|
|
_, _ = pg.Exec(cleanupCtx, `DELETE FROM stripe_webhook_events WHERE company_id = $1`, companyID)
|
||
|
|
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||
|
|
})
|
||
|
|
return companyID
|
||
|
|
}
|
||
|
|
|
||
|
|
func creditTotal(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) int {
|
||
|
|
t.Helper()
|
||
|
|
var total int
|
||
|
|
err := pg.QueryRow(ctx, `SELECT COALESCE(total_credits, 0) FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total)
|
||
|
|
if err != nil {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return total
|
||
|
|
}
|
||
|
|
|
||
|
|
func activePlanName(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) string {
|
||
|
|
t.Helper()
|
||
|
|
var name string
|
||
|
|
err := pg.QueryRow(ctx, `
|
||
|
|
SELECT lower(p.name) FROM company_plans cp
|
||
|
|
JOIN plans p ON p.id = cp.plan_id
|
||
|
|
WHERE cp.company_id = $1 AND cp.is_active = true
|
||
|
|
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&name)
|
||
|
|
if err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return name
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMockCheckoutPlanAssignsAndGrants(t *testing.T) {
|
||
|
|
pg, ctx, cancel := openStripeMockPool(t)
|
||
|
|
defer cancel()
|
||
|
|
companyID := seedStripeMockCompany(t, pg, ctx)
|
||
|
|
billing := &Service{Pool: pg}
|
||
|
|
if err := billing.EnsureDefaultPlans(ctx); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
s := &StripeService{
|
||
|
|
Pool: pg,
|
||
|
|
Billing: billing,
|
||
|
|
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
|
||
|
|
}
|
||
|
|
res, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "starter", Term: "monthly"})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if !res.Mock || !res.Applied {
|
||
|
|
t.Fatalf("expected mock applied checkout, got %#v", res)
|
||
|
|
}
|
||
|
|
if activePlanName(t, pg, ctx, companyID) != "starter" {
|
||
|
|
t.Fatalf("plan=%q want starter", activePlanName(t, pg, ctx, companyID))
|
||
|
|
}
|
||
|
|
want := MonthlyCreditsForPlan("Starter", 0)
|
||
|
|
if got := creditTotal(t, pg, ctx, companyID); got != want {
|
||
|
|
t.Fatalf("credits=%d want %d", got, want)
|
||
|
|
}
|
||
|
|
var subID *string
|
||
|
|
_ = pg.QueryRow(ctx, `
|
||
|
|
SELECT stripe_subscription_id FROM company_plans
|
||
|
|
WHERE company_id = $1 AND is_active = true`, companyID).Scan(&subID)
|
||
|
|
if subID == nil || *subID == "" {
|
||
|
|
t.Fatal("mock checkout must set stripe_subscription_id")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestMockCreditPackCheckoutGrants(t *testing.T) {
|
||
|
|
pg, ctx, cancel := openStripeMockPool(t)
|
||
|
|
defer cancel()
|
||
|
|
companyID := seedStripeMockCompany(t, pg, ctx)
|
||
|
|
billing := &Service{Pool: pg}
|
||
|
|
s := &StripeService{
|
||
|
|
Pool: pg,
|
||
|
|
Billing: billing,
|
||
|
|
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
|
||
|
|
}
|
||
|
|
before := creditTotal(t, pg, ctx, companyID)
|
||
|
|
res, err := s.CreateCreditPackCheckout(ctx, companyID, "mock@example.com", "Mock Co", "small")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if !res.Mock || !res.Applied {
|
||
|
|
t.Fatalf("expected mock applied pack, got %#v", res)
|
||
|
|
}
|
||
|
|
pack, _ := CreditPackByID("small")
|
||
|
|
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
|
||
|
|
t.Fatalf("credits=%d want %d", got, before+pack.Credits)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWebhookClaimIdempotentAndCreditGrant(t *testing.T) {
|
||
|
|
pg, ctx, cancel := openStripeMockPool(t)
|
||
|
|
defer cancel()
|
||
|
|
companyID := seedStripeMockCompany(t, pg, ctx)
|
||
|
|
billing := &Service{Pool: pg}
|
||
|
|
s := &StripeService{
|
||
|
|
Pool: pg,
|
||
|
|
Billing: billing,
|
||
|
|
Cfg: StripeConfig{ForceMock: true}, // unsigned allowed locally; no webhook secret
|
||
|
|
}
|
||
|
|
|
||
|
|
eventID := "evt_mock_credit_" + companyID.String()[:8]
|
||
|
|
payload, err := json.Marshal(map[string]any{
|
||
|
|
"id": eventID,
|
||
|
|
"type": "checkout.session.completed",
|
||
|
|
"data": map[string]any{
|
||
|
|
"object": map[string]any{
|
||
|
|
"id": "cs_mock_1",
|
||
|
|
"client_reference_id": companyID.String(),
|
||
|
|
"metadata": map[string]string{
|
||
|
|
"kind": "credit_pack",
|
||
|
|
"pack": "tiny",
|
||
|
|
"credits": "9999", // must be ignored for catalog pack
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
before := creditTotal(t, pg, ctx, companyID)
|
||
|
|
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
pack, _ := CreditPackByID("tiny")
|
||
|
|
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
|
||
|
|
t.Fatalf("after grant credits=%d want %d", got, before+pack.Credits)
|
||
|
|
}
|
||
|
|
mid := creditTotal(t, pg, ctx, companyID)
|
||
|
|
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got := creditTotal(t, pg, ctx, companyID); got != mid {
|
||
|
|
t.Fatalf("idempotent claim must not double-grant: got %d mid %d", got, mid)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWebhookSubscriptionDeletedDowngrades(t *testing.T) {
|
||
|
|
pg, ctx, cancel := openStripeMockPool(t)
|
||
|
|
defer cancel()
|
||
|
|
companyID := seedStripeMockCompany(t, pg, ctx)
|
||
|
|
billing := &Service{Pool: pg}
|
||
|
|
if err := billing.EnsureDefaultPlans(ctx); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
s := &StripeService{
|
||
|
|
Pool: pg,
|
||
|
|
Billing: billing,
|
||
|
|
Cfg: StripeConfig{ForceMock: true},
|
||
|
|
}
|
||
|
|
if _, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "plus", Term: "monthly"}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if activePlanName(t, pg, ctx, companyID) != "plus" {
|
||
|
|
t.Fatalf("precondition plan=%q", activePlanName(t, pg, ctx, companyID))
|
||
|
|
}
|
||
|
|
|
||
|
|
eventID := "evt_mock_del_" + companyID.String()[:8]
|
||
|
|
payload, err := json.Marshal(map[string]any{
|
||
|
|
"id": eventID,
|
||
|
|
"type": "customer.subscription.deleted",
|
||
|
|
"data": map[string]any{
|
||
|
|
"object": map[string]any{
|
||
|
|
"id": "sub_mock_del",
|
||
|
|
"customer": "cus_mock",
|
||
|
|
"status": "canceled",
|
||
|
|
"metadata": map[string]string{"company_id": companyID.String()},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if got := activePlanName(t, pg, ctx, companyID); got != "free" {
|
||
|
|
t.Fatalf("after delete plan=%q want free", got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWebhookVerifyStillRequiredWithSecretUnderForceMock(t *testing.T) {
|
||
|
|
pg, ctx, cancel := openStripeMockPool(t)
|
||
|
|
defer cancel()
|
||
|
|
companyID := seedStripeMockCompany(t, pg, ctx)
|
||
|
|
secret := "whsec_mock_local"
|
||
|
|
s := &StripeService{
|
||
|
|
Pool: pg,
|
||
|
|
Billing: &Service{Pool: pg},
|
||
|
|
Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret},
|
||
|
|
}
|
||
|
|
payload, err := json.Marshal(map[string]any{
|
||
|
|
"id": "evt_signed_" + companyID.String()[:8],
|
||
|
|
"type": "ping",
|
||
|
|
"data": map[string]any{"object": map[string]any{}},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := s.HandleWebhook(ctx, payload, ""); err == nil {
|
||
|
|
t.Fatal("unsigned must fail when webhook secret set")
|
||
|
|
}
|
||
|
|
sig := signStripePayload(t, secret, payload)
|
||
|
|
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
|
||
|
|
t.Fatalf("valid signature under ForceMock: %v", err)
|
||
|
|
}
|
||
|
|
// Second delivery is an idempotent no-op.
|
||
|
|
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
var n int
|
||
|
|
if err := pg.QueryRow(ctx, `SELECT COUNT(*) FROM stripe_webhook_events WHERE event_id = $1`,
|
||
|
|
fmt.Sprintf("evt_signed_%s", companyID.String()[:8])).Scan(&n); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if n != 1 {
|
||
|
|
t.Fatalf("claim rows=%d want 1", n)
|
||
|
|
}
|
||
|
|
}
|