implement core foundation

This commit is contained in:
talksik
2026-04-14 11:00:46 -07:00
parent aff18d82db
commit 279a3e52c2
25 changed files with 1455 additions and 62 deletions
+37
View File
@@ -0,0 +1,37 @@
package billing
import (
"fmt"
"strings"
)
type Config struct {
SecretKey string
WebhookSecret string
PriceMonthlyID string
PriceAnnualID string
SuccessURL string
CancelURL string
}
func (c Config) Validate() error {
required := map[string]string{
"SecretKey": c.SecretKey,
"WebhookSecret": c.WebhookSecret,
"PriceMonthlyID": c.PriceMonthlyID,
"PriceAnnualID": c.PriceAnnualID,
"SuccessURL": c.SuccessURL,
"CancelURL": c.CancelURL,
}
var missing []string
for name, value := range required {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("billing config missing: %s", strings.Join(missing, ", "))
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package billing
import "time"
type Cadence string
const (
CadenceMonthly Cadence = "monthly"
CadenceAnnual Cadence = "annual"
)
func (c Cadence) IsValid() bool {
return c == CadenceMonthly || c == CadenceAnnual
}
type Plan string
const (
PlanFree Plan = "free"
PlanPro Plan = "pro"
)
// Subscription is the persisted projection of a Stripe subscription,
// reconciled on every webhook event.
type Subscription struct {
ID string
NetworkID string
StripeCustomerID string
Status string
PriceID string
Cadence Cadence
Quantity int
CancelAtPeriodEnd bool
CurrentPeriodStart time.Time
CurrentPeriodEnd time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type Status struct {
Plan Plan `json:"plan"`
PlanStatus string `json:"plan_status"`
Cadence *Cadence `json:"cadence"`
Seats int `json:"seats"` // 0 on free plan; sub.Quantity on pro
CurrentPeriodEnd *time.Time `json:"current_period_end"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
PriceMonthlyCents int64 `json:"price_monthly_cents"`
PriceAnnualCents int64 `json:"price_annual_cents"`
}
type CheckoutParams struct {
NetworkID string
AdminHumanID string
AdminEmail string
Cadence Cadence
Seats int
}
+26
View File
@@ -0,0 +1,26 @@
package billing
import (
"context"
"errors"
)
// Noop returns a billing service for binaries that depend on network.Service
// but never mutate membership (jobs, pusher). Stripe isn't configured.
func Noop() Service { return noopService{} }
type noopService struct{}
var errNoopBilling = errors.New("billing: not configured in this process")
func (noopService) GetStatus(context.Context, string) (*Status, error) {
return nil, errNoopBilling
}
func (noopService) CreateCheckoutSession(context.Context, CheckoutParams) (string, error) {
return "", errNoopBilling
}
func (noopService) CreatePortalSession(context.Context, string) (string, error) {
return "", errNoopBilling
}
func (noopService) SyncSeats(context.Context, string, int) error { return nil }
func (noopService) HandleWebhook(context.Context, []byte, string) error { return errNoopBilling }
+112
View File
@@ -0,0 +1,112 @@
package billing
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var errNotFound = errors.New("not found")
type repository interface {
getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error)
upsertSubscription(ctx context.Context, sub *Subscription) error
deleteSubscriptionByID(ctx context.Context, subscriptionID string) error
getStripeCustomerID(ctx context.Context, networkID string) (string, error)
setStripeCustomerID(ctx context.Context, networkID, customerID string) error
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
const subscriptionColumns = `id, network_id, stripe_customer_id, status, price_id, cadence, quantity, cancel_at_period_end, current_period_start, current_period_end, created_at, updated_at`
func scanSubscription(row pgx.Row, s *Subscription) error {
return row.Scan(
&s.ID, &s.NetworkID, &s.StripeCustomerID, &s.Status, &s.PriceID,
&s.Cadence, &s.Quantity, &s.CancelAtPeriodEnd,
&s.CurrentPeriodStart, &s.CurrentPeriodEnd,
&s.CreatedAt, &s.UpdatedAt,
)
}
func (r *repositoryImpl) getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error) {
var s Subscription
err := scanSubscription(
r.pool.QueryRow(ctx,
`SELECT `+subscriptionColumns+` FROM network_subscriptions WHERE network_id = $1`,
networkID,
),
&s,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
}
if err != nil {
return nil, err
}
return &s, nil
}
func (r *repositoryImpl) upsertSubscription(ctx context.Context, sub *Subscription) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_subscriptions (
id, network_id, stripe_customer_id, status, price_id, cadence,
quantity, cancel_at_period_end, current_period_start, current_period_end
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET
stripe_customer_id = EXCLUDED.stripe_customer_id,
status = EXCLUDED.status,
price_id = EXCLUDED.price_id,
cadence = EXCLUDED.cadence,
quantity = EXCLUDED.quantity,
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
current_period_start = EXCLUDED.current_period_start,
current_period_end = EXCLUDED.current_period_end,
updated_at = NOW()
`,
sub.ID, sub.NetworkID, sub.StripeCustomerID, sub.Status, sub.PriceID, sub.Cadence,
sub.Quantity, sub.CancelAtPeriodEnd, sub.CurrentPeriodStart, sub.CurrentPeriodEnd,
)
return err
}
func (r *repositoryImpl) deleteSubscriptionByID(ctx context.Context, subscriptionID string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM network_subscriptions WHERE id = $1`,
subscriptionID,
)
return err
}
func (r *repositoryImpl) getStripeCustomerID(ctx context.Context, networkID string) (string, error) {
var customerID string
err := r.pool.QueryRow(ctx,
`SELECT stripe_customer_id FROM network_stripe_customers WHERE network_id = $1`,
networkID,
).Scan(&customerID)
if errors.Is(err, pgx.ErrNoRows) {
return "", errNotFound
}
if err != nil {
return "", err
}
return customerID, nil
}
func (r *repositoryImpl) setStripeCustomerID(ctx context.Context, networkID, customerID string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO network_stripe_customers (network_id, stripe_customer_id)
VALUES ($1, $2)
ON CONFLICT (network_id) DO NOTHING
`, networkID, customerID)
return err
}
+237
View File
@@ -0,0 +1,237 @@
package billing
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stripe/stripe-go/v81"
billingportalsession "github.com/stripe/stripe-go/v81/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v81/checkout/session"
stripecustomer "github.com/stripe/stripe-go/v81/customer"
stripeprice "github.com/stripe/stripe-go/v81/price"
stripesub "github.com/stripe/stripe-go/v81/subscription"
)
type Service interface {
GetStatus(ctx context.Context, networkID string) (*Status, error)
CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error)
CreatePortalSession(ctx context.Context, networkID string) (url string, err error)
// SyncSeats updates the Stripe subscription quantity with proration.
// No-op if the network has no active subscription.
SyncSeats(ctx context.Context, networkID string, seats int) error
HandleWebhook(ctx context.Context, payload []byte, signature string) error
}
var (
ErrNoActiveSubscription = errors.New("network has no stripe customer yet")
ErrInvalidCadence = errors.New("invalid billing cadence")
)
type serviceImpl struct {
cfg Config
repo repository
priceMonthlyCents int64
priceAnnualCents int64
}
func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
stripe.Key = cfg.SecretKey
monthly, err := stripeprice.Get(cfg.PriceMonthlyID, &stripe.PriceParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return nil, fmt.Errorf("fetch stripe monthly price: %w", err)
}
annual, err := stripeprice.Get(cfg.PriceAnnualID, &stripe.PriceParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return nil, fmt.Errorf("fetch stripe annual price: %w", err)
}
return &serviceImpl{
cfg: cfg,
repo: newRepository(pool),
priceMonthlyCents: monthly.UnitAmount,
priceAnnualCents: annual.UnitAmount,
}, nil
}
func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if err != nil && !errors.Is(err, errNotFound) {
return nil, err
}
status := &Status{
Plan: PlanFree,
PlanStatus: "active",
PriceMonthlyCents: s.priceMonthlyCents,
PriceAnnualCents: s.priceAnnualCents,
}
if sub != nil {
cadence := sub.Cadence
periodEnd := sub.CurrentPeriodEnd
// past_due keeps access: Stripe still considers the subscription live
// during the dunning window.
switch stripe.SubscriptionStatus(sub.Status) {
case stripe.SubscriptionStatusActive,
stripe.SubscriptionStatusTrialing,
stripe.SubscriptionStatusPastDue:
status.Plan = PlanPro
}
status.PlanStatus = sub.Status
status.Cadence = &cadence
status.Seats = sub.Quantity
status.CurrentPeriodEnd = &periodEnd
status.CancelAtPeriodEnd = sub.CancelAtPeriodEnd
}
return status, nil
}
func (s *serviceImpl) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (string, error) {
if !p.Cadence.IsValid() {
return "", ErrInvalidCadence
}
if p.Seats < 1 {
return "", fmt.Errorf("seats must be >= 1")
}
customerID, err := s.ensureStripeCustomer(ctx, p)
if err != nil {
return "", fmt.Errorf("ensure stripe customer: %w", err)
}
priceID := s.cfg.PriceAnnualID
switch p.Cadence {
case CadenceAnnual:
priceID = s.cfg.PriceAnnualID
break
case CadenceMonthly:
priceID = s.cfg.PriceMonthlyID
break
}
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(customerID),
ClientReferenceID: stripe.String(p.NetworkID),
SuccessURL: stripe.String(s.cfg.SuccessURL),
CancelURL: stripe.String(s.cfg.CancelURL),
LineItems: []*stripe.CheckoutSessionLineItemParams{{
Price: stripe.String(priceID),
Quantity: stripe.Int64(int64(p.Seats)),
}},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
Metadata: map[string]string{
"network_id": p.NetworkID,
"admin_human_id": p.AdminHumanID,
"cadence": string(p.Cadence),
},
},
}
params.Context = ctx
sess, err := checkoutsession.New(params)
if err != nil {
return "", fmt.Errorf("stripe checkout: %w", err)
}
return sess.URL, nil
}
func (s *serviceImpl) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if errors.Is(err, errNotFound) {
return "", ErrNoActiveSubscription
}
if err != nil {
return "", err
}
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(sub.StripeCustomerID),
ReturnURL: stripe.String(s.cfg.SuccessURL),
}
params.Context = ctx
sess, err := billingportalsession.New(params)
if err != nil {
return "", fmt.Errorf("stripe portal: %w", err)
}
return sess.URL, nil
}
func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int) error {
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
if errors.Is(err, errNotFound) {
return nil
}
if err != nil {
return err
}
if sub.Quantity == seats {
return nil
}
liveSub, err := stripesub.Get(sub.ID, &stripe.SubscriptionParams{
Params: stripe.Params{Context: ctx},
})
if err != nil {
return fmt.Errorf("fetch stripe subscription: %w", err)
}
if len(liveSub.Items.Data) == 0 {
return fmt.Errorf("stripe subscription %s has no items", sub.ID)
}
params := &stripe.SubscriptionParams{
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
Items: []*stripe.SubscriptionItemsParams{{
ID: stripe.String(liveSub.Items.Data[0].ID),
Quantity: stripe.Int64(int64(seats)),
}},
}
params.Context = ctx
if _, err := stripesub.Update(sub.ID, params); err != nil {
return fmt.Errorf("update stripe subscription quantity: %w", err)
}
// customer.subscription.updated webhook arrives within seconds and
// reconciles quantity in our DB.
return nil
}
func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) {
existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID)
if err == nil {
return existing, nil
}
if !errors.Is(err, errNotFound) {
return "", err
}
params := &stripe.CustomerParams{
Email: stripe.String(p.AdminEmail),
Metadata: map[string]string{
"network_id": p.NetworkID,
"admin_human_id": p.AdminHumanID,
},
}
params.Context = ctx
cust, err := stripecustomer.New(params)
if err != nil {
return "", fmt.Errorf("create stripe customer: %w", err)
}
if err := s.repo.setStripeCustomerID(ctx, p.NetworkID, cust.ID); err != nil {
return "", fmt.Errorf("persist stripe customer id: %w", err)
}
return cust.ID, nil
}
+115
View File
@@ -0,0 +1,115 @@
package billing
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/webhook"
)
func (s *serviceImpl) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret)
if err != nil {
return fmt.Errorf("verify stripe signature: %w", err)
}
slog.Info("stripe webhook", "type", event.Type, "id", event.ID)
switch event.Type {
case "checkout.session.completed":
// subscription.created fires right after with full detail; we handle
// the subscription there.
return nil
case "customer.subscription.created", "customer.subscription.updated":
return s.handleSubscriptionUpsert(ctx, event)
case "customer.subscription.deleted":
return s.handleSubscriptionDeleted(ctx, event)
default:
return nil
}
}
func (s *serviceImpl) handleSubscriptionUpsert(ctx context.Context, event stripe.Event) error {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
networkID := sub.Metadata["network_id"]
if networkID == "" {
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
}
local, err := subscriptionFromStripe(&sub, networkID)
if err != nil {
return err
}
if err := s.repo.upsertSubscription(ctx, local); err != nil {
return fmt.Errorf("upsert subscription: %w", err)
}
return nil
}
func (s *serviceImpl) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return fmt.Errorf("decode subscription: %w", err)
}
networkID := sub.Metadata["network_id"]
if networkID == "" {
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
}
if err := s.repo.deleteSubscriptionByID(ctx, sub.ID); err != nil {
return fmt.Errorf("delete subscription: %w", err)
}
return nil
}
func subscriptionFromStripe(sub *stripe.Subscription, networkID string) (*Subscription, error) {
if len(sub.Items.Data) == 0 {
return nil, fmt.Errorf("subscription %s has no items", sub.ID)
}
item := sub.Items.Data[0]
cadence, err := cadenceFromInterval(item.Price)
if err != nil {
return nil, fmt.Errorf("subscription %s: %w", sub.ID, err)
}
var customerID string
if sub.Customer != nil {
customerID = sub.Customer.ID
}
return &Subscription{
ID: sub.ID,
NetworkID: networkID,
StripeCustomerID: customerID,
Status: string(sub.Status),
PriceID: item.Price.ID,
Cadence: cadence,
Quantity: int(item.Quantity),
CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
CurrentPeriodStart: time.Unix(sub.CurrentPeriodStart, 0).UTC(),
CurrentPeriodEnd: time.Unix(sub.CurrentPeriodEnd, 0).UTC(),
}, nil
}
func cadenceFromInterval(price *stripe.Price) (Cadence, error) {
if price == nil || price.Recurring == nil {
return "", fmt.Errorf("price is not recurring")
}
switch price.Recurring.Interval {
case stripe.PriceRecurringIntervalMonth:
return CadenceMonthly, nil
case stripe.PriceRecurringIntervalYear:
return CadenceAnnual, nil
default:
return "", fmt.Errorf("unsupported price interval %q", price.Recurring.Interval)
}
}