Files
billing/internal/payments/payments.go
Blake Ridgway d8cb1d277f first commit
2026-03-27 23:18:17 -05:00

217 lines
6.4 KiB
Go

package payments
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"time"
stripelib "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/checkout/session"
stripecustomer "github.com/stripe/stripe-go/v81/customer"
"github.com/stripe/stripe-go/v81/subscription"
)
// Config holds Stripe credentials loaded from environment variables.
type Config struct {
SecretKey string
WebhookSecret string
PriceIDs map[string]string // plan key → Stripe price ID
}
// ConfigFromEnv reads Stripe settings from environment variables.
func ConfigFromEnv() Config {
return Config{
SecretKey: os.Getenv("STRIPE_SECRET_KEY"),
WebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
PriceIDs: map[string]string{
"shared_starter": os.Getenv("STRIPE_PRICE_SHARED_STARTER"),
"shared_pro": os.Getenv("STRIPE_PRICE_SHARED_PRO"),
"shared_business": os.Getenv("STRIPE_PRICE_SHARED_BUSINESS"),
"wp_starter": os.Getenv("STRIPE_PRICE_WP_STARTER"),
"wp_pro": os.Getenv("STRIPE_PRICE_WP_PRO"),
"wp_business": os.Getenv("STRIPE_PRICE_WP_BUSINESS"),
"vps_1": os.Getenv("STRIPE_PRICE_VPS_1"),
"vps_2": os.Getenv("STRIPE_PRICE_VPS_2"),
"vps_3": os.Getenv("STRIPE_PRICE_VPS_3"),
"vps_4": os.Getenv("STRIPE_PRICE_VPS_4"),
},
}
}
// Ready reports whether the required Stripe credentials are set.
func (c Config) Ready() bool {
return c.SecretKey != "" && c.WebhookSecret != ""
}
// CreateCustomer creates a Stripe customer and returns the Stripe customer ID.
func CreateCustomer(email, firstName, lastName string) (string, error) {
params := &stripelib.CustomerParams{
Email: stripelib.String(email),
Name: stripelib.String(firstName + " " + lastName),
}
c, err := stripecustomer.New(params)
if err != nil {
return "", fmt.Errorf("stripe create customer: %w", err)
}
return c.ID, nil
}
// CreateCheckoutSession creates a Stripe Checkout session and returns the hosted URL.
func CreateCheckoutSession(
cfg Config,
customerID int64,
stripeCustomerID,
priceID,
successURL,
cancelURL string,
) (string, error) {
params := &stripelib.CheckoutSessionParams{
Customer: stripelib.String(stripeCustomerID),
Mode: stripelib.String(string(stripelib.CheckoutSessionModeSubscription)),
LineItems: []*stripelib.CheckoutSessionLineItemParams{
{
Price: stripelib.String(priceID),
Quantity: stripelib.Int64(1),
},
},
SuccessURL: stripelib.String(successURL),
CancelURL: stripelib.String(cancelURL),
Metadata: map[string]string{
"customer_id": fmt.Sprintf("%d", customerID),
},
}
s, err := session.New(params)
if err != nil {
return "", fmt.Errorf("stripe checkout session: %w", err)
}
return s.URL, nil
}
// CancelSubscription cancels a Stripe subscription by ID.
func CancelSubscription(stripeSubID string) error {
params := &stripelib.SubscriptionCancelParams{}
_, err := subscription.Cancel(stripeSubID, params)
if err != nil {
return fmt.Errorf("stripe cancel subscription: %w", err)
}
return nil
}
// HandleCheckoutCompleted processes a checkout.session.completed webhook event.
func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error {
var cs stripelib.CheckoutSession
if err := json.Unmarshal(raw, &cs); err != nil {
return fmt.Errorf("unmarshal checkout session: %w", err)
}
if cs.Subscription == nil {
return nil
}
customerIDStr, ok := cs.Metadata["customer_id"]
if !ok {
return fmt.Errorf("missing customer_id in metadata")
}
var customerID int64
fmt.Sscanf(customerIDStr, "%d", &customerID)
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`INSERT INTO subscriptions
(customer_id, stripe_subscription_id, stripe_price_id, plan_name, status, current_period_end, created_at, updated_at)
VALUES (?, ?, ?, ?, 'active', '', ?, ?)
ON CONFLICT(stripe_subscription_id) DO UPDATE SET
status = 'active',
updated_at = excluded.updated_at`,
customerID, cs.Subscription.ID, "", "", now, now,
)
return err
}
// HandleInvoicePaid processes an invoice.paid webhook event.
func HandleInvoicePaid(db *sql.DB, raw json.RawMessage) error {
var inv stripelib.Invoice
if err := json.Unmarshal(raw, &inv); err != nil {
return fmt.Errorf("unmarshal invoice: %w", err)
}
var customerID int64
if inv.Customer != nil {
err := db.QueryRow(
`SELECT id FROM customers WHERE stripe_customer_id = ?`, inv.Customer.ID,
).Scan(&customerID)
if err != nil {
return fmt.Errorf("find customer for stripe id %s: %w", inv.Customer.ID, err)
}
}
pdfURL := ""
if inv.InvoicePDF != "" {
pdfURL = inv.InvoicePDF
}
periodStart := time.Unix(inv.PeriodStart, 0).UTC().Format(time.RFC3339)
periodEnd := time.Unix(inv.PeriodEnd, 0).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`INSERT INTO invoices
(customer_id, stripe_invoice_id, amount_cents, currency, status, invoice_pdf_url, period_start, period_end, created_at)
VALUES (?, ?, ?, ?, 'paid', ?, ?, ?, ?)
ON CONFLICT(stripe_invoice_id) DO UPDATE SET
status = 'paid',
invoice_pdf_url = excluded.invoice_pdf_url`,
customerID, inv.ID, inv.AmountPaid, string(inv.Currency), pdfURL, periodStart, periodEnd, now,
)
if err != nil {
return fmt.Errorf("upsert invoice: %w", err)
}
if inv.Subscription != nil {
_, _ = db.Exec(
`UPDATE subscriptions SET current_period_end = ?, updated_at = ? WHERE stripe_subscription_id = ?`,
periodEnd, now, inv.Subscription.ID,
)
}
return nil
}
// HandleInvoicePaymentFailed processes an invoice.payment_failed webhook event.
func HandleInvoicePaymentFailed(db *sql.DB, raw json.RawMessage) error {
var inv stripelib.Invoice
if err := json.Unmarshal(raw, &inv); err != nil {
return fmt.Errorf("unmarshal invoice: %w", err)
}
if inv.Subscription == nil {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`UPDATE subscriptions SET status = 'past_due', updated_at = ? WHERE stripe_subscription_id = ?`,
now, inv.Subscription.ID,
)
return err
}
// HandleSubscriptionDeleted processes a customer.subscription.deleted webhook event.
func HandleSubscriptionDeleted(db *sql.DB, raw json.RawMessage) error {
var sub stripelib.Subscription
if err := json.Unmarshal(raw, &sub); err != nil {
return fmt.Errorf("unmarshal subscription: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(
`UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`,
now, sub.ID,
)
return err
}