This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/catalogue"
|
||||
"github.com/mrhid6/vantage/admin/internal/cloudprov"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/admin/internal/paddle"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// placeholderReconcileInterval is how often placeholders are swept: paid cloud
|
||||
// ones a failed webhook left unprovisioned are completed, and stale unpaid ones
|
||||
// of either deployment are reaped.
|
||||
const placeholderReconcileInterval = 5 * time.Minute
|
||||
|
||||
// abandonedPlaceholderAfter is how long an unpaid placeholder may sit before it
|
||||
// is treated as an abandoned checkout and deleted. Comfortably longer than a
|
||||
// webhook's delivery lag, so a just-paid placeholder awaiting its subscription
|
||||
// event is never mistaken for an abandoned one.
|
||||
const abandonedPlaceholderAfter = 24 * time.Hour
|
||||
|
||||
// StartPlaceholderReconciler owns the after-checkout lifecycle of placeholders.
|
||||
//
|
||||
// It recovers the one failure the webhook cannot on its own — a confirmed payment
|
||||
// whose provisioning handler errored, which is not retried once its event is
|
||||
// claimed and which the inject reconciler (licences only) does not repair — by
|
||||
// completing paid cloud placeholders here. And it reaps abandoned ones: a
|
||||
// placeholder with no subscription past abandonedPlaceholderAfter is a checkout
|
||||
// nobody finished, and deleting it loses nothing, because a placeholder has no
|
||||
// control-plane footprint until it is paid for and provisioned.
|
||||
func StartPlaceholderReconciler(ctx context.Context) {
|
||||
go func() {
|
||||
t := time.NewTicker(placeholderReconcileInterval)
|
||||
defer t.Stop()
|
||||
reconcilePlaceholders(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
reconcilePlaceholders(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func reconcilePlaceholders(ctx context.Context) {
|
||||
cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"placeholder": true})
|
||||
if err != nil {
|
||||
log.Printf("placeholder reconcile: query: %v", err)
|
||||
return
|
||||
}
|
||||
var placeholders []models.Instance
|
||||
if err := cur.All(ctx, &placeholders); err != nil {
|
||||
log.Printf("placeholder reconcile: decode: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, inst := range placeholders {
|
||||
var sub models.Subscription
|
||||
paid := db.Admin("subscriptions").FindOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub) == nil
|
||||
|
||||
if !paid {
|
||||
// Never paid for. Reap once it is old enough to be an abandoned
|
||||
// checkout rather than one still awaiting its subscription webhook.
|
||||
if now.Sub(inst.CreatedAt) > abandonedPlaceholderAfter {
|
||||
if _, err := db.Admin("admin_instances").DeleteOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "placeholder": true}); err != nil {
|
||||
log.Printf("placeholder reconcile: reap abandoned %s: %v", inst.InstanceID, err)
|
||||
} else {
|
||||
log.Printf("placeholder reconcile: reaped abandoned placeholder %s", inst.InstanceID)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Paid, self-hosted: nothing to provision — the customer installs and
|
||||
// links, and lifecycle chases them. Only cloud is completed here.
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Printf("placeholder reconcile: resolve items for %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
provisioned, err := completeCloudPlaceholder(ctx, &inst)
|
||||
if err != nil {
|
||||
log.Printf("placeholder reconcile: complete %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
if err := promoteAndIssue(ctx, provisioned, match, models.ReasonNew); err != nil {
|
||||
log.Printf("placeholder reconcile: issue %s: %v", inst.InstanceID, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("placeholder reconcile: completed paid cloud instance %s", inst.InstanceID)
|
||||
}
|
||||
}
|
||||
|
||||
// completeCloudPlaceholder provisions the cloud instance a paid placeholder stands
|
||||
// for, once payment is confirmed, and returns the row promoted to a real instance.
|
||||
//
|
||||
// It is the payment-first half of the paid-cloud flow: createCloudCheckout made
|
||||
// the placeholder before payment, this provisions it after. The control-plane
|
||||
// instance is created with the placeholder's OWN id (cloudprov.CreateInstanceWithID),
|
||||
// so nothing is rewritten and the subscription's custom_data still resolves this
|
||||
// row on every later webhook.
|
||||
//
|
||||
// Every step is idempotent, because a webhook can be retried after this partly
|
||||
// ran: provisioning converges rather than duplicates, and the row flip and
|
||||
// membership insert are guarded on what they write. The caller then issues.
|
||||
func completeCloudPlaceholder(ctx context.Context, inst *models.Instance) (*models.Instance, error) {
|
||||
cu, err := placeholderOwner(ctx, inst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prov, err := cloudprov.CreateInstanceWithID(ctx, inst.InstanceID, inst.Name,
|
||||
cu.Email, cu.PasswordHash, cu.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provision cloud instance %s: %w", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID},
|
||||
bson.M{
|
||||
"$set": bson.M{"slug": prov.Slug, "status": models.StatusActive, "placeholder": false},
|
||||
"$unset": bson.M{"pending_owner_user_id": ""},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("promote placeholder %s: %w", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
// Record the owner's membership. Best-effort and guarded on absence: the
|
||||
// projected user is what grants access, so a missing row costs a line in the
|
||||
// members panel, not access — and the boot backfill rebuilds it.
|
||||
if ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID); err == nil {
|
||||
if n, _ := db.Admin("instance_members").CountDocuments(ctx,
|
||||
bson.M{"instance_id": inst.InstanceID, "customer_user_id": cu.UserID}); n == 0 {
|
||||
if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: inst.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: cu.UserID,
|
||||
ControlUserID: ownerID,
|
||||
Role: sharedmodels.RoleOwner,
|
||||
Email: cu.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
log.Printf("completeCloudPlaceholder: record owner membership for %s: %v",
|
||||
inst.InstanceID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next := *inst
|
||||
next.Slug = prov.Slug
|
||||
next.Status = models.StatusActive
|
||||
next.Placeholder = false
|
||||
next.PendingOwnerUserID = ""
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
// placeholderOwner resolves the customer_user who should own a provisioned cloud
|
||||
// placeholder: the buyer recorded at checkout, or the account owner if that
|
||||
// pointer is somehow missing.
|
||||
func placeholderOwner(ctx context.Context, inst *models.Instance) (*models.CustomerUser, error) {
|
||||
var cu models.CustomerUser
|
||||
if inst.PendingOwnerUserID != "" {
|
||||
if err := db.Admin("customer_users").FindOne(ctx,
|
||||
bson.M{"user_id": inst.PendingOwnerUserID}).Decode(&cu); err == nil {
|
||||
return &cu, nil
|
||||
}
|
||||
}
|
||||
if err := db.Admin("customer_users").FindOne(ctx,
|
||||
bson.M{"account_id": inst.AccountID, "account_role": models.AccountRoleOwner}).Decode(&cu); err != nil {
|
||||
return nil, fmt.Errorf("no owner for account %s to provision %s: %w",
|
||||
inst.AccountID, inst.InstanceID, err)
|
||||
}
|
||||
return &cu, nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/admin/internal/paddle"
|
||||
"github.com/mrhid6/vantage/shared/license"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
@@ -87,20 +88,32 @@ func handleSubscription(ctx context.Context, ev Event) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// Placeholders are the payment-first path: the instance does not exist until
|
||||
// this confirmed-payment event. A cloud placeholder is provisioned here and
|
||||
// then issued (first term). A self-hosted placeholder has no UUID to bind to
|
||||
// until the customer pastes their install's — its subscription is recorded and
|
||||
// the link endpoint issues later.
|
||||
reason := models.ReasonEntitlementChange
|
||||
if inst.Placeholder {
|
||||
return nil
|
||||
if inst.Deployment != license.DeploymentCloud {
|
||||
return nil
|
||||
}
|
||||
provisioned, err := completeCloudPlaceholder(ctx, &inst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inst = *provisioned
|
||||
reason = models.ReasonNew
|
||||
}
|
||||
|
||||
return promoteAndIssue(ctx, &inst, match, models.ReasonEntitlementChange)
|
||||
return promoteAndIssue(ctx, &inst, match, reason)
|
||||
}
|
||||
|
||||
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
|
||||
|
||||
Reference in New Issue
Block a user