This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mrhid6/vantage/admin/internal/api"
|
||||
"github.com/mrhid6/vantage/admin/internal/auth"
|
||||
"github.com/mrhid6/vantage/admin/internal/billing"
|
||||
"github.com/mrhid6/vantage/admin/internal/config"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"github.com/mrhid6/vantage/admin/internal/hqsync"
|
||||
@@ -93,6 +94,7 @@ func main() {
|
||||
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
|
||||
defer stopReconcile()
|
||||
inject.StartReconciler(reconcileCtx)
|
||||
billing.StartPlaceholderReconciler(reconcileCtx)
|
||||
hqsync.Start(reconcileCtx)
|
||||
|
||||
lifecycle.SetPortalURL(cfg.PublicURL)
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -74,6 +75,50 @@ func createSelfHostedPlaceholder(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
|
||||
}
|
||||
|
||||
// createCloudCheckout creates a PAID cloud placeholder and hands back its id so
|
||||
// the browser can open a Paddle checkout keyed to it. Nothing is provisioned yet:
|
||||
// a cloud instance costs real infrastructure, so it is created only once payment
|
||||
// is confirmed, by the subscription webhook (billing.handleSubscription).
|
||||
//
|
||||
// This mirrors the self-hosted placeholder, with one difference that matters:
|
||||
// admin owns the cloud UUID, so the id generated here is the id the instance
|
||||
// will keep. Provisioning on the webhook reuses it (provision.CreateInstanceWithID),
|
||||
// which is why there is no claim-and-rewrite step and the subscription's
|
||||
// custom_data never goes stale. PendingOwnerUserID remembers who bought it so the
|
||||
// webhook can make them the instance owner.
|
||||
//
|
||||
// An abandoned checkout therefore leaves only this row — no infrastructure — the
|
||||
// same cheap, reap-safe state a self-hosted placeholder leaves.
|
||||
func createCloudCheckout(c *gin.Context) {
|
||||
s := auth.Current(c)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
inst := models.Instance{
|
||||
InstanceID: uuid.NewString(),
|
||||
AccountID: s.AccountID,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Deployment: license.DeploymentCloud,
|
||||
Status: models.StatusAwaitingLink,
|
||||
Placeholder: true,
|
||||
PendingOwnerUserID: s.UserID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.cloud_placeholder_created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, IP: c.ClientIP()})
|
||||
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
|
||||
}
|
||||
|
||||
// updateEntitlement sets an instance's DESIRED configuration and pushes the
|
||||
// resulting line items to Paddle. It does NOT issue — the resulting
|
||||
// subscription.updated webhook does, from granted. An increase is prorated
|
||||
|
||||
@@ -200,6 +200,96 @@ func listSubscriptions(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, subs)
|
||||
}
|
||||
|
||||
// provisionCloudInstance provisions a real cloud instance in the control plane
|
||||
// and records admin's row for it, WITHOUT issuing a licence. Both the Free
|
||||
// create path and the paid-checkout path share it, so the provisioning — and its
|
||||
// unwind-in-reverse rollback — has one definition rather than two that drift.
|
||||
//
|
||||
// It leaves the instance unlicensed on purpose: createInstance then issues Free,
|
||||
// and createCloudCheckout leaves it for the paid subscription webhook to license.
|
||||
// The returned rec carries no Tier or CurrentLicense; the caller sets those once
|
||||
// it has issued.
|
||||
//
|
||||
// Errors are returned unwrapped for the provisioning step so the caller can still
|
||||
// match provision.ErrEmailTaken / ErrNameRejected; later steps are wrapped.
|
||||
func provisionCloudInstance(c *gin.Context, name string) (*models.Instance, error) {
|
||||
ctx := c.Request.Context()
|
||||
s := auth.Current(c)
|
||||
|
||||
var cu models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx,
|
||||
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
|
||||
return nil, fmt.Errorf("read account: %w", err)
|
||||
}
|
||||
|
||||
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec := models.Instance{
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: s.AccountID,
|
||||
Name: inst.Name,
|
||||
Slug: inst.Slug,
|
||||
Deployment: license.DeploymentCloud,
|
||||
Status: models.StatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
|
||||
// Unwind in reverse: the owner first, because RollbackInstance refuses
|
||||
// an instance that still has users.
|
||||
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
|
||||
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
|
||||
}
|
||||
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
|
||||
log.Printf("provisionCloudInstance: rollback of %s failed: %v", inst.InstanceID, e)
|
||||
}
|
||||
return nil, fmt.Errorf("record instance: %w", err)
|
||||
}
|
||||
|
||||
// Record the owner's membership. Best-effort: the projected user already
|
||||
// exists and is what actually grants access, so a missing row here costs a
|
||||
// line in the members panel, not access — and the boot backfill rebuilds it.
|
||||
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
|
||||
if err != nil {
|
||||
log.Printf("provisionCloudInstance: owner lookup for %s: %v", inst.InstanceID, err)
|
||||
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: s.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: cu.UserID,
|
||||
ControlUserID: ownerID,
|
||||
Role: sharedmodels.RoleOwner,
|
||||
Email: cu.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
log.Printf("provisionCloudInstance: record owner membership for %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// cloudProvisionError maps the errors provisionCloudInstance can surface onto the
|
||||
// customer-facing responses shared by the Free and paid-checkout paths.
|
||||
func cloudProvisionError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, provision.ErrEmailTaken):
|
||||
// users.email is unique per instance, so this means the address already
|
||||
// owns a user in an instance we are not creating — a legacy cloud tenant.
|
||||
// Staff have to attach that one by hand.
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
|
||||
case errors.Is(err, provision.ErrNameRejected):
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
|
||||
}
|
||||
}
|
||||
|
||||
// createInstance provisions a Free cloud instance for the calling account.
|
||||
//
|
||||
// The ordering matters and each step unwinds the previous one. Licence issuance
|
||||
@@ -241,75 +331,12 @@ func createInstance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var cu models.CustomerUser
|
||||
if err := db.Admin("customer_users").FindOne(ctx,
|
||||
bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
|
||||
return
|
||||
}
|
||||
|
||||
inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
|
||||
rec, err := provisionCloudInstance(c, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, provision.ErrEmailTaken) {
|
||||
// users.email is unique per instance, so this means the address
|
||||
// already owns a user in an instance we are not creating — a legacy
|
||||
// cloud tenant. Staff have to attach that one by hand.
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, provision.ErrNameRejected) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
|
||||
cloudProvisionError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rec := models.Instance{
|
||||
InstanceID: inst.InstanceID,
|
||||
AccountID: s.AccountID,
|
||||
Name: inst.Name,
|
||||
Slug: inst.Slug,
|
||||
Deployment: license.DeploymentCloud,
|
||||
Status: models.StatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
|
||||
// Unwind in reverse: the owner first, because RollbackInstance refuses
|
||||
// an instance that still has users.
|
||||
if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
|
||||
_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
|
||||
}
|
||||
if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
|
||||
log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
|
||||
return
|
||||
}
|
||||
|
||||
// Record the owner's membership. Best-effort: the projected user already
|
||||
// exists and is what actually grants access, so a missing row here costs a
|
||||
// line in the members panel, not access — and the boot backfill rebuilds it.
|
||||
ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
|
||||
if err != nil {
|
||||
log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
|
||||
} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
|
||||
MemberID: uuid.NewString(),
|
||||
AccountID: s.AccountID,
|
||||
InstanceID: inst.InstanceID,
|
||||
CustomerUserID: cu.UserID,
|
||||
ControlUserID: ownerID,
|
||||
Role: sharedmodels.RoleOwner,
|
||||
Email: cu.Email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
audit.Write(ctx, models.AuditEntry{
|
||||
Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
|
||||
Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
|
||||
inst := rec
|
||||
|
||||
// Past this point nothing fails the request.
|
||||
lic, err := licensing.Issue(ctx, licensing.IssueInput{
|
||||
|
||||
@@ -80,6 +80,10 @@ func Routes(cfg config.Config) http.Handler {
|
||||
cust.POST("/instances/self-hosted",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createSelfHostedPlaceholder)
|
||||
// Paid cloud: provisions a real instance the paid webhook then licenses.
|
||||
cust.POST("/instances/cloud",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
createCloudCheckout)
|
||||
cust.PUT("/instances/:id/entitlement",
|
||||
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
|
||||
updateEntitlement)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
sharedmodels "github.com/mrhid6/vantage/shared/models"
|
||||
"github.com/mrhid6/vantage/shared/provision"
|
||||
@@ -32,11 +33,29 @@ import (
|
||||
// On owner-insert failure the instance is rolled back, so a failed provision
|
||||
// never leaves a slug permanently occupied by an instance nobody owns.
|
||||
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
|
||||
inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
|
||||
return CreateInstanceWithID(ctx, uuid.NewString(), name, ownerEmail, ownerPasswordHash, hqUserID)
|
||||
}
|
||||
|
||||
// CreateInstanceWithID provisions a cloud instance under a caller-supplied ID and
|
||||
// its owner. It backs the paid-cloud flow, where the ID is a placeholder created
|
||||
// before payment and provisioning runs on the confirmed-payment webhook (see
|
||||
// provision.CreateInstanceWithID).
|
||||
//
|
||||
// It is idempotent, because a webhook can be retried after provisioning partly
|
||||
// completed: the instance is created only if absent, and the owner only if the
|
||||
// instance has none yet. A second call therefore converges to the same state
|
||||
// rather than colliding on the per-instance email unique index.
|
||||
func CreateInstanceWithID(ctx context.Context, instanceID, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
|
||||
inst, err := provision.CreateInstanceWithID(ctx, db.ControlDB(), instanceID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A retry that already created the owner must not create a second one.
|
||||
if _, err := OwnerUserID(ctx, inst.InstanceID); err == nil {
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
|
||||
ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
|
||||
if err != nil {
|
||||
|
||||
@@ -147,7 +147,11 @@ type Instance struct {
|
||||
// checkout has something to attach custom_data to, before the customer has
|
||||
// pasted their install's real UUID. Cleared when the instance is linked.
|
||||
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
// PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
|
||||
// remembered so the confirmed-payment webhook can provision the instance with
|
||||
// them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
|
||||
PendingOwnerUserID string `bson:"pending_owner_user_id,omitempty" json:"-"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// License is append-only. A renewal writes a new row and sets SupersededBy on
|
||||
|
||||
@@ -70,10 +70,15 @@ export default function OverviewPage() {
|
||||
title="Overview"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
!hasFree && live.length > 0 ? (
|
||||
<LinkButton variant="line" href="/instances/new">
|
||||
Create a free instance
|
||||
</LinkButton>
|
||||
live.length > 0 ? (
|
||||
<>
|
||||
{!hasFree && (
|
||||
<LinkButton variant="line" href="/instances/new">
|
||||
Create a free instance
|
||||
</LinkButton>
|
||||
)}
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
record={[
|
||||
@@ -96,7 +101,10 @@ export default function OverviewPage() {
|
||||
server, and link it here to get your licence file.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<LinkButton href="/instances/new">Create a free instance</LinkButton>
|
||||
<LinkButton href="/purchase">Buy a plan</LinkButton>
|
||||
<LinkButton variant="line" href="/instances/new">
|
||||
Create a free instance
|
||||
</LinkButton>
|
||||
<LinkButton variant="line" href="/instances/link">
|
||||
Link an install
|
||||
</LinkButton>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,15 @@ import type { Metadata } from "next";
|
||||
import { PurchaseForm } from "./PurchaseForm";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export const metadata: Metadata = { title: "Buy a self-hosted plan" };
|
||||
export const metadata: Metadata = { title: "Buy a plan" };
|
||||
|
||||
export default function PurchasePage() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Self-hosted plan"
|
||||
subtitle="Name your instance, choose a plan, and pay. After payment, paste the ID your install reports to receive its licence. Self-hosted is billed annually."
|
||||
title="Choose your plan"
|
||||
subtitle="Configure the instance, see exactly what you'll be charged, then pay. Nothing is billed until you confirm at checkout."
|
||||
/>
|
||||
<PurchaseForm />
|
||||
</div>
|
||||
|
||||
@@ -319,6 +319,9 @@ export const api = {
|
||||
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
|
||||
createSelfHosted: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
|
||||
// Paid cloud: provisions the real instance the paid webhook then licenses.
|
||||
createCloudCheckout: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/cloud", { name }),
|
||||
updateEntitlement: (
|
||||
id: string,
|
||||
body: { tier: Tier; term: Term; servers: number; features: string[] },
|
||||
|
||||
@@ -15,3 +15,62 @@ export function initPaddle(): Promise<Paddle | undefined> {
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export interface PricedLine {
|
||||
priceId: string;
|
||||
/* Already localised and currency-formatted by Paddle, e.g. "£39.00". The line
|
||||
* total for the quantity, not the unit price. */
|
||||
total: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface PricePreview {
|
||||
currency: string;
|
||||
/* Grand total, formatted. */
|
||||
total: string;
|
||||
lines: Record<string, PricedLine>;
|
||||
}
|
||||
|
||||
/*
|
||||
* previewPrices asks Paddle for the real localised prices of a set of line items,
|
||||
* so the order summary shows what the customer will actually pay rather than a
|
||||
* hardcoded number that would drift from the dashboard.
|
||||
*
|
||||
* It returns null when Paddle is unavailable or a price cannot be previewed (an
|
||||
* unconfigured sandbox price, an ad blocker). The caller falls back to showing
|
||||
* the line items without amounts rather than a wrong total — the real figure
|
||||
* still appears in the checkout overlay, which is the authority.
|
||||
*/
|
||||
export async function previewPrices(
|
||||
items: { priceId: string; quantity: number }[],
|
||||
): Promise<PricePreview | null> {
|
||||
if (items.length === 0) return { currency: "", total: "", lines: {} };
|
||||
const paddle = await initPaddle();
|
||||
if (!paddle) return null;
|
||||
try {
|
||||
const res = await paddle.PricePreview({
|
||||
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
|
||||
});
|
||||
const currency = res.data.currencyCode;
|
||||
const lines: Record<string, PricedLine> = {};
|
||||
// Paddle gives per-line totals but no grand total, so sum the raw minor
|
||||
// units and format once. The checkout overlay is the authority; this is
|
||||
// the honest preview beside it.
|
||||
let subtotalMinor = 0;
|
||||
for (const li of res.data.details.lineItems) {
|
||||
lines[li.price.id] = {
|
||||
priceId: li.price.id,
|
||||
total: li.formattedTotals.subtotal,
|
||||
unit: li.formattedUnitTotals.subtotal,
|
||||
};
|
||||
subtotalMinor += Number.parseInt(li.totals.subtotal, 10) || 0;
|
||||
}
|
||||
const total = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(subtotalMinor / 100);
|
||||
return { currency, total, lines };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,35 @@ var ErrNameRejected = errors.New("organisation name rejected")
|
||||
|
||||
const maxSlugAttempts = 50
|
||||
|
||||
// CreateInstance inserts an instance under the first free slug derived from name.
|
||||
// CreateInstance inserts an instance under the first free slug derived from name,
|
||||
// with a freshly generated ID.
|
||||
func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) {
|
||||
return CreateInstanceWithID(ctx, db, uuid.NewString(), name)
|
||||
}
|
||||
|
||||
// CreateInstanceWithID inserts an instance under the first free slug derived from
|
||||
// name, using a caller-supplied instance ID.
|
||||
//
|
||||
// A caller-supplied ID exists for the paid-cloud flow: a placeholder row is
|
||||
// created before payment and provisioning happens on the confirmed-payment
|
||||
// webhook. Provisioning with the placeholder's own ID keeps the id stable, so
|
||||
// the subscription's custom_data never points at a rewritten row and later
|
||||
// webhooks still resolve it. If an instance with this ID already exists — a
|
||||
// webhook retried after a partial provision — it is returned as-is rather than
|
||||
// duplicated.
|
||||
//
|
||||
// The count-then-insert loop is racy on its own. It is safe only because
|
||||
// instances.slug carries a unique index: a lost race surfaces as a duplicate-key
|
||||
// error, which we treat as "that slug is taken" and retry. Do not remove the
|
||||
// duplicate-key branch, and do not remove the index.
|
||||
func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) {
|
||||
func CreateInstanceWithID(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) {
|
||||
// Idempotency: a retried provision finds its own instance already present.
|
||||
var existing models.Instance
|
||||
if err := db.Collection("instances").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID}).Decode(&existing); err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
|
||||
base, err := BaseSlug(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
|
||||
@@ -41,7 +63,7 @@ func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*mode
|
||||
}
|
||||
|
||||
inst := models.Instance{
|
||||
InstanceID: uuid.NewString(),
|
||||
InstanceID: instanceID,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
|
||||
Reference in New Issue
Block a user