Skip to content
plexusonePublic

About

The billing runtime for a product whose pricing and packaging is described by a pricing spec. The spec says what is offered; payforge does what that needs at the payment provider

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

77 Commits

Folders and files

Repository files navigation

PayForge

Go CI Go Lint Go SAST Docs DevGuide Visualization License

The billing runtime for a product whose pricing and packaging is described by a pricing spec. The spec says what is offered; payforge does what that needs at the payment provider: it computes the products and prices the provider should hold, starts checkout and the customer portal, and turns a paid subscription into an entitlement through ports the host supplies.

Status: in development. The provider-neutral core and the Stripe adapter's customers, plan and one-time checkout, customer portal, catalog sync, webhook verification, the subscription lifecycle, Connect sales and their refund and account events exist; see the roadmap. The user guide walks through all of it.

spec, err := pricingspec.Load(f)
if err != nil { return err }

// Stop at startup if billing is half configured or cannot sell the spec.
if err := payforge.CheckStartup(spec, payforge.ProviderConfig{
	SecretKey: key, WebhookSecrets: []string{endpointSecret},
}); err != nil {
	return err
}

// With no key, every account is on the spec's default plan.
var billing payforge.Billing = payforge.Disabled(spec)

// What a provider account should hold, found again by stable lookup keys.
plan := payforge.ProviderPlanFor(spec)

With a key, the Stripe adapter implements the same interface:

import payforgestripe "github.com/plexusone/payforge/stripe"

billing, err := payforgestripe.New(spec, payforgestripe.Config{
	SecretKey:       key,
	AllowedURLHosts: []string{"app.example.com"}, // where buyers may be sent back to
})
if err != nil { return err }

sess, err := billing.CheckoutPlan(ctx, payforge.PlanCheckout{
	Subject:    payforge.Subject{ID: org.ID, Name: org.Name, Email: org.BillingEmail},
	Plan:       "pro",
	Interval:   pricingspec.IntervalMonth,
	SuccessURL: "https://app.example.com/billing",
	CancelURL:  "https://app.example.com/pricing",
})
// Send the buyer to sess.URL.

Prices are found by lookup key, so the catalog must be in the account. Sync it first, with a dry run to see what would change:

report, err := billing.Sync(ctx, true) // true: report only, write nothing
for _, a := range report.Actions {
	fmt.Println(a.Kind, a.Key, a.Detail)
}
_, err = billing.Sync(ctx, false)

Sync is safe to repeat. It never deletes, and it never edits an amount: Stripe prices are immutable, so a changed amount creates a new price, moves the lookup key to it and leaves the old one for the subscribers already on it. A subject that already has a granting subscription is refused with payforge.ErrAlreadySubscribed, and a plan with a trial starts one without asking for a card.

Webhooks are verified and reduced to a small event:

hook := payforgestripe.Webhook{
	Mode:    billing.Mode(), // events of the other mode are ignored
	Secrets: []string{platformSecret, connectSecret},
}

func handle(w http.ResponseWriter, r *http.Request) {
	ev, err := hook.Read(r) // verifies the signature; the body is limited to 1 MiB
	if errors.Is(err, payforge.ErrSignatureInvalid) {
		http.Error(w, "bad signature", http.StatusBadRequest)
		return
	}
	if err != nil { // too large, or unreadable: see examples/webhook
		http.Error(w, "cannot read event", http.StatusInternalServerError)
		return
	}
	// ev.Kind is a subscription, checkout, invoice, refund or account change,
	// or payforge.EventIgnored for anything else: acknowledge it and move on.
}

A subscription event then becomes the account's entitlement, decided from what Stripe holds now rather than the order webhooks arrived in:

life := &payforge.Lifecycle{
	Spec: spec, Store: store, Sink: sink, // yours: see payforgetest to check them
	Provider: billing,                     // the Stripe client reads subscriptions
}
res, err := life.Handle(ctx, ev)
// err: answer 5xx and Stripe redelivers. Otherwise answer 2xx; res.Outcome is
// applied, duplicate, ignored or skipped, with a Reason.

A subscription grants while it is active, trialing or past_due. A price the spec does not have never grants. Everything written to the provider carries payforge_app, so several products can share one account and still be told apart.

Releases

v0.1.1 · v0.1.0 · Changelog

Development

go test ./...
go test . -update   # rewrite the golden files, then review the diff

About

The billing runtime for a product whose pricing and packaging is described by a pricing spec. The spec says what is offered; payforge does what that needs at the payment provider

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages