diff --git a/admin/internal/billing/events.go b/admin/internal/billing/events.go index e5fa40c..d11e823 100644 --- a/admin/internal/billing/events.go +++ b/admin/internal/billing/events.go @@ -49,7 +49,3 @@ func decode[T any](ev Event) (T, error) { } return v, 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 } diff --git a/admin/internal/billing/transaction.go b/admin/internal/billing/transaction.go new file mode 100644 index 0000000..754b531 --- /dev/null +++ b/admin/internal/billing/transaction.go @@ -0,0 +1,124 @@ +package billing + +import ( + "context" + "fmt" + + "github.com/mrhid6/vantage/admin/internal/catalogue" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type transactionData struct { + ID string `json:"id"` + SubscriptionID string `json:"subscription_id"` + Origin string `json:"origin"` + Items []struct { + Price struct { + ID string `json:"id"` + } `json:"price"` + Quantity int `json:"quantity"` + } `json:"items"` +} + +// handleTransactionCompleted issues the next term's licence on a renewal. +// +// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's +// desired (smaller) configuration becomes granted. Mid-term reductions never +// shrink a live licence. On a first charge (origin not recurring) the +// subscription.created/updated handler already issued, so this is a no-op to +// avoid a double issue. +func handleTransactionCompleted(ctx context.Context, ev Event) error { + d, err := decode[transactionData](ev) + if err != nil { + return err + } + if d.Origin != "subscription_recurring" { + return nil + } + if d.SubscriptionID == "" { + return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID) + } + + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil { + return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err) + } + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil { + return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err) + } + + // Prefer the transaction's own item list (authoritative for this period); + // fall back to the subscription's recorded items. + 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}) + } + if len(items) == 0 { + 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 fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err) + } + + // Collapse a scheduled reduction: desired becomes granted, and the pending + // marker is cleared, since a new term has begun. This is the only place a + // licence ever gets a smaller cap. + if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil { + return err + } + + // Issue the next term. Renewal resets relink_count inside licensing.Issue. + if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil { + return err + } + + // Clear lifecycle notices so the next term starts the sequence fresh (mirrors + // the self-serve renew path). + _, _ = db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$unset": bson.M{"notices_sent": ""}}) + return nil +} + +// promoteScheduledReduction collapses a pending reduction into granted at +// renewal and clears scheduled_change_at. A no-op when nothing is pending — the +// match resolved from the renewal's items is authoritative either way, so this +// only keeps the entitlement's own bookkeeping honest. +func promoteScheduledReduction(ctx context.Context, instanceID string) error { + ent, err := models.GetEntitlement(ctx, instanceID) + if err != nil { + return nil // no entitlement to reconcile + } + if ent.ScheduledChangeAt == nil { + return nil + } + ent.Granted = ent.Desired + ent.ScheduledChangeAt = nil + return models.UpsertEntitlement(ctx, *ent) +} + +// handlePaymentFailed records the failure for staff visibility. No licence +// action — the licence runs to its (grace-padded) expiry and Paddle retries. +func handlePaymentFailed(ctx context.Context, ev Event) error { + d, err := decode[transactionData](ev) + if err != nil { + return err + } + if d.SubscriptionID == "" { + return nil + } + _, err = db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": d.SubscriptionID}, + bson.M{"$set": bson.M{"status": models.SubPastDue}}) + return err +}