This commit is contained in:
17
services/billing/Dockerfile
Normal file
17
services/billing/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/billing/ services/billing/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/billing-server ./services/billing/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/billing-server ./billing-server
|
||||
COPY --from=build /src/services/billing/migrations ./migrations
|
||||
ENV ENKLY_BILLING_GRPC_ADDR=:9002
|
||||
EXPOSE 9002
|
||||
ENTRYPOINT ["./billing-server"]
|
||||
109
services/billing/cmd/server/main.go
Normal file
109
services/billing/cmd/server/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Command server runs the billing microservice: a gRPC server exposing
|
||||
// api/proto/billing/v1, backed by its own Postgres database and the
|
||||
// Stripe REST API. Replaces internal/handlers/billing.go +
|
||||
// internal/billing/*.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/billing/internal/application"
|
||||
"enkly/services/billing/internal/infrastructure/postgres"
|
||||
"enkly/services/billing/internal/infrastructure/stripeapi"
|
||||
grpcsrv "enkly/services/billing/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
if err := envkit.LoadDotEnv(".env"); err != nil {
|
||||
logger.Error("load .env", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var (
|
||||
grpcAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", ":9002")
|
||||
postgresDSN = envkit.String("ENKLY_BILLING_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_billing?sslmode=disable")
|
||||
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
|
||||
migrationsPath = envkit.String("ENKLY_BILLING_MIGRATIONS", "migrations/0001_init.sql")
|
||||
|
||||
stripeSecretKey = envkit.String("STRIPE_SECRET_KEY", "")
|
||||
stripePublishKey = envkit.String("STRIPE_PUBLISH_KEY", "")
|
||||
stripeWebhookKey = envkit.String("STRIPE_WEBHOOK_SECRET", "")
|
||||
)
|
||||
|
||||
db, err := pgkit.Open(postgresDSN)
|
||||
if err != nil {
|
||||
logger.Error("open postgres", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
migrationSQL, err := os.ReadFile(migrationsPath)
|
||||
if err != nil {
|
||||
logger.Error("read migrations", "path", migrationsPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
|
||||
logger.Error("migrate", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
bus, err := eventbus.Connect(rabbitURL, logger)
|
||||
if err != nil {
|
||||
logger.Error("connect rabbitmq", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer bus.Close()
|
||||
|
||||
svc := &application.Service{
|
||||
Subscriptions: postgres.NewSubscriptionRepo(db),
|
||||
StripeEvents: postgres.NewStripeEventRepo(db),
|
||||
Usage: postgres.NewUsageRepo(db),
|
||||
Stripe: stripeapi.New(stripeSecretKey),
|
||||
WebhookSecret: stripeWebhookKey,
|
||||
Events: bus,
|
||||
Logger: logger,
|
||||
GetEnv: os.Getenv,
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
billingv1.RegisterBillingServiceServer(grpcServer, grpcsrv.New(svc, stripePublishKey))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("billing service listening", "addr", grpcAddr, "stripe_configured", stripeSecretKey != "")
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
logger.Error("serve", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
logger.Info("shutting down")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
320
services/billing/internal/application/service.go
Normal file
320
services/billing/internal/application/service.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Package application holds billing's use cases, transplanted from
|
||||
// internal/handlers/billing.go with the html/template "planView"
|
||||
// rendering left out — that's the gateway's job now (it calls
|
||||
// GetPlanCatalog + GetOrCreateSubscription and builds the page itself),
|
||||
// since presentation never belongs in a bounded context's core.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/services/billing/internal/domain"
|
||||
"enkly/services/billing/internal/infrastructure/stripeapi"
|
||||
)
|
||||
|
||||
type Publisher interface {
|
||||
PublishBestEffort(ctx context.Context, routingKey string, payload any)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Subscriptions domain.SubscriptionRepository
|
||||
StripeEvents domain.StripeEventRepository
|
||||
Usage domain.UsageRepository
|
||||
Stripe *stripeapi.Client
|
||||
WebhookSecret string
|
||||
Events Publisher
|
||||
Logger *slog.Logger
|
||||
|
||||
// GetPriceEnvVar resolves a plan's configured Stripe Price ID env
|
||||
// var at startup time (see cmd/server/main.go) — injected rather
|
||||
// than read from os.Getenv directly, same reasoning as the
|
||||
// monolith's billing.PriceIDs(getenv).
|
||||
GetEnv func(key string) string
|
||||
|
||||
priceMu sync.Mutex
|
||||
planPrices map[domain.Plan]string
|
||||
stripePrices map[string]domain.Plan
|
||||
}
|
||||
|
||||
// GetOrCreateSubscription replaces db.GetOrCreateSubscription.
|
||||
func (s *Service) GetOrCreateSubscription(ctx context.Context, userID int64) (*domain.Subscription, error) {
|
||||
return s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
}
|
||||
|
||||
// PlanCatalogEntry is GetPlanCatalog's per-plan result.
|
||||
type PlanCatalogEntry struct {
|
||||
domain.PlanInfo
|
||||
Available bool
|
||||
}
|
||||
|
||||
// GetPlanCatalog replaces the plan-availability half of
|
||||
// buildSubscriptionData: Free is always available, every paid plan is
|
||||
// available whenever Stripe itself is configured (its Price is created
|
||||
// on demand — see planPrice/EnsurePrice).
|
||||
func (s *Service) GetPlanCatalog(ctx context.Context) []PlanCatalogEntry {
|
||||
out := make([]PlanCatalogEntry, 0, len(domain.Catalog))
|
||||
for _, p := range domain.Catalog {
|
||||
out = append(out, PlanCatalogEntry{
|
||||
PlanInfo: p,
|
||||
Available: p.Plan == domain.PlanFree || s.Stripe.Configured(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CreateCheckoutSession replaces Handlers.CreateCheckout (minus the
|
||||
// "plan==free redirects to the portal" edge case, which the gateway
|
||||
// handles at the edge since it's pure routing, not a billing decision).
|
||||
func (s *Service) CreateCheckoutSession(ctx context.Context, userID int64, email string, plan domain.Plan, successURL, cancelURL string) (string, error) {
|
||||
if !s.Stripe.Configured() {
|
||||
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
|
||||
}
|
||||
priceID, err := s.planPrice(ctx, plan)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, checkoutURL, err := s.Stripe.CreateCheckoutSession(ctx, stripeapi.CheckoutParams{
|
||||
PriceID: priceID,
|
||||
CustomerID: sub.StripeCustomerID,
|
||||
CustomerEmail: email,
|
||||
ClientReferenceID: strconv.FormatInt(userID, 10),
|
||||
UserID: userID,
|
||||
SuccessURL: successURL,
|
||||
CancelURL: cancelURL,
|
||||
})
|
||||
return checkoutURL, err
|
||||
}
|
||||
|
||||
// SyncCheckoutSession replaces Handlers.syncCheckoutSession.
|
||||
func (s *Service) SyncCheckoutSession(ctx context.Context, userID int64, sessionID string) (*domain.Subscription, error) {
|
||||
sess, err := s.Stripe.GetCheckoutSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sess.ClientReferenceID != strconv.FormatInt(userID, 10) {
|
||||
return nil, fmt.Errorf("session %s does not belong to user %d", sess.ID, userID)
|
||||
}
|
||||
if sess.Subscription == "" || sess.Customer == "" {
|
||||
return nil, fmt.Errorf("session %s has no subscription yet", sess.ID)
|
||||
}
|
||||
stripeSub, err := s.Stripe.GetSubscription(ctx, sess.Subscription)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan, ok := s.planForPrice(ctx, stripeSub.PriceID())
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("subscription %s: unrecognized price %q", stripeSub.ID, stripeSub.PriceID())
|
||||
}
|
||||
var periodEnd time.Time
|
||||
if unix := stripeSub.PeriodEndUnix(); unix > 0 {
|
||||
periodEnd = time.Unix(unix, 0)
|
||||
}
|
||||
if err := s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, sess.Customer, stripeSub.ID, stripeSub.PriceID(),
|
||||
string(plan), stripeSub.Status, periodEnd, stripeSub.CancelAtPeriodEnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.publishChanged(ctx, userID, plan, stripeSub.Status, "checkout.session.completed")
|
||||
return s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
}
|
||||
|
||||
// CreateBillingPortalSession replaces Handlers.BillingPortal.
|
||||
func (s *Service) CreateBillingPortalSession(ctx context.Context, userID int64, returnURL string) (string, error) {
|
||||
if !s.Stripe.Configured() {
|
||||
return "", fmt.Errorf("billing: STRIPE_SECRET_KEY is not configured")
|
||||
}
|
||||
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sub.StripeCustomerID == "" {
|
||||
return "", errNoBillingAccount
|
||||
}
|
||||
return s.Stripe.CreateBillingPortalSession(ctx, sub.StripeCustomerID, returnURL)
|
||||
}
|
||||
|
||||
var errNoBillingAccount = errors.New("billing: no stripe customer on file yet")
|
||||
|
||||
// IsNoBillingAccount reports whether err is the "subscribe to a paid
|
||||
// plan first" case Handlers.BillingPortal used to check for via a bare
|
||||
// StripeCustomerID == "" comparison.
|
||||
func IsNoBillingAccount(err error) bool { return errors.Is(err, errNoBillingAccount) }
|
||||
|
||||
// CheckAndIncrementUsage replaces the daily-cap check inlined in
|
||||
// Handlers.Chat, now its own use case since the agent service calls it
|
||||
// once per turn over gRPC.
|
||||
func (s *Service) CheckAndIncrementUsage(ctx context.Context, userID int64) (allowed bool, used, limit int, planName string, err error) {
|
||||
sub, err := s.Subscriptions.GetOrCreate(ctx, userID)
|
||||
if err != nil {
|
||||
return false, 0, 0, "", err
|
||||
}
|
||||
info := domain.Get(sub.Plan)
|
||||
if info.DailyMessageLimit == 0 {
|
||||
// Unlimited plan: still counted (for the usage page), never capped.
|
||||
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
|
||||
return true, n, 0, info.Name, err
|
||||
}
|
||||
used, err = s.Usage.DailyUsage(ctx, userID)
|
||||
if err != nil {
|
||||
return false, 0, info.DailyMessageLimit, info.Name, err
|
||||
}
|
||||
if used >= info.DailyMessageLimit {
|
||||
return false, used, info.DailyMessageLimit, info.Name, nil
|
||||
}
|
||||
n, err := s.Usage.IncrementDailyUsage(ctx, userID)
|
||||
return true, n, info.DailyMessageLimit, info.Name, err
|
||||
}
|
||||
|
||||
// GetDailyUsage replaces db.DailyUsage: a read-only lookup for the
|
||||
// /subscription page, which must not itself count as a sent message.
|
||||
func (s *Service) GetDailyUsage(ctx context.Context, userID int64) (int, error) {
|
||||
return s.Usage.DailyUsage(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) publishChanged(ctx context.Context, userID int64, plan domain.Plan, status, eventType string) {
|
||||
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeySubscriptionChanged, events.SubscriptionChanged{
|
||||
UserID: userID, Plan: string(plan), Status: status, EventType: eventType,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Price resolution cache — replaces Handlers.planPrice / planForPrice.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) planPrice(ctx context.Context, plan domain.Plan) (string, error) {
|
||||
s.priceMu.Lock()
|
||||
defer s.priceMu.Unlock()
|
||||
if id, ok := s.planPrices[plan]; ok && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
id, err := s.Stripe.EnsurePrice(ctx, stripeapi.Info{
|
||||
Plan: string(domain.Get(string(plan)).Plan), Name: domain.Get(string(plan)).Name, PriceEUR: domain.Get(string(plan)).PriceEUR,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if s.planPrices == nil {
|
||||
s.planPrices = map[domain.Plan]string{}
|
||||
}
|
||||
if s.stripePrices == nil {
|
||||
s.stripePrices = map[string]domain.Plan{}
|
||||
}
|
||||
s.planPrices[plan] = id
|
||||
s.stripePrices[id] = plan
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) planForPrice(ctx context.Context, priceID string) (domain.Plan, bool) {
|
||||
s.priceMu.Lock()
|
||||
plan, ok := s.stripePrices[priceID]
|
||||
s.priceMu.Unlock()
|
||||
if ok || !s.Stripe.Configured() {
|
||||
return plan, ok
|
||||
}
|
||||
for _, p := range domain.Catalog {
|
||||
if p.PriceEnvVar == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.planPrice(ctx, p.Plan); err != nil {
|
||||
s.Logger.Warn("resolve stripe price", "plan", p.Name, "err", err)
|
||||
}
|
||||
}
|
||||
s.priceMu.Lock()
|
||||
defer s.priceMu.Unlock()
|
||||
plan, ok = s.stripePrices[priceID]
|
||||
return plan, ok
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Stripe webhook ingestion — replaces Handlers.StripeWebhook / applyStripeEvent.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Service) IngestStripeWebhook(ctx context.Context, payload []byte, sigHeader string) error {
|
||||
ev, err := stripeapi.ConstructEvent(payload, sigHeader, s.WebhookSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe webhook rejected: %w", err)
|
||||
}
|
||||
firstTime, err := s.StripeEvents.RecordIfNew(ctx, ev.ID, ev.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record stripe event: %w", err)
|
||||
}
|
||||
if !firstTime {
|
||||
return nil // already applied on an earlier delivery
|
||||
}
|
||||
if err := s.applyStripeEvent(ctx, ev); err != nil {
|
||||
// Logged, not propagated: the event is already recorded as
|
||||
// seen, so returning an error here would just make the
|
||||
// gateway tell Stripe to retry a delivery that (from Stripe's
|
||||
// side) already succeeded — see the monolith's identical
|
||||
// comment in StripeWebhook.
|
||||
s.Logger.Error("apply stripe event", "err", err, "type", ev.Type, "event_id", ev.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyStripeEvent(ctx context.Context, ev *stripeapi.Event) error {
|
||||
switch ev.Type {
|
||||
case "checkout.session.completed":
|
||||
var sess stripeapi.CheckoutSession
|
||||
if err := json.Unmarshal(ev.Data.Object, &sess); err != nil {
|
||||
return fmt.Errorf("decode checkout session: %w", err)
|
||||
}
|
||||
if sess.Customer == "" {
|
||||
return nil
|
||||
}
|
||||
userID, err := strconv.ParseInt(sess.ClientReferenceID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checkout session %s: unusable client_reference_id %q", sess.ID, sess.ClientReferenceID)
|
||||
}
|
||||
return s.Subscriptions.SetStripeCustomer(ctx, userID, sess.Customer)
|
||||
|
||||
case "customer.subscription.created", "customer.subscription.updated":
|
||||
var st stripeapi.StripeSubscription
|
||||
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
|
||||
return fmt.Errorf("decode subscription: %w", err)
|
||||
}
|
||||
plan, ok := s.planForPrice(ctx, st.PriceID())
|
||||
if !ok {
|
||||
return fmt.Errorf("subscription %s: unrecognized price %q (check the STRIPE_PRICE_* env vars)", st.ID, st.PriceID())
|
||||
}
|
||||
var periodEnd time.Time
|
||||
if unix := st.PeriodEndUnix(); unix > 0 {
|
||||
periodEnd = time.Unix(unix, 0)
|
||||
}
|
||||
err := s.Subscriptions.ApplyStripeSubscription(ctx, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
userID, perr := strconv.ParseInt(st.Metadata["user_id"], 10, 64)
|
||||
if perr != nil {
|
||||
return fmt.Errorf("subscription %s: no matching customer row and no usable metadata.user_id", st.ID)
|
||||
}
|
||||
err = s.Subscriptions.ApplyStripeSubscriptionForUser(ctx, userID, st.Customer, st.ID, st.PriceID(), string(plan), st.Status, periodEnd, st.CancelAtPeriodEnd)
|
||||
if err == nil {
|
||||
s.publishChanged(ctx, userID, plan, st.Status, ev.Type)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
var st stripeapi.StripeSubscription
|
||||
if err := json.Unmarshal(ev.Data.Object, &st); err != nil {
|
||||
return fmt.Errorf("decode subscription: %w", err)
|
||||
}
|
||||
return s.Subscriptions.CancelByStripeSubscriptionID(ctx, st.ID)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
112
services/billing/internal/domain/subscription.go
Normal file
112
services/billing/internal/domain/subscription.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Package domain is the Billing bounded context's entities, plan
|
||||
// catalog and repository interfaces — transplanted from
|
||||
// internal/billing/plan.go (pure data, unchanged) plus the shape of
|
||||
// internal/db's subscriptions.go results.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("billing: not found")
|
||||
|
||||
// Plan identifies one of enkly's subscription tiers. Stored verbatim in
|
||||
// subscriptions.plan — see migrations/0001_init.sql's CHECK constraint.
|
||||
type Plan string
|
||||
|
||||
const (
|
||||
PlanFree Plan = "free"
|
||||
PlanBasic Plan = "basic"
|
||||
PlanPro Plan = "pro"
|
||||
PlanPremium Plan = "premium"
|
||||
)
|
||||
|
||||
// PlanInfo describes one plan: its display name/price and the env var
|
||||
// holding its Stripe Price ID (empty for Free).
|
||||
type PlanInfo struct {
|
||||
Plan Plan
|
||||
Name string
|
||||
PriceEUR int
|
||||
PriceEnvVar string
|
||||
DailyMessageLimit int // 0 = unlimited
|
||||
}
|
||||
|
||||
// Catalog lists every plan, in display order — identical to the
|
||||
// monolith's billing.Catalog.
|
||||
var Catalog = []PlanInfo{
|
||||
{Plan: PlanFree, Name: "Free", PriceEUR: 0, PriceEnvVar: "", DailyMessageLimit: 20},
|
||||
{Plan: PlanBasic, Name: "Basic", PriceEUR: 5, PriceEnvVar: "STRIPE_PRICE_BASIC", DailyMessageLimit: 100},
|
||||
{Plan: PlanPro, Name: "Pro", PriceEUR: 8, PriceEnvVar: "STRIPE_PRICE_PRO", DailyMessageLimit: 400},
|
||||
{Plan: PlanPremium, Name: "Premium", PriceEUR: 12, PriceEnvVar: "STRIPE_PRICE_PREMIUM", DailyMessageLimit: 0},
|
||||
}
|
||||
|
||||
func Get(plan string) PlanInfo {
|
||||
for _, p := range Catalog {
|
||||
if string(p.Plan) == plan {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return Catalog[0]
|
||||
}
|
||||
|
||||
func Valid(plan string) bool {
|
||||
for _, p := range Catalog {
|
||||
if string(p.Plan) == plan {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsPriceID(v string) bool { return strings.HasPrefix(v, "price_") && len(v) > len("price_") }
|
||||
|
||||
// Rank returns plan's position in the catalog, so an upgrade can be
|
||||
// told from a downgrade. Unknown plans rank as Free.
|
||||
func Rank(plan Plan) int {
|
||||
for i, p := range Catalog {
|
||||
if p.Plan == plan {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Subscription is one user's billing state — models.Subscription minus
|
||||
// json tags.
|
||||
type Subscription struct {
|
||||
UserID int64
|
||||
Plan string
|
||||
Status string
|
||||
StripeCustomerID string
|
||||
StripeSubscriptionID string
|
||||
StripePriceID string
|
||||
CurrentPeriodEnd time.Time
|
||||
CancelAtPeriodEnd bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SubscriptionRepository persists subscriptions, mirroring
|
||||
// internal/db/subscriptions.go's functions one-for-one.
|
||||
type SubscriptionRepository interface {
|
||||
GetOrCreate(ctx context.Context, userID int64) (*Subscription, error)
|
||||
SetStripeCustomer(ctx context.Context, userID int64, customerID string) error
|
||||
ApplyStripeSubscription(ctx context.Context, customerID, subscriptionID, priceID, plan, status string, periodEnd time.Time, cancelAtPeriodEnd bool) error
|
||||
ApplyStripeSubscriptionForUser(ctx context.Context, userID int64, customerID, subscriptionID, priceID, plan, status string, periodEnd time.Time, cancelAtPeriodEnd bool) error
|
||||
CancelByStripeSubscriptionID(ctx context.Context, subscriptionID string) error
|
||||
}
|
||||
|
||||
// StripeEventRepository is the webhook idempotency log.
|
||||
type StripeEventRepository interface {
|
||||
// RecordIfNew reports whether eventID hadn't been seen before.
|
||||
RecordIfNew(ctx context.Context, eventID, eventType string) (firstTime bool, err error)
|
||||
}
|
||||
|
||||
// UsageRepository is the per-user, per-UTC-day message counter.
|
||||
type UsageRepository interface {
|
||||
DailyUsage(ctx context.Context, userID int64) (int, error)
|
||||
IncrementDailyUsage(ctx context.Context, userID int64) (int, error)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package postgres implements billing's domain repositories against the
|
||||
// billing database (see services/billing/migrations/0001_init.sql).
|
||||
// One-for-one transplant of internal/db/subscriptions.go.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/pgkit"
|
||||
"enkly/services/billing/internal/domain"
|
||||
)
|
||||
|
||||
const subscriptionColumns = `user_id, plan, status, coalesce(stripe_customer_id, ''), coalesce(stripe_subscription_id, ''), ` +
|
||||
`coalesce(stripe_price_id, ''), current_period_end, cancel_at_period_end, created_at, updated_at`
|
||||
|
||||
func scanSubscription(row interface{ Scan(...any) error }) (*domain.Subscription, error) {
|
||||
var (
|
||||
s domain.Subscription
|
||||
periodEnd sql.NullTime
|
||||
)
|
||||
if err := row.Scan(&s.UserID, &s.Plan, &s.Status, &s.StripeCustomerID, &s.StripeSubscriptionID,
|
||||
&s.StripePriceID, &periodEnd, &s.CancelAtPeriodEnd, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if periodEnd.Valid {
|
||||
s.CurrentPeriodEnd = periodEnd.Time
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
type SubscriptionRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewSubscriptionRepo(db *pgkit.DB) *SubscriptionRepo { return &SubscriptionRepo{DB: db} }
|
||||
|
||||
func (r *SubscriptionRepo) GetOrCreate(ctx context.Context, userID int64) (*domain.Subscription, error) {
|
||||
if _, err := r.DB.ExecContext(ctx,
|
||||
`INSERT INTO subscriptions (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanSubscription(r.DB.QueryRowContext(ctx,
|
||||
`SELECT `+subscriptionColumns+` FROM subscriptions WHERE user_id = $1`, userID))
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) SetStripeCustomer(ctx context.Context, userID int64, customerID string) error {
|
||||
_, err := r.DB.ExecContext(ctx,
|
||||
`UPDATE subscriptions SET stripe_customer_id = $2, updated_at = now() WHERE user_id = $1`,
|
||||
userID, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) ApplyStripeSubscription(ctx context.Context, customerID, subscriptionID, priceID, plan, status string,
|
||||
periodEnd time.Time, cancelAtPeriodEnd bool) error {
|
||||
var pe any
|
||||
if !periodEnd.IsZero() {
|
||||
pe = periodEnd
|
||||
}
|
||||
res, err := r.DB.ExecContext(ctx, `
|
||||
UPDATE subscriptions SET
|
||||
plan = $2, status = $3, stripe_subscription_id = $4, stripe_price_id = $5,
|
||||
current_period_end = $6, cancel_at_period_end = $7, updated_at = now()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
customerID, plan, status, subscriptionID, priceID, pe, cancelAtPeriodEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) ApplyStripeSubscriptionForUser(ctx context.Context, userID int64, customerID, subscriptionID, priceID, plan, status string,
|
||||
periodEnd time.Time, cancelAtPeriodEnd bool) error {
|
||||
var pe any
|
||||
if !periodEnd.IsZero() {
|
||||
pe = periodEnd
|
||||
}
|
||||
_, err := r.DB.ExecContext(ctx, `
|
||||
INSERT INTO subscriptions (user_id, plan, status, stripe_customer_id, stripe_subscription_id, stripe_price_id, current_period_end, cancel_at_period_end)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
plan = excluded.plan, status = excluded.status, stripe_customer_id = excluded.stripe_customer_id,
|
||||
stripe_subscription_id = excluded.stripe_subscription_id, stripe_price_id = excluded.stripe_price_id,
|
||||
current_period_end = excluded.current_period_end, cancel_at_period_end = excluded.cancel_at_period_end,
|
||||
updated_at = now()`,
|
||||
userID, plan, status, customerID, subscriptionID, priceID, pe, cancelAtPeriodEnd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepo) CancelByStripeSubscriptionID(ctx context.Context, subscriptionID string) error {
|
||||
_, err := r.DB.ExecContext(ctx, `
|
||||
UPDATE subscriptions SET
|
||||
plan = 'free', status = 'canceled', stripe_subscription_id = NULL,
|
||||
stripe_price_id = NULL, cancel_at_period_end = false, updated_at = now()
|
||||
WHERE stripe_subscription_id = $1`, subscriptionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Webhook idempotency
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type StripeEventRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewStripeEventRepo(db *pgkit.DB) *StripeEventRepo { return &StripeEventRepo{DB: db} }
|
||||
|
||||
func (r *StripeEventRepo) RecordIfNew(ctx context.Context, eventID, eventType string) (bool, error) {
|
||||
res, err := r.DB.ExecContext(ctx,
|
||||
`INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`, eventID, eventType)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Daily usage
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type UsageRepo struct {
|
||||
DB *pgkit.DB
|
||||
}
|
||||
|
||||
func NewUsageRepo(db *pgkit.DB) *UsageRepo { return &UsageRepo{DB: db} }
|
||||
|
||||
func (r *UsageRepo) DailyUsage(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := r.DB.QueryRowContext(ctx,
|
||||
`SELECT messages FROM usage_daily WHERE user_id = $1 AND day = current_date`, userID).Scan(&n)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *UsageRepo) IncrementDailyUsage(ctx context.Context, userID int64) (int, error) {
|
||||
var n int
|
||||
err := r.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO usage_daily (user_id, day, messages) VALUES ($1, current_date, 1)
|
||||
ON CONFLICT (user_id, day) DO UPDATE SET messages = usage_daily.messages + 1
|
||||
RETURNING messages`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
468
services/billing/internal/infrastructure/stripeapi/stripe.go
Normal file
468
services/billing/internal/infrastructure/stripeapi/stripe.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package stripeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiBase is Stripe's REST API root. Every call in this file talks
|
||||
// directly to it over net/http — enkly deliberately has no dependency
|
||||
// on Stripe's official Go SDK (see the comment on config/env.go about
|
||||
// keeping go.mod small), and the handful of endpoints subscriptions
|
||||
// need (Checkout Sessions, Billing Portal Sessions, webhook signature
|
||||
// verification) are simple enough not to need it.
|
||||
const apiBase = "https://api.stripe.com/v1"
|
||||
|
||||
// Info is the subset of domain.PlanInfo EnsurePrice needs to find-or-
|
||||
// create a plan's monthly price in Stripe. Kept as its own small type
|
||||
// here (rather than importing services/billing/internal/domain) so
|
||||
// this package stays a standalone Stripe API client with zero
|
||||
// dependency on billing's own domain model — the same separation the
|
||||
// monolith had between internal/billing (which owned both) and nothing
|
||||
// else, now made explicit by the package boundary.
|
||||
type Info struct {
|
||||
Plan string
|
||||
Name string
|
||||
PriceEUR int
|
||||
}
|
||||
|
||||
// Client is a small Stripe API client, authenticated with a secret key
|
||||
// (STRIPE_SECRET_KEY). Safe for concurrent use.
|
||||
type Client struct {
|
||||
SecretKey string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// New builds a Client. secretKey is Stripe's secret key (sk_test_… or
|
||||
// sk_live_…) — never the publishable key, and never exposed to the
|
||||
// browser.
|
||||
func New(secretKey string) *Client {
|
||||
return &Client{
|
||||
SecretKey: secretKey,
|
||||
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether a secret key was supplied, so callers can
|
||||
// give a clear error instead of a confusing Stripe 401 when it wasn't.
|
||||
func (c *Client) Configured() bool { return c != nil && c.SecretKey != "" }
|
||||
|
||||
// post sends a application/x-www-form-urlencoded POST to a Stripe
|
||||
// endpoint (Stripe's API accepts form-encoded bodies for every
|
||||
// resource, including nested params via PHP-style bracket keys — see
|
||||
// the callers below) and decodes the JSON response into out.
|
||||
func (c *Client) post(ctx context.Context, path string, form url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+path, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
// APIError is what do() returns for any non-2xx response from Stripe —
|
||||
// kept as its own type (rather than just fmt.Errorf-ing the message in)
|
||||
// specifically so callers showing this to a self-hosting admin (see
|
||||
// internal/handlers/billing.go's subscriptionError) can display
|
||||
// Message on its own: it's Stripe's own human-readable explanation
|
||||
// (e.g. "No such price: 'prod_ABC123'; a similar object exists in live
|
||||
// mode, but a test mode key was used to make this request.") and is
|
||||
// almost always more useful for fixing a misconfigured Price ID, a
|
||||
// test/live key mismatch, etc. than a generic "something went wrong".
|
||||
type APIError struct {
|
||||
Path string // the Stripe API path that was called, e.g. "/checkout/sessions"
|
||||
Status int
|
||||
Type string // Stripe's error "type", e.g. "invalid_request_error"
|
||||
Message string // Stripe's own explanation — safe and useful to show as-is
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("stripe %s: %s", e.Path, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("stripe %s: status %d", e.Path, e.Status)
|
||||
}
|
||||
|
||||
// Explain extracts a clean, safe-to-display message from an error one
|
||||
// of this package's Client methods returned: Stripe's own explanation
|
||||
// for an *APIError (a bad Price ID, a test/live key mismatch, an
|
||||
// invalid API key, ...), or the error's own message otherwise (a
|
||||
// network/DNS failure reaching Stripe, a malformed local response).
|
||||
// Never includes the secret key — it's only ever sent in a request
|
||||
// header, never echoed back into an error — so this is safe to show
|
||||
// directly to whoever administers this server.
|
||||
func Explain(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) && apiErr.Message != "" {
|
||||
return apiErr.Message
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
if c.SecretKey == "" {
|
||||
return errors.New("billing: STRIPE_SECRET_KEY is not configured")
|
||||
}
|
||||
req.SetBasicAuth(c.SecretKey, "") // Stripe auth: secret key as the HTTP Basic username, empty password
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
// Pin an API version so Stripe's response shape (e.g. where
|
||||
// current_period_end lives on a subscription) can't drift under us
|
||||
// between requests. Any recent version works; this one is well
|
||||
// after Price/nested line_items became standard.
|
||||
req.Header.Set("Stripe-Version", "2024-06-20")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("billing: request %s: %w", req.URL.Path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("billing: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
var parsed struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
return &APIError{Path: req.URL.Path, Status: resp.StatusCode, Type: parsed.Error.Type, Message: parsed.Error.Message}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("billing: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Customers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// CreateCustomer creates a Stripe Customer for a newly-paying user.
|
||||
// userID is stashed in metadata so a webhook can map a Stripe object
|
||||
// back to an enkly account even if the subscriptions row lookup by
|
||||
// stripe_customer_id ever misses.
|
||||
func (c *Client) CreateCustomer(ctx context.Context, email string, userID int64) (string, error) {
|
||||
form := url.Values{
|
||||
"email": {email},
|
||||
"metadata[user_id]": {strconv.FormatInt(userID, 10)},
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.post(ctx, "/customers", form, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.ID, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Prices
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// EnsurePrice returns the Stripe Price ID for a paid plan, creating the
|
||||
// product and recurring monthly EUR price on first use. Prices are found
|
||||
// again on later runs through a Stripe "lookup key" (enkly_<plan>_monthly),
|
||||
// so calling this on every startup never creates duplicates. This is what
|
||||
// lets the app work when STRIPE_PRICE_* is unset or wrongly set to an
|
||||
// amount like "5" instead of a price_… ID.
|
||||
func (c *Client) EnsurePrice(ctx context.Context, info Info) (string, error) {
|
||||
lookupKey := "enkly_" + string(info.Plan) + "_monthly"
|
||||
|
||||
var found struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
UnitAmount int `json:"unit_amount"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"data"`
|
||||
}
|
||||
q := url.Values{"lookup_keys[]": {lookupKey}, "active": {"true"}}
|
||||
if err := c.get(ctx, "/prices?"+q.Encode(), &found); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, pr := range found.Data {
|
||||
if pr.UnitAmount == info.PriceEUR*100 && pr.Currency == "eur" {
|
||||
return pr.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var product struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := c.post(ctx, "/products", url.Values{"name": {"enkly " + info.Name}}, &product); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var price struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
err := c.post(ctx, "/prices", url.Values{
|
||||
"product": {product.ID},
|
||||
"currency": {"eur"},
|
||||
"unit_amount": {strconv.Itoa(info.PriceEUR * 100)},
|
||||
"recurring[interval]": {"month"},
|
||||
"nickname": {"enkly " + info.Name + " (monthly)"},
|
||||
"lookup_key": {lookupKey},
|
||||
"transfer_lookup_key": {"true"},
|
||||
}, &price)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return price.ID, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Checkout Sessions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// CheckoutParams describes one subscription checkout.
|
||||
type CheckoutParams struct {
|
||||
PriceID string
|
||||
CustomerID string // existing Stripe customer, if any (preferred over CustomerEmail)
|
||||
CustomerEmail string // used only when CustomerID is empty
|
||||
ClientReferenceID string // enkly user ID, as a string — echoed back on the session object
|
||||
UserID int64
|
||||
SuccessURL string
|
||||
CancelURL string
|
||||
}
|
||||
|
||||
// CreateCheckoutSession starts a Stripe-hosted Checkout page for a new
|
||||
// subscription. The caller redirects the browser to the returned URL;
|
||||
// enkly never collects card details itself.
|
||||
func (c *Client) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (sessionID, checkoutURL string, err error) {
|
||||
form := url.Values{
|
||||
"mode": {"subscription"},
|
||||
"success_url": {p.SuccessURL},
|
||||
"cancel_url": {p.CancelURL},
|
||||
"line_items[0][price]": {p.PriceID},
|
||||
"line_items[0][quantity]": {"1"},
|
||||
"client_reference_id": {p.ClientReferenceID},
|
||||
"subscription_data[metadata][user_id]": {strconv.FormatInt(p.UserID, 10)},
|
||||
"allow_promotion_codes": {"true"},
|
||||
"billing_address_collection": {"auto"},
|
||||
}
|
||||
if p.CustomerID != "" {
|
||||
form.Set("customer", p.CustomerID)
|
||||
} else if p.CustomerEmail != "" {
|
||||
form.Set("customer_email", p.CustomerEmail)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := c.post(ctx, "/checkout/sessions", form, &out); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return out.ID, out.URL, nil
|
||||
}
|
||||
|
||||
// CheckoutSession is the subset of a Checkout Session object handlers
|
||||
// need after a "checkout.session.completed" webhook.
|
||||
type CheckoutSession struct {
|
||||
ID string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
Subscription string `json:"subscription"`
|
||||
ClientReferenceID string `json:"client_reference_id"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// GetCheckoutSession retrieves a Checkout Session by ID. The success page
|
||||
// uses it to confirm a payment with Stripe directly, so a plan activates
|
||||
// even when webhooks aren't reaching the server (e.g. local testing).
|
||||
func (c *Client) GetCheckoutSession(ctx context.Context, id string) (*CheckoutSession, error) {
|
||||
var out CheckoutSession
|
||||
if err := c.get(ctx, "/checkout/sessions/"+url.PathEscape(id), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Billing Portal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// CreateBillingPortalSession opens Stripe's hosted "manage my
|
||||
// subscription" page (update card, change plan, cancel, view invoices)
|
||||
// for an existing customer.
|
||||
func (c *Client) CreateBillingPortalSession(ctx context.Context, customerID, returnURL string) (string, error) {
|
||||
form := url.Values{"customer": {customerID}, "return_url": {returnURL}}
|
||||
var out struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := c.post(ctx, "/billing_portal/sessions", form, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Subscriptions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// StripeSubscription is the subset of a Subscription object handlers
|
||||
// need to keep internal/db's subscriptions table in sync.
|
||||
type StripeSubscription struct {
|
||||
ID string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
Status string `json:"status"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"` // unix seconds; 0 on API versions that moved this onto items
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Items struct {
|
||||
Data []struct {
|
||||
Price struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"price"`
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"`
|
||||
} `json:"data"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// PriceID returns the subscription's single price ID (enkly's checkout
|
||||
// always creates a one-line-item subscription), or "" if absent.
|
||||
func (s StripeSubscription) PriceID() string {
|
||||
if len(s.Items.Data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.Items.Data[0].Price.ID
|
||||
}
|
||||
|
||||
// PeriodEndUnix returns current_period_end, preferring the
|
||||
// subscription-level field and falling back to the first item's (newer
|
||||
// Stripe API versions report it per-item instead).
|
||||
func (s StripeSubscription) PeriodEndUnix() int64 {
|
||||
if s.CurrentPeriodEnd != 0 {
|
||||
return s.CurrentPeriodEnd
|
||||
}
|
||||
if len(s.Items.Data) > 0 {
|
||||
return s.Items.Data[0].CurrentPeriodEnd
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetSubscription retrieves a subscription by ID — used as a fallback
|
||||
// when a webhook event's embedded object is missing a field this code
|
||||
// needs (e.g. an older API version, or a checkout.session.completed
|
||||
// event that only references the subscription by ID).
|
||||
func (c *Client) GetSubscription(ctx context.Context, id string) (*StripeSubscription, error) {
|
||||
var out StripeSubscription
|
||||
if err := c.get(ctx, "/subscriptions/"+url.PathEscape(id), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Webhooks
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Event is the minimal shape of a Stripe webhook event enkly needs:
|
||||
// enough to dispatch on Type and unmarshal Data.Object into the
|
||||
// concrete struct (CheckoutSession / StripeSubscription) each handler
|
||||
// expects.
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
Object json.RawMessage `json:"object"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// maxWebhookSkew rejects a webhook whose signed timestamp is further
|
||||
// than this from "now" — Stripe's own recommended replay-attack guard.
|
||||
const maxWebhookSkew = 5 * time.Minute
|
||||
|
||||
// ConstructEvent verifies a webhook payload's "Stripe-Signature" header
|
||||
// against webhookSecret and, only if it's genuine, parses it into an
|
||||
// Event. This reimplements Stripe's documented signature scheme
|
||||
// (HMAC-SHA256 over "{timestamp}.{payload}", header format
|
||||
// "t=…,v1=…,v1=…") by hand rather than pulling in the SDK just for
|
||||
// this one function.
|
||||
func ConstructEvent(payload []byte, sigHeader, webhookSecret string) (*Event, error) {
|
||||
if webhookSecret == "" {
|
||||
return nil, errors.New("billing: STRIPE_WEBHOOK_SECRET is not configured")
|
||||
}
|
||||
ts, sigs, err := parseSignatureHeader(sigHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skew := time.Since(time.Unix(ts, 0)); skew > maxWebhookSkew || skew < -maxWebhookSkew {
|
||||
return nil, fmt.Errorf("billing: webhook timestamp %s outside tolerance", time.Unix(ts, 0))
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(webhookSecret))
|
||||
mac.Write([]byte(strconv.FormatInt(ts, 10)))
|
||||
mac.Write([]byte("."))
|
||||
mac.Write(payload)
|
||||
want := mac.Sum(nil)
|
||||
|
||||
ok := false
|
||||
for _, sig := range sigs {
|
||||
got, err := hex.DecodeString(sig)
|
||||
if err == nil && hmac.Equal(got, want) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("billing: webhook signature verification failed")
|
||||
}
|
||||
|
||||
var ev Event
|
||||
if err := json.Unmarshal(payload, &ev); err != nil {
|
||||
return nil, fmt.Errorf("billing: decode webhook payload: %w", err)
|
||||
}
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
// parseSignatureHeader splits "t=1614556800,v1=abc…,v1=def…" (Stripe
|
||||
// includes a second v1 briefly while rotating a webhook signing
|
||||
// secret) into the timestamp and the list of v1 signatures to try.
|
||||
func parseSignatureHeader(header string) (ts int64, sigs []string, err error) {
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "t":
|
||||
ts, err = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("billing: bad webhook timestamp: %w", err)
|
||||
}
|
||||
case "v1":
|
||||
sigs = append(sigs, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
if ts == 0 || len(sigs) == 0 {
|
||||
return 0, nil, errors.New("billing: malformed Stripe-Signature header")
|
||||
}
|
||||
return ts, sigs, nil
|
||||
}
|
||||
141
services/billing/internal/interfaces/grpc/server.go
Normal file
141
services/billing/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/services/billing/internal/application"
|
||||
"enkly/services/billing/internal/domain"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
billingv1.UnimplementedBillingServiceServer
|
||||
Svc *application.Service
|
||||
PublishableKey string // STRIPE_PUBLISH_KEY, for GetPlanCatalogResponse
|
||||
}
|
||||
|
||||
func New(svc *application.Service, publishableKey string) *Server {
|
||||
return &Server{Svc: svc, PublishableKey: publishableKey}
|
||||
}
|
||||
|
||||
func toProtoPlan(p domain.Plan) billingv1.Plan {
|
||||
switch p {
|
||||
case domain.PlanFree:
|
||||
return billingv1.Plan_PLAN_FREE
|
||||
case domain.PlanBasic:
|
||||
return billingv1.Plan_PLAN_BASIC
|
||||
case domain.PlanPro:
|
||||
return billingv1.Plan_PLAN_PRO
|
||||
case domain.PlanPremium:
|
||||
return billingv1.Plan_PLAN_PREMIUM
|
||||
default:
|
||||
return billingv1.Plan_PLAN_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoPlan(p billingv1.Plan) domain.Plan {
|
||||
switch p {
|
||||
case billingv1.Plan_PLAN_FREE:
|
||||
return domain.PlanFree
|
||||
case billingv1.Plan_PLAN_BASIC:
|
||||
return domain.PlanBasic
|
||||
case billingv1.Plan_PLAN_PRO:
|
||||
return domain.PlanPro
|
||||
case billingv1.Plan_PLAN_PREMIUM:
|
||||
return domain.PlanPremium
|
||||
default:
|
||||
return domain.PlanFree
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoSubscription(s *domain.Subscription) *billingv1.Subscription {
|
||||
out := &billingv1.Subscription{
|
||||
UserId: s.UserID,
|
||||
Plan: toProtoPlan(domain.Plan(s.Plan)),
|
||||
Status: s.Status,
|
||||
CancelAtPeriodEnd: s.CancelAtPeriodEnd,
|
||||
HasBillingAccount: s.StripeCustomerID != "",
|
||||
}
|
||||
if !s.CurrentPeriodEnd.IsZero() {
|
||||
out.CurrentPeriodEnd = timestamppb.New(s.CurrentPeriodEnd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) GetOrCreateSubscription(ctx context.Context, req *billingv1.GetOrCreateSubscriptionRequest) (*billingv1.Subscription, error) {
|
||||
sub, err := s.Svc.GetOrCreateSubscription(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return toProtoSubscription(sub), nil
|
||||
}
|
||||
|
||||
func (s *Server) GetPlanCatalog(ctx context.Context, req *billingv1.GetPlanCatalogRequest) (*billingv1.GetPlanCatalogResponse, error) {
|
||||
entries := s.Svc.GetPlanCatalog(ctx)
|
||||
resp := &billingv1.GetPlanCatalogResponse{PublishableKey: s.PublishableKey}
|
||||
for _, e := range entries {
|
||||
resp.Plans = append(resp.Plans, &billingv1.PlanInfo{
|
||||
Plan: toProtoPlan(e.Plan),
|
||||
Name: e.Name,
|
||||
PriceEur: int32(e.PriceEUR),
|
||||
DailyMessageLimit: int32(e.DailyMessageLimit),
|
||||
Available: e.Available,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateCheckoutSession(ctx context.Context, req *billingv1.CreateCheckoutSessionRequest) (*billingv1.CreateCheckoutSessionResponse, error) {
|
||||
url, err := s.Svc.CreateCheckoutSession(ctx, req.GetUserId(), req.GetEmail(), fromProtoPlan(req.GetPlan()), req.GetSuccessUrl(), req.GetCancelUrl())
|
||||
if err != nil {
|
||||
return nil, grpcerr.FailedPrecondition(err.Error())
|
||||
}
|
||||
return &billingv1.CreateCheckoutSessionResponse{CheckoutUrl: url}, nil
|
||||
}
|
||||
|
||||
func (s *Server) SyncCheckoutSession(ctx context.Context, req *billingv1.SyncCheckoutSessionRequest) (*billingv1.Subscription, error) {
|
||||
sub, err := s.Svc.SyncCheckoutSession(ctx, req.GetUserId(), req.GetStripeSessionId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.FailedPrecondition(err.Error())
|
||||
}
|
||||
return toProtoSubscription(sub), nil
|
||||
}
|
||||
|
||||
func (s *Server) CreateBillingPortalSession(ctx context.Context, req *billingv1.CreateBillingPortalSessionRequest) (*billingv1.CreateBillingPortalSessionResponse, error) {
|
||||
url, err := s.Svc.CreateBillingPortalSession(ctx, req.GetUserId(), req.GetReturnUrl())
|
||||
if err != nil {
|
||||
if application.IsNoBillingAccount(err) {
|
||||
return nil, grpcerr.FailedPrecondition("no billing account yet — subscribe to a paid plan first")
|
||||
}
|
||||
return nil, grpcerr.FailedPrecondition(err.Error())
|
||||
}
|
||||
return &billingv1.CreateBillingPortalSessionResponse{PortalUrl: url}, nil
|
||||
}
|
||||
|
||||
func (s *Server) IngestStripeWebhook(ctx context.Context, req *billingv1.IngestStripeWebhookRequest) (*billingv1.IngestStripeWebhookResponse, error) {
|
||||
if err := s.Svc.IngestStripeWebhook(ctx, req.GetPayload(), req.GetSignatureHeader()); err != nil {
|
||||
return nil, grpcerr.InvalidArgument(err.Error())
|
||||
}
|
||||
return &billingv1.IngestStripeWebhookResponse{Accepted: true}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CheckAndIncrementUsage(ctx context.Context, req *billingv1.CheckAndIncrementUsageRequest) (*billingv1.CheckAndIncrementUsageResponse, error) {
|
||||
allowed, used, limit, planName, err := s.Svc.CheckAndIncrementUsage(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &billingv1.CheckAndIncrementUsageResponse{
|
||||
Allowed: allowed, Used: int32(used), Limit: int32(limit), PlanName: planName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) GetDailyUsage(ctx context.Context, req *billingv1.GetDailyUsageRequest) (*billingv1.GetDailyUsageResponse, error) {
|
||||
used, err := s.Svc.GetDailyUsage(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, grpcerr.Internal(err)
|
||||
}
|
||||
return &billingv1.GetDailyUsageResponse{Used: int32(used)}, nil
|
||||
}
|
||||
47
services/billing/migrations/0001_init.sql
Normal file
47
services/billing/migrations/0001_init.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- =====================================================================
|
||||
-- billing service — 0001_init.sql
|
||||
-- Database-per-service: owns `subscriptions`, `stripe_events` and
|
||||
-- `usage_daily`. user_id columns below are bare BIGINTs, NOT a foreign
|
||||
-- key to iam's users(id) — that table lives in a different database.
|
||||
-- Referential integrity across that boundary is enforced by the
|
||||
-- application layer (iam is the only service that can create a
|
||||
-- user_id in the first place) rather than by Postgres; see
|
||||
-- ARCHITECTURE.md's "Database-per-service" section.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL UNIQUE,
|
||||
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'basic', 'pro', 'premium')),
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
stripe_price_id TEXT,
|
||||
current_period_end TIMESTAMPTZ,
|
||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_stripe_customer_id_idx
|
||||
ON subscriptions (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS subscriptions_stripe_subscription_id_idx
|
||||
ON subscriptions (stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL;
|
||||
|
||||
-- Idempotency log for incoming webhooks — see application.Service.
|
||||
-- IngestStripeWebhook.
|
||||
CREATE TABLE IF NOT EXISTS stripe_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Per-user, per-UTC-day message counter backing each plan's
|
||||
-- DailyMessageLimit (see domain.PlanInfo).
|
||||
CREATE TABLE IF NOT EXISTS usage_daily (
|
||||
user_id BIGINT NOT NULL,
|
||||
day DATE NOT NULL,
|
||||
messages INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, day)
|
||||
);
|
||||
Reference in New Issue
Block a user