feat(admin): Paddle client behind an interface, config, and the event idempotency record
Client is a thin REST client (net/http) rather than the vendor SDK: the surface we need is two calls, and a hand-rolled client has no version-drift risk and no dependency in go.sum. All Paddle wire shapes live only in http.go. PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are boot-required — an unverified webhook endpoint is one anyone can issue licences through. paddle_events carries a unique index on event_id for webhook idempotency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,21 @@ const (
|
||||
ReasonEntitlementChange = "entitlement_change"
|
||||
)
|
||||
|
||||
// Subscription statuses, mirrored from Paddle. Ours, not a vendor SDK's, so the
|
||||
// billing package does not import anything Paddle.
|
||||
const (
|
||||
SubActive = "active"
|
||||
SubCanceled = "canceled"
|
||||
SubPastDue = "past_due"
|
||||
SubTrialing = "trialing"
|
||||
)
|
||||
|
||||
// Billing terms. These match catalogue price-ID keys and license.TermsFor.
|
||||
const (
|
||||
TermMonthly = "monthly"
|
||||
TermAnnual = "annual"
|
||||
)
|
||||
|
||||
// MaxRelinksPerTerm is the customer-facing relink cap.
|
||||
//
|
||||
// This is an abuse SIGNAL, not abuse prevention — offline licences cannot be
|
||||
@@ -128,6 +143,10 @@ type Instance struct {
|
||||
// clears it, so the next term starts the sequence again. It is what stops a
|
||||
// restart re-sending a notice.
|
||||
NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
|
||||
// Placeholder is true while a self-hosted instance row exists only so a
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -158,11 +177,32 @@ type Subscription struct {
|
||||
AccountID string `bson:"account_id" json:"account_id"`
|
||||
InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"`
|
||||
PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"`
|
||||
PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"`
|
||||
Tier string `bson:"tier" json:"tier"`
|
||||
Term string `bson:"term" json:"term"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"`
|
||||
// Items is the full line-item list. Spec 7 made a subscription several
|
||||
// prices — a base, a per-server unit at quantity N, an item per paid
|
||||
// feature — so a single price ID can no longer describe it.
|
||||
Items []SubItem `bson:"items,omitempty" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// SubItem is one line of a subscription: a price and its quantity, the shape
|
||||
// catalogue.ResolveItems reads back into a plan and configuration.
|
||||
type SubItem struct {
|
||||
PriceID string `bson:"price_id" json:"price_id"`
|
||||
Quantity int `bson:"quantity" json:"quantity"`
|
||||
}
|
||||
|
||||
// PaddleEvent is the idempotency record for one webhook delivery. The unique
|
||||
// index on EventID is what makes a retry a no-op rather than a second licence.
|
||||
type PaddleEvent struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
EventID string `bson:"event_id" json:"event_id"`
|
||||
EventType string `bson:"event_type" json:"event_type"`
|
||||
ReceivedAt time.Time `bson:"received_at" json:"received_at"`
|
||||
ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
|
||||
Error string `bson:"error,omitempty" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/admin/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// ClaimEvent records an event ID before it is processed and reports whether THIS
|
||||
// call is the one that claimed it.
|
||||
//
|
||||
// The unique index on event_id turns a duplicate insert into a duplicate-key
|
||||
// error, which is the signal that another delivery of the same event already
|
||||
// owns it — so this returns (false, nil) and the caller answers 200 without
|
||||
// acting. A genuine error returns (false, err).
|
||||
func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) {
|
||||
_, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{
|
||||
EventID: eventID,
|
||||
EventType: eventType,
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
})
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// MarkEventProcessed stamps success, or records the error for staff visibility.
|
||||
// A failed event keeps no processed_at, so a retry re-runs it.
|
||||
func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error {
|
||||
set := bson.M{}
|
||||
if procErr != nil {
|
||||
set["error"] = procErr.Error()
|
||||
} else {
|
||||
now := time.Now().UTC()
|
||||
set["processed_at"] = now
|
||||
set["error"] = ""
|
||||
}
|
||||
_, err := db.Admin("paddle_events").UpdateOne(ctx,
|
||||
bson.M{"event_id": eventID}, bson.M{"$set": set})
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user