first commit
This commit is contained in:
216
internal/payments/payments.go
Normal file
216
internal/payments/payments.go
Normal file
@@ -0,0 +1,216 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user