Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

View 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"]

View 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()
}

View 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
}
}

View 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)
}

View File

@@ -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
}

View 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
}

View 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
}

View 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)
);