Documentation
¶
Overview ¶
Package billing connects SystemForge to payforge (github.com/plexusone/payforge), which turns a pricing spec into products and prices at a payment provider and a paid subscription into an entitlement. It supplies the ports payforge leaves to the host, backed by the SystemForge Ent client, and the HTTP handlers an application mounts.
It is a package of its own, not part of marketplace, because it serves both the organization's platform plan and marketplace listings, and because marketplace stays free of any payment provider: billing depends on marketplace, never the reverse.
The subject ¶
The subject payforge bills is the buying organization: payforge's Subject.ID and Entitlement.Subject are an organization ID. A person pays on behalf of an organization they may manage.
Pieces ¶
- Store is payforge's Store: processed event IDs, kept with a unique constraint so an event is applied once across instances, and the mapping from a provider customer to an organization. It also records listing orders and sellers' connected accounts.
- billing/pglock is a payforge.Locker that holds across instances with a PostgreSQL advisory lock; payforge.MemoryLocker holds within one process. Give the same Locker to the lifecycle, the Stripe client and billing's configs.
- Sink is payforge's EntitlementSink, SaleSink, SellerSink and DisputeSink: a plan becomes the organization's platform subscription, a paid listing checkout a license, a full refund or a dispute the license's suspension, and a seller's connected account whether its paid listings can be published (SellerGate). Every apply writes the authorization graph again, so a sync that failed is repaired by the provider's redelivery.
- NewWebhookHandler is the webhook endpoint.
- Checkout holds the checkout and customer-portal endpoints.
- NewStripe builds the payforge Stripe client and webhook reader with the settings production needs.
Wiring ¶
locker := pglock.New(sqlDB, logger) // &payforge.MemoryLocker{} on one instance
store := billing.NewStore(entClient)
sink := billing.NewSink(entClient,
billing.WithAuthzSyncer(marketplace.NewSpiceDBSyncer(spice)))
client, reader, err := billing.NewStripe(spec, billing.StripeConfig{
SecretKey: key, WebhookSecrets: secrets,
AllowedURLHosts: []string{"app.example.com"}, Locker: locker})
life := &payforge.Lifecycle{Spec: spec, Store: store, Sink: sink,
Sales: sink, Sellers: sink, Disputes: sink,
Provider: client, Accounts: client, Locker: locker}
hook, err := billing.NewWebhookHandler(billing.WebhookConfig{
Reader: reader, Lifecycle: life, Store: store, Checkouts: sink,
Logger: logger})
mux.Handle("POST /billing/webhook", hook)
co, err := billing.NewCheckout(billing.CheckoutConfig{
Billing: client, Connect: client, Spec: spec, Store: store,
DB: entClient, Listings: svc.Listings(), ListingProduct: "listing",
Purchases: marketplace.OrgRolePurchaseAuthorizer(orgAuthz),
Locker: locker, URLs: billing.RedirectURLs{...},
AllowedURLHosts: []string{"app.example.com"}, Logger: logger})
co.Register(mux, "/billing")
svc.WithPublishGate(billing.NewSellerGate(store))
The tables are created by the Ent schema migration (Schema.Create, or systemauth migrate); no SQL migration is needed.
Decisions ¶
Redirect URLs are configured on the server and never read from a request. Responses carry fixed messages; causes are logged. Only one-time listings are sold through Checkout: payforge sells subscriptions to the pricing spec's plans, not to individual listings. Once billing is wired, it owns the organization's platform subscription: an entitlement sets its plan, so a plan set by hand is replaced by the next subscription event for the organization.
Index ¶
- Variables
- func CredentialOrganization(r *http.Request) (uuid.UUID, error)
- func NewStripe(spec *pricingspec.Spec, cfg StripeConfig) (*payforgestripe.Client, payforgestripe.Webhook, error)
- func NewWebhookHandler(cfg WebhookConfig) (http.Handler, error)
- type Checkout
- type CheckoutCompleter
- type CheckoutConfig
- type EventHandler
- type Order
- type OrganizationResolver
- type PlanChange
- type PlanSyncer
- type RedirectURLs
- type Seller
- type SellerGate
- type Sink
- func (s *Sink) Apply(ctx context.Context, e payforge.Entitlement) error
- func (s *Sink) ApplyDispute(ctx context.Context, d payforge.ChargeDispute) error
- func (s *Sink) ApplyRefund(ctx context.Context, r payforge.SaleRefund) error
- func (s *Sink) ApplySeller(ctx context.Context, a payforge.SellerAccount) error
- func (s *Sink) CompleteCheckout(ctx context.Context, ev payforge.Event) error
- type SinkOption
- func OnEntitlement(fn func(ctx context.Context, c PlanChange) error) SinkOption
- func OnSeller(fn func(ctx context.Context, s Seller) error) SinkOption
- func WithAuthzSyncer(a marketplace.AuthzSyncer) SinkOption
- func WithPlanSyncer(p PlanSyncer) SinkOption
- func WithSinkLogger(l *slog.Logger) SinkOption
- type Store
- func (s *Store) CustomerForOrganization(ctx context.Context, orgID uuid.UUID) (string, error)
- func (s *Store) LinkCustomer(ctx context.Context, customerID string, orgID uuid.UUID) error
- func (s *Store) LinkSeller(ctx context.Context, orgID uuid.UUID, accountID string) error
- func (s *Store) MarkSeen(ctx context.Context, eventID string) error
- func (s *Store) PruneEvents(ctx context.Context, cutoff time.Time) (int, error)
- func (s *Store) RecordOrder(ctx context.Context, o Order) error
- func (s *Store) Seen(ctx context.Context, eventID string) (bool, error)
- func (s *Store) SellerFor(ctx context.Context, orgID uuid.UUID) (Seller, error)
- func (s *Store) SubjectForCustomer(ctx context.Context, customerID string) (string, error)
- type StripeConfig
- type WebhookConfig
- type WebhookReader
Constants ¶
This section is empty.
Variables ¶
var ErrCustomerConflict = errors.New("billing: customer belongs to another organization")
ErrCustomerConflict is returned by LinkCustomer when the provider customer is already linked to another organization.
var ErrNoOrganization = errors.New("billing: the request acts for no organization")
ErrNoOrganization is returned by an OrganizationResolver for a request that acts for no organization.
Functions ¶
func CredentialOrganization ¶
CredentialOrganization resolves the organization from the caller's credential: the organization its session or token is scoped to (requestprincipal.RequestPrincipal.Organization). It is the default.
func NewStripe ¶
func NewStripe(spec *pricingspec.Spec, cfg StripeConfig) (*payforgestripe.Client, payforgestripe.Webhook, error)
NewStripe checks the configuration as payforge.CheckStartup does, and returns the Stripe client and the webhook reader, with the reader's mode set from the key so events of the other mode are acknowledged and not applied. It fails with payforge.ErrNotConfigured when there is no key: use payforge.Disabled for free mode then.
func NewWebhookHandler ¶
func NewWebhookHandler(cfg WebhookConfig) (http.Handler, error)
NewWebhookHandler returns the endpoint the payment provider posts events to. It answers:
- 405 to anything but POST;
- 400 when the signature does not verify, and 413 when the body is over the reader's limit: a retry cannot fix either;
- 500 when the event could not be read or handled, including a lock that could not be taken, so the provider redelivers it;
- 200 otherwise, including an event that was a duplicate, was ignored (an event of the other mode among them), or was skipped because it belongs to another app or account.
A response body is a fixed word or two and never carries error detail; the cause is logged.
Serve it from an http.Server with ReadHeaderTimeout and ReadTimeout set: the body is read before the signature is checked.
Types ¶
type Checkout ¶
type Checkout struct {
// contains filtered or unexported fields
}
Checkout serves checkout and the customer portal for the caller's organization. The organization comes from the caller's credential, the caller must pass Purchases for it, the provider customer is found from the organization on the server, and every redirect URL is configured. Responses are JSON: {"url": "..."} to send the browser to, or {"error": "..."} with a fixed message.
func NewCheckout ¶
func NewCheckout(cfg CheckoutConfig) (*Checkout, error)
NewCheckout checks cfg and returns a Checkout.
func (*Checkout) ListingHandler ¶
ListingHandler starts a one-time checkout for a published marketplace listing, at the listing's price.
func (*Checkout) PlanHandler ¶
PlanHandler starts a subscription checkout for one of the spec's self-serve plans.
func (*Checkout) PortalHandler ¶
PortalHandler opens the provider's customer portal for the organization's customer.
type CheckoutCompleter ¶
CheckoutCompleter records a one-time checkout that was paid, or whose delayed payment failed, which payforge's lifecycle leaves to the host. Sink implements it. It must be safe to call more than once for the same checkout.
type CheckoutConfig ¶
type CheckoutConfig struct {
// Billing is the payment provider, usually a payforge/stripe client.
Billing payforge.Billing
// Connect sells listings for their creator organizations when the spec
// has a marketplace. Without a marketplace a listing is sold as the
// platform's own product.
Connect payforge.Connect
Spec *pricingspec.Spec
Store *Store
DB *ent.Client
// Listings, when set, enables listing checkout.
Listings marketplace.ListingService
// ListingProduct is the spec's seller-set product that a listing is sold
// as, at the listing's price. Required with Listings.
ListingProduct string
// Purchases decides who may buy, and open the billing portal, for an
// organization, for example marketplace.OrgRolePurchaseAuthorizer. It is
// required: without it nobody could be allowed, so NewCheckout refuses.
Purchases marketplace.PurchaseAuthorizer
// Purchasers, when set, refuses managed principals.
Purchasers marketplace.PurchaserChecker
// Locker is held per organization around a listing checkout. Use the
// lifecycle's Locker. A plan checkout is serialized by the payforge
// Stripe client's own Config.Locker, which must be the same Locker.
Locker payforge.Locker
// Caller identifies the purchaser. It defaults to
// marketplace.RequestPrincipalCaller.
Caller marketplace.CallerResolver
// Organization finds the organization the caller acts for. It defaults
// to CredentialOrganization.
Organization OrganizationResolver
URLs RedirectURLs
// AllowedURLHosts are the hosts redirect URLs may name. Give the
// payforge Stripe client the same list (stripe.Config.AllowedURLHosts),
// which refuses any other host.
AllowedURLHosts []string
// Logger receives the causes of failures. It defaults to slog.Default().
Logger *slog.Logger
}
CheckoutConfig configures Checkout. Billing, Spec, Store, DB, Purchases and Locker are required, and AllowedURLHosts must name the host of every URL in URLs.
type EventHandler ¶
type EventHandler interface {
Handle(ctx context.Context, ev payforge.Event) (payforge.Result, error)
}
EventHandler handles a verified event. *payforge.Lifecycle implements it.
type Order ¶
type Order struct {
SessionID string
ListingID uuid.UUID
OrganizationID uuid.UUID
PurchaserID uuid.UUID
AmountCents int64
}
Order is a one-time checkout for a marketplace listing, recorded when the checkout is created.
type OrganizationResolver ¶
OrganizationResolver returns the organization a request acts for. It returns ErrNoOrganization when the request names none.
type PlanChange ¶
type PlanChange struct {
OrganizationID uuid.UUID
Entitlement payforge.Entitlement
Subscription *marketplace.Subscription
}
PlanChange is what OnEntitlement receives: the entitlement payforge applied and the platform subscription it was stored as.
type PlanSyncer ¶
type PlanSyncer interface {
SyncSubscription(ctx context.Context, sub *marketplace.Subscription) error
}
PlanSyncer writes an organization's platform subscription to the authorization graph. marketplace.SpiceDBSyncer implements it.
type RedirectURLs ¶
type RedirectURLs struct {
PlanSuccess string
PlanCancel string
ListingSuccess string
ListingCancel string
PortalReturn string
}
RedirectURLs are where the provider sends the buyer back to. They are configured on the server and never taken from a request. The checkout session ID is appended to a success URL.
type Seller ¶
type Seller struct {
OrganizationID uuid.UUID
AccountID string
ChargesEnabled bool
PayoutsEnabled bool
DetailsSubmitted bool
}
Seller is a selling organization's connected account.
type SellerGate ¶
type SellerGate struct {
// contains filtered or unexported fields
}
SellerGate is a marketplace.PublishGate: an organization may publish a paid listing while its connected account can take charges.
func NewSellerGate ¶
func NewSellerGate(store *Store) *SellerGate
NewSellerGate returns a SellerGate over store.
func (*SellerGate) CanPublish ¶
CanPublish implements marketplace.PublishGate.
type Sink ¶
type Sink struct {
// contains filtered or unexported fields
}
Sink is payforge's EntitlementSink, SaleSink, SellerSink and DisputeSink on the SystemForge Ent client, and the CheckoutCompleter of the webhook.
- A plan entitlement becomes the organization's platform subscription (the subscription with no listing): its plan_tier is the plan the organization is on, which is the spec's default plan when nothing grants.
- A paid one-time checkout for a listing grants the buying organization a license, matched through the order recorded at checkout.
- A full refund, an open dispute or a lost one suspends that license (its valid_until is set); a dispute that is won restores it. A refund is permanent, and a closed dispute stays closed.
- A seller's connected account records whether it can take charges, which SellerGate turns into whether its paid listings can be published.
Every apply is idempotent and writes the authorization graph again from the stored state, whether or not the database changed, so a sync that failed is repaired when the provider redelivers the event. Database changes for one apply happen in one transaction; the graph is written after it commits, and a failure is returned so the event is retried.
func NewSink ¶
func NewSink(db *ent.Client, opts ...SinkOption) *Sink
NewSink returns a Sink. It panics on a nil client, a programming error.
func (*Sink) ApplyDispute ¶
ApplyDispute implements payforge.DisputeSink. An open or lost dispute suspends the license the disputed payment's order granted; a dispute that is won restores it.
func (*Sink) ApplyRefund ¶
ApplyRefund implements payforge.SaleSink. It keeps the highest refunded total seen for the charge, and a full refund suspends the license its order granted for good.
func (*Sink) ApplySeller ¶
ApplySeller implements payforge.SellerSink.
type SinkOption ¶
type SinkOption func(*Sink)
SinkOption configures a Sink.
func OnEntitlement ¶
func OnEntitlement(fn func(ctx context.Context, c PlanChange) error) SinkOption
OnEntitlement calls fn after every entitlement is stored, for the host's own state that follows the plan, such as limits or feature flags. An error is returned to payforge, and the event is retried; fn must be idempotent.
func OnSeller ¶
func OnSeller(fn func(ctx context.Context, s Seller) error) SinkOption
OnSeller calls fn after every change to a seller's account is stored, with the same retry rules as OnEntitlement.
func WithAuthzSyncer ¶
func WithAuthzSyncer(a marketplace.AuthzSyncer) SinkOption
WithAuthzSyncer writes licenses to the authorization graph, for example marketplace.NewSpiceDBSyncer(client).
func WithPlanSyncer ¶
func WithPlanSyncer(p PlanSyncer) SinkOption
WithPlanSyncer writes the platform subscription to the authorization graph on every entitlement.
func WithSinkLogger ¶
func WithSinkLogger(l *slog.Logger) SinkOption
WithSinkLogger sets where events the sink cannot act on are logged. It defaults to slog.Default().
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is payforge's Store on the SystemForge Ent client. It is portable across the Ent dialects SystemForge supports; the guarantee that an event is processed once rests on the unique event_id column, so it holds across instances sharing a database.
func (*Store) CustomerForOrganization ¶
CustomerForOrganization returns the provider customer an organization pays through, or payforge.ErrNotFound.
func (*Store) LinkCustomer ¶
LinkCustomer records that an organization pays through a provider customer. Linking the same pair again does nothing. An organization that had another customer is moved to this one. A customer already linked to another organization is refused with ErrCustomerConflict.
func (*Store) LinkSeller ¶
LinkSeller records an organization's connected account, as returned by payforge's Connect.CreateAccount. It cannot sell until the provider reports that charges are enabled. Linking the same pair again does nothing.
func (*Store) MarkSeen ¶
MarkSeen implements payforge.Store. Marking an event twice, or from two instances at once, is harmless: the unique constraint keeps one row.
func (*Store) PruneEvents ¶
PruneEvents forgets events processed before cutoff and reports how many. The provider redelivers an event for a few days at most, so a cutoff of a month or more keeps every event that can still arrive.
func (*Store) RecordOrder ¶
RecordOrder records a checkout before the buyer is sent to it, so its payment and refunds are matched to the listing and organization from this record, never from what the provider sends back.
type StripeConfig ¶
type StripeConfig struct {
// SecretKey is the Stripe secret key. Its mode (test or live) is the
// mode webhook events must match.
SecretKey string
// WebhookSecrets are the signing secrets of the webhook endpoints: one,
// and a second for connected-account events when the spec has a
// marketplace.
WebhookSecrets []string
// AllowedURLHosts are the hosts redirect URLs may name. Required; give
// CheckoutConfig.AllowedURLHosts the same list.
AllowedURLHosts []string
// Locker is held per organization around a plan checkout. Required;
// give the lifecycle and CheckoutConfig the same Locker.
Locker payforge.Locker
}
StripeConfig is what NewStripe needs. It holds secrets: do not log it.
type WebhookConfig ¶
type WebhookConfig struct {
Reader WebhookReader
Lifecycle EventHandler
// Store is the lifecycle's store. It finds the organization of a
// checkout that names only a customer, and records completed checkouts
// as processed.
Store payforge.Store
// Locker is held per organization, under payforge.LockKey, around a
// completed checkout, as the lifecycle holds it around its own events.
// Use the lifecycle's Locker; it defaults to that of a
// *payforge.Lifecycle.
Locker payforge.Locker
// Checkouts, when set, receives one-time checkouts for marketplace
// listings. Without it they are acknowledged and not recorded.
Checkouts CheckoutCompleter
// App is the pricing spec's app. A checkout for another app is
// acknowledged and not passed to Checkouts. It defaults to the spec of
// a *payforge.Lifecycle.
App string
// Logger receives one line per event. It defaults to slog.Default().
Logger *slog.Logger
}
WebhookConfig configures the webhook endpoint. Reader, Lifecycle and Store are required, and a Locker when Checkouts is set.