From 2e8114c41eb6d60c35b64fd069f4dbb57709d6e0 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 10:40:06 +0100 Subject: [PATCH] feat(admin): subscription webhooks promote the entitlement and reissue; cancel and past-due take no licence action created/updated/activated fold into 'make the world match current state', so out-of-order delivery is correct by construction. A confirmed subscription promotes desired->granted and signs from granted only. Cancel and past-due touch only status; the licence runs to expiry. IssueForInstance backstops a self-hosted placeholder that is linked after payment. Co-Authored-By: Claude Opus 5 --- admin/internal/billing/deliver.go | 24 +++ admin/internal/billing/events.go | 6 +- admin/internal/billing/subscription.go | 276 +++++++++++++++++++++++++ admin/internal/mail/mail.go | 26 +++ 4 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 admin/internal/billing/deliver.go create mode 100644 admin/internal/billing/subscription.go diff --git a/admin/internal/billing/deliver.go b/admin/internal/billing/deliver.go new file mode 100644 index 0000000..6b734cb --- /dev/null +++ b/admin/internal/billing/deliver.go @@ -0,0 +1,24 @@ +package billing + +import ( + "context" + + "github.com/mrhid6/vantage/admin/internal/inject" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" +) + +// deliver sends a freshly issued licence where it belongs. Cloud is injected; +// self-hosted is emailed the blob (their database is theirs). This mirrors the +// api-side deliver helper but takes no gin context — webhooks have none, and the +// customer is not on the other end of the request. +func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) { + if inst.Deployment == license.DeploymentCloud { + inject.Deliver(ctx, lic) + return + } + if to != "" && mail.Enabled() { + _ = mail.SendLicense(to, inst.Name, lic.Blob) + } +} diff --git a/admin/internal/billing/events.go b/admin/internal/billing/events.go index 4b2b4ec..e5fa40c 100644 --- a/admin/internal/billing/events.go +++ b/admin/internal/billing/events.go @@ -50,10 +50,6 @@ func decode[T any](ev Event) (T, error) { return v, nil } -// Stubs replaced in tasks 3 and 4. -func handleSubscription(ctx context.Context, ev Event) error { return nil } -func handleCanceled(ctx context.Context, ev Event) error { return nil } -func handlePastDue(ctx context.Context, ev Event) error { return nil } +// Transaction stubs replaced in task 4. func handleTransactionCompleted(ctx context.Context, ev Event) error { return nil } func handlePaymentFailed(ctx context.Context, ev Event) error { return nil } -func handleCustomerUpdated(ctx context.Context, ev Event) error { return nil } diff --git a/admin/internal/billing/subscription.go b/admin/internal/billing/subscription.go new file mode 100644 index 0000000..27c3863 --- /dev/null +++ b/admin/internal/billing/subscription.go @@ -0,0 +1,276 @@ +package billing + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/catalogue" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// subscriptionData is the slice of Paddle's subscription payload we read. Fields +// we ignore are simply absent — encoding/json drops them. +type subscriptionData struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + Status string `json:"status"` + CustomData struct { + AccountID string `json:"account_id"` + InstanceID string `json:"instance_id"` + } `json:"custom_data"` + CurrentBillingPeriod struct { + EndsAt time.Time `json:"ends_at"` + } `json:"current_billing_period"` + Items []struct { + Price struct { + ID string `json:"id"` + } `json:"price"` + Quantity int `json:"quantity"` + } `json:"items"` +} + +func (d subscriptionData) lineItems() []catalogue.Item { + items := make([]catalogue.Item, 0, len(d.Items)) + for _, it := range d.Items { + items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity}) + } + return items +} + +// handleSubscription folds created/updated/activated into one job: make the +// world match the subscription's CURRENT state. That is what keeps out-of-order +// delivery correct — an updated arriving before its created still carries the +// full item list, so reading all of it is reading current state, not a +// transition. +func handleSubscription(ctx context.Context, ev Event) error { + d, err := decode[subscriptionData](ev) + if err != nil { + return err + } + if d.CustomData.InstanceID == "" { + return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID) + } + + match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems()) + if err != nil { + // A price we cannot map is a configuration error, not a customer error. + // Fail loudly so it is retried and surfaced rather than guessed. + return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err) + } + + sub := models.Subscription{ + AccountID: d.CustomData.AccountID, + InstanceID: d.CustomData.InstanceID, + PaddleSubscriptionID: d.ID, + Tier: match.Tier, + Term: match.Term, + Status: d.Status, + CurrentPeriodEnd: d.CurrentBillingPeriod.EndsAt, + Items: toSubItems(d.lineItems()), + } + if err := upsertSubscription(ctx, sub); err != nil { + return err + } + + // Learn the Paddle customer ID onto the account the first time we see it. + if d.CustomerID != "" && d.CustomData.AccountID != "" { + _, _ = db.Admin("accounts").UpdateOne(ctx, + bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}}, + bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}}) + } + + // A self-hosted placeholder that has not been linked yet gets its + // subscription recorded but NO licence — there is no UUID to bind to. The + // link endpoint issues when the customer pastes it. + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil { + return fmt.Errorf("subscription %s names unknown instance %s: %w", + d.ID, d.CustomData.InstanceID, err) + } + if inst.Placeholder { + return nil + } + + return promoteAndIssue(ctx, &inst, match, models.ReasonEntitlementChange) +} + +// promoteAndIssue promotes desired→granted from the resolved match, then signs a +// licence from granted. This is the only promotion path other than the staff +// grant, and it exists because a webhook is a confirmed payment. +func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error { + plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier) + if err != nil { + return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err) + } + granted := models.Config{ + Servers: match.Servers, + Features: models.Features(match.Features).OrEmpty(), + } + limits, _, err := catalogue.Resolve(ctx, plan, granted) + if err != nil { + return err + } + if err := models.UpsertEntitlement(ctx, models.Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: inst.Deployment, + Tier: match.Tier, + Term: match.Term, + Desired: granted, + Granted: granted, + ResolvedLimits: limits, + }); err != nil { + return err + } + + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: match.Tier, + Term: match.Term, + Reason: reason, + IssuedBy: "paddle", + }) + if err != nil { + return fmt.Errorf("issue for %s: %w", inst.InstanceID, err) + } + deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID)) + return nil +} + +// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action. +// +// The instance stays active until its licence expires, when the existing +// lifecycle sweep lapses it. Flipping the instance to cancelled here would stop +// inject.Reconcile and the sweep repairing a licence that is still valid — the +// opposite of "keeps working until it expires". +func handleCanceled(ctx context.Context, ev Event) error { + d, err := decode[subscriptionData](ev) + if err != nil { + return err + } + if _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": d.ID}, + bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil { + return err + } + if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" { + _ = mail.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID)) + } + return nil +} + +// handlePastDue flags the subscription and notifies, but leaves the licence +// alone. Dunning is Paddle's; ours is not to punish a retryable card failure. +func handlePastDue(ctx context.Context, ev Event) error { + d, err := decode[subscriptionData](ev) + if err != nil { + return err + } + if _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": d.ID}, + bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil { + return err + } + if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" { + _ = mail.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID)) + } + return nil +} + +// handleCustomerUpdated syncs the billing email onto the account. +func handleCustomerUpdated(ctx context.Context, ev Event) error { + d, err := decode[struct { + ID string `json:"id"` + Email string `json:"email"` + }](ev) + if err != nil { + return err + } + if d.ID == "" || d.Email == "" { + return nil + } + _, err = db.Admin("accounts").UpdateOne(ctx, + bson.M{"paddle_customer_id": d.ID}, + bson.M{"$set": bson.M{"billing_email": d.Email}}) + return err +} + +// IssueForInstance issues from an instance's recorded subscription. Called when +// a self-hosted customer finally links a placeholder they have already paid for. +func IssueForInstance(ctx context.Context, instanceID string) error { + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil { + return fmt.Errorf("no active subscription for %s: %w", instanceID, err) + } + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err != nil { + return err + } + items := make([]catalogue.Item, 0, len(sub.Items)) + for _, it := range sub.Items { + items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity}) + } + match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items) + if err != nil { + return err + } + return promoteAndIssue(ctx, &inst, match, models.ReasonNew) +} + +func toSubItems(items []catalogue.Item) []models.SubItem { + out := make([]models.SubItem, 0, len(items)) + for _, it := range items { + out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity}) + } + return out +} + +func upsertSubscription(ctx context.Context, sub models.Subscription) error { + _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID}, + bson.M{"$set": bson.M{ + "account_id": sub.AccountID, + "instance_id": sub.InstanceID, + "tier": sub.Tier, + "term": sub.Term, + "status": sub.Status, + "current_period_end": sub.CurrentPeriodEnd, + "items": sub.Items, + }, "$setOnInsert": bson.M{ + "subscription_id": uuid.NewString(), + "paddle_subscription_id": sub.PaddleSubscriptionID, + }}, + options.UpdateOne().SetUpsert(true)) + return err +} + +// billingEmailFor reads the account's billing email for self-hosted delivery. +func billingEmailFor(ctx context.Context, accountID string) string { + var acc models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": accountID}).Decode(&acc); err != nil { + return "" + } + return acc.BillingEmail +} + +// instanceNameFor is a best-effort display name for an email subject. +func instanceNameFor(ctx context.Context, instanceID string) string { + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" { + return "your instance" + } + return inst.Name +} diff --git a/admin/internal/mail/mail.go b/admin/internal/mail/mail.go index ba5e8d5..b81de5a 100644 --- a/admin/internal/mail/mail.go +++ b/admin/internal/mail/mail.go @@ -164,6 +164,32 @@ func SendLicense(to, instanceName, blob string) error { instanceName, blob)) } +// SendCancelled confirms a cancellation and states what stays true: the licence +// keeps working until it expires, then the instance degrades to read-only. +func SendCancelled(to, instanceName string) error { + return send(to, "Your Vantage subscription is cancelled", + fmt.Sprintf("Your subscription for %s is cancelled.\n\n"+ + "Your instance keeps working until the current licence expires. After "+ + "that, monitors keep running but changes are disabled.\n", instanceName)) +} + +// SendPastDue notifies of a failed charge without alarming: the licence is +// untouched while Paddle retries the card. +func SendPastDue(to, instanceName string) error { + return send(to, "Payment failed for your Vantage subscription", + fmt.Sprintf("A payment for %s failed.\n\n"+ + "Your instance is unaffected while the card is retried. Update your "+ + "payment method from the billing portal.\n", instanceName)) +} + +// SendLinkReminder chases a self-hosted customer who paid but never linked. +func SendLinkReminder(to, instanceName string) error { + return send(to, "Finish setting up "+instanceName, + fmt.Sprintf("Your subscription for %s is active, but the instance is not "+ + "linked yet.\n\nPaste your install's ID in the portal to receive your "+ + "licence.\n", instanceName)) +} + // SendInstanceReady tells a customer their cloud instance exists, where it is, // and when its licence runs out. //