|
|
|
@@ -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
|
|
|
|
|
}
|