Compare commits
4 Commits
936418d61f
...
a621b1deb9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a621b1deb9 | ||
|
|
8827980043 | ||
|
|
3230a28804 | ||
|
|
eeb7b8e488 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ arcline-billing
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
billing
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -27,6 +28,18 @@ func Open(path string) (*sql.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// PurgeExpired deletes expired sessions and used/expired password reset tokens.
|
||||
func PurgeExpired(database *sql.DB) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := database.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now); err != nil {
|
||||
return fmt.Errorf("purge sessions: %w", err)
|
||||
}
|
||||
if _, err := database.Exec(`DELETE FROM password_resets WHERE expires_at < ? OR used = 1`, now); err != nil {
|
||||
return fmt.Errorf("purge password_resets: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSchema(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS customers (
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -45,6 +46,17 @@ func (c Config) Ready() bool {
|
||||
return c.SecretKey != "" && c.WebhookSecret != ""
|
||||
}
|
||||
|
||||
// PlanName returns the human-readable plan key for a Stripe price ID,
|
||||
// or an empty string if the price ID isn't in the configured map.
|
||||
func (c Config) PlanName(priceID string) string {
|
||||
for name, id := range c.PriceIDs {
|
||||
if id == priceID {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateCustomer creates a Stripe customer and returns the Stripe customer ID.
|
||||
func CreateCustomer(email, firstName, lastName string) (string, error) {
|
||||
params := &stripelib.CustomerParams{
|
||||
@@ -90,10 +102,13 @@ func CreateCheckoutSession(
|
||||
return s.URL, nil
|
||||
}
|
||||
|
||||
// CancelSubscription cancels a Stripe subscription by ID.
|
||||
// CancelSubscription schedules a Stripe subscription to cancel at the end of
|
||||
// the current billing period rather than immediately.
|
||||
func CancelSubscription(stripeSubID string) error {
|
||||
params := &stripelib.SubscriptionCancelParams{}
|
||||
_, err := subscription.Cancel(stripeSubID, params)
|
||||
params := &stripelib.SubscriptionParams{
|
||||
CancelAtPeriodEnd: stripelib.Bool(true),
|
||||
}
|
||||
_, err := subscription.Update(stripeSubID, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe cancel subscription: %w", err)
|
||||
}
|
||||
@@ -101,7 +116,7 @@ func CancelSubscription(stripeSubID string) error {
|
||||
}
|
||||
|
||||
// HandleCheckoutCompleted processes a checkout.session.completed webhook event.
|
||||
func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error {
|
||||
func HandleCheckoutCompleted(db *sql.DB, cfg Config, raw json.RawMessage) error {
|
||||
var cs stripelib.CheckoutSession
|
||||
if err := json.Unmarshal(raw, &cs); err != nil {
|
||||
return fmt.Errorf("unmarshal checkout session: %w", err)
|
||||
@@ -119,15 +134,28 @@ func HandleCheckoutCompleted(db *sql.DB, raw json.RawMessage) error {
|
||||
var customerID int64
|
||||
fmt.Sscanf(customerIDStr, "%d", &customerID)
|
||||
|
||||
// Fetch the full subscription to get the price ID and plan name.
|
||||
priceID := ""
|
||||
planName := ""
|
||||
sub, err := subscription.Get(cs.Subscription.ID, nil)
|
||||
if err != nil {
|
||||
slog.Warn("checkout completed: fetch subscription from stripe", "err", err)
|
||||
} else if len(sub.Items.Data) > 0 {
|
||||
priceID = sub.Items.Data[0].Price.ID
|
||||
planName = cfg.PlanName(priceID)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := db.Exec(
|
||||
_, 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',
|
||||
stripe_price_id = excluded.stripe_price_id,
|
||||
plan_name = excluded.plan_name,
|
||||
updated_at = excluded.updated_at`,
|
||||
customerID, cs.Subscription.ID, "", "", now, now,
|
||||
customerID, cs.Subscription.ID, priceID, planName, now, now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ func (h *Handler) DashboardGET(w http.ResponseWriter, r *http.Request) {
|
||||
flash = "Checkout was cancelled. No charge was made."
|
||||
}
|
||||
if r.URL.Query().Get("cancelled") == "1" {
|
||||
flash = "Your subscription has been cancelled."
|
||||
flash = "Your subscription has been cancelled and will not renew. You retain access until the end of the current billing period."
|
||||
}
|
||||
if r.URL.Query().Get("error") == "cancel_failed" {
|
||||
flash = "Could not cancel subscription. Please contact support."
|
||||
@@ -597,7 +597,7 @@ func (h *Handler) WebhookPOST(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
if err := payments.HandleCheckoutCompleted(h.DB, event.Data.Raw); err != nil {
|
||||
if err := payments.HandleCheckoutCompleted(h.DB, h.Stripe, event.Data.Raw); err != nil {
|
||||
slog.Error("webhook: checkout.session.completed", "err", err)
|
||||
}
|
||||
case "invoice.paid":
|
||||
@@ -643,10 +643,10 @@ func (h *Handler) CancelPOST(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, _ = h.DB.Exec(
|
||||
`UPDATE subscriptions SET status = 'cancelled', updated_at = ? WHERE stripe_subscription_id = ?`,
|
||||
`UPDATE subscriptions SET status = 'cancelling', updated_at = ? WHERE stripe_subscription_id = ?`,
|
||||
now, stripeSubID,
|
||||
)
|
||||
|
||||
slog.Info("subscription cancelled", "customer_id", customerID, "stripe_sub_id", stripeSubID)
|
||||
slog.Info("subscription scheduled for cancellation", "customer_id", customerID, "stripe_sub_id", stripeSubID)
|
||||
http.Redirect(w, r, "/dashboard?cancelled=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
</div>
|
||||
{{if .Subscription.CurrentPeriodEnd}}
|
||||
<div class="status-row">
|
||||
<span class="status-row__label">next billing date</span>
|
||||
<span class="status-row__label">{{if eq .Subscription.Status "cancelling"}}access until{{else}}next billing date{{end}}</span>
|
||||
<span class="status-row__dots"></span>
|
||||
<span class="status-row__value">{{.Subscription.CurrentPeriodEnd}}</span>
|
||||
<span class="status-row__value">{{.Subscription.CurrentPeriodEnd | fmtDate}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
18
main.go
18
main.go
@@ -120,6 +120,24 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Background job: purge expired sessions and password reset tokens every 24 hours.
|
||||
go func() {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := db.PurgeExpired(database); err != nil {
|
||||
slog.Error("purge expired rows", "err", err)
|
||||
} else {
|
||||
slog.Info("purged expired sessions and reset tokens")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
stop()
|
||||
slog.Info("shutting down")
|
||||
|
||||
Reference in New Issue
Block a user