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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user