Help us shape the future of Go! Take the 2026 Go Developer Survey

payforge

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 9 Imported by: 2

README

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.0 · Changelog

Development

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

Documentation

Overview

Package payforge is the billing runtime for a product whose pricing and packaging is described by a pricing spec (github.com/plexusone/systemspec-pricingpackaging).

It owns what happens at the payment provider and in the subscription lifecycle: creating the provider's products and prices from the spec, checkout and the customer portal, and turning a paid subscription into an entitlement through ports the host supplies. The provider-neutral core is this package; the Stripe adapter is in payforge/stripe.

A host that has no provider configured runs in free mode: every account is on the spec's default plan.

Index

Constants

View Source
const (
	// MetaApp is the spec's product identifier, so several products can share
	// one provider account and still be told apart.
	MetaApp = "payforge_app"
	// MetaPlan is the plan an object was created for.
	MetaPlan = "payforge_plan"
	// MetaProduct is the one-time product an object was created for.
	MetaProduct = "payforge_product"
	// MetaSeller is the connected account a sale's proceeds go to.
	MetaSeller = "payforge_seller"
	// MetaSubject is the host's account identifier, on customers and
	// subscriptions.
	MetaSubject = "payforge_subject"
)

Metadata keys written to every object payforge creates at the provider.

View Source
const (
	StatusActive            = "active"
	StatusTrialing          = "trialing"
	StatusPastDue           = "past_due"
	StatusUnpaid            = "unpaid"
	StatusCanceled          = "canceled"
	StatusIncomplete        = "incomplete"
	StatusIncompleteExpired = "incomplete_expired"
	StatusPaused            = "paused"
)

Subscription statuses, as the provider reports them.

Variables

View Source
var (
	// ErrInvalidSubject means the subject has no usable ID.
	ErrInvalidSubject = errors.New("payforge: invalid subject")
	// ErrInvalidURL means a return URL is not an absolute http or https URL.
	ErrInvalidURL = errors.New("payforge: invalid return URL")
	// ErrUnknownPlan means the spec has no such plan.
	ErrUnknownPlan = errors.New("payforge: unknown plan")
	// ErrNotSelfServe means the plan cannot be bought through checkout.
	ErrNotSelfServe = errors.New("payforge: plan is not self-serve")
	// ErrNoPrice means the plan has no price for the interval.
	ErrNoPrice = errors.New("payforge: plan has no price for the interval")
	// ErrUnknownProduct means the spec has no such one-time product.
	ErrUnknownProduct = errors.New("payforge: unknown product")
	// ErrBelowMinimum means a seller-set amount is under the product's minimum.
	ErrBelowMinimum = errors.New("payforge: amount is below the product's minimum")
	// ErrAmountNotAllowed means an amount was given for a product whose price is fixed.
	ErrAmountNotAllowed = errors.New("payforge: product has a fixed price")
	// ErrNotSynced means the provider has no price for a lookup key: the
	// catalog has not been synced to the account.
	ErrNotSynced = errors.New("payforge: price not found at the provider; sync the catalog")
	// ErrAlreadySubscribed means the subject already holds a granting
	// subscription, so a second checkout would bill it twice.
	ErrAlreadySubscribed = errors.New("payforge: subject already has a subscription")
)

Errors a host maps to its own responses.

View Source
var (
	// ErrNoMarketplace means the spec has no marketplace section, so there is no
	// platform fee to take and no sale to make.
	ErrNoMarketplace = errors.New("payforge: the spec has no marketplace")
	// ErrInvalidAccount means a connected account ID is empty or malformed.
	ErrInvalidAccount = errors.New("payforge: invalid connected account")
	// ErrForeignAccount means a connected account was not created by this app, so
	// it is not paid.
	ErrForeignAccount = errors.New("payforge: the connected account does not belong to this app")
	// ErrSellerNotReady means the seller's account cannot take charges yet, so
	// it has not finished onboarding or the provider has paused it.
	ErrSellerNotReady = errors.New("payforge: the seller's account cannot take charges")
)
View Source
var (
	// ErrSignatureInvalid means no configured secret verifies the payload's
	// signature, or the signature is missing, malformed or too old. The caller
	// should answer 400 and not retry.
	ErrSignatureInvalid = errors.New("payforge: webhook signature invalid")
	// ErrPayloadTooLarge means the request body is over the size limit.
	ErrPayloadTooLarge = errors.New("payforge: webhook payload too large")
)

Errors from reading a webhook.

View Source
var ErrNotConfigured = errors.New("payforge: billing is not configured")

ErrNotConfigured is returned by an operation that needs a payment provider when none is configured.

View Source
var ErrNotFound = errors.New("payforge: not found")

ErrNotFound is returned by a Store lookup that finds nothing.

Functions

func CheckStartup

func CheckStartup(spec *pricingspec.Spec, cfg ProviderConfig) error

CheckStartup returns an error when billing is half-configured or the spec cannot be billed, so a mistake stops the host at startup instead of showing as plans that never change. With no key it succeeds: that is free mode.

With a key it requires the spec to be valid, a webhook secret (two when the spec has a marketplace or seller-set products, because connected-account events arrive on their own endpoint), and a plan to sell. Errors name what is missing and never contain a secret.

func Fee

func Fee(amountCents int64, bps int) (int64, error)

Fee returns the platform's share of an amount at a rate in basis points, rounded down to a whole cent, so the seller never receives less than the exact share and the platform never takes more.

func FeeBps

func FeeBps(spec *pricingspec.Spec, sellerPlan string) (int, error)

FeeBps returns the platform fee for a sale by a seller on the given plan, in basis points: the plan's own fee when it sets one, else the marketplace's. It fails with ErrNoMarketplace when the spec has none.

func Grants

func Grants(status string) bool

Grants reports whether a subscription in this status grants its plan.

A subscription grants while it is active or in a trial, and while it is past due: the provider is still retrying the payment, and cutting access during the retries would punish a customer whose card is being fixed. Every other status, and any status this library does not know, does not grant.

func LockKey

func LockKey(app, subject string) string

LockKey is the key a Locker is asked for: one per app and account.

func MetadataPlan

func MetadataPlan(meta map[string]string, legacyPrefixes []string) string

MetadataPlan reads the plan from an object's metadata: payforge's own key first, then each legacy prefix's "<prefix>_plan".

func MetadataSubject

func MetadataSubject(meta map[string]string, legacyPrefixes []string) string

MetadataSubject reads the host's subject from an object's metadata: payforge's own key first, then each legacy prefix's "<prefix>_organization_id".

func ParsePlanLookupKey

func ParsePlanLookupKey(key string) (app, plan string, interval pricingspec.Interval, ok bool)

ParsePlanLookupKey splits a plan price's lookup key. ok is false for any other key, so a price someone else created is never mistaken for one of ours.

func PlanLookupKey

func PlanLookupKey(app, plan string, interval pricingspec.Interval) string

PlanLookupKey is the lookup key of a plan's price for an interval.

func ProductLookupKey

func ProductLookupKey(app, product string) string

ProductLookupKey is the lookup key of a fixed one-time product's price.

Types

type AccountReader

type AccountReader interface {
	Account(ctx context.Context, accountID string) (AccountState, error)
}

AccountReader reads a connected account from the provider.

type AccountState

type AccountState struct {
	ID               string
	ChargesEnabled   bool
	PayoutsEnabled   bool
	DetailsSubmitted bool
}

AccountState is a connected account's capabilities.

type Billing

type Billing interface {
	// Enabled reports whether a payment provider is configured.
	Enabled() bool
	// Plan resolves the plan an account has stored. Without a provider every
	// account is on the spec's default plan; with one, an unknown plan is the
	// default plan too.
	Plan(stored string) pricingspec.Plan
	// CheckoutPlan starts a subscription checkout.
	CheckoutPlan(ctx context.Context, c PlanCheckout) (Session, error)
	// CheckoutProduct starts a one-time purchase checkout.
	CheckoutProduct(ctx context.Context, c ProductCheckout) (Session, error)
	// EnsureCustomer returns the provider's customer for a subject, creating it
	// once. The host may keep the ID, but need not.
	EnsureCustomer(ctx context.Context, s Subject) (string, error)
	// Portal opens the provider's customer portal for a customer.
	Portal(ctx context.Context, customerID, returnURL string) (string, error)
}

Billing is what a host calls. The Stripe adapter implements it; Disabled returns the free-mode implementation.

func Disabled

func Disabled(spec *pricingspec.Spec) Billing

Disabled returns the free-mode Billing for a spec: every account is on the spec's default plan, and nothing that needs a provider works. A host can test Enabled to hide upgrade prompts.

type ChargeDispute

type ChargeDispute struct {
	DisputeID     string
	ChargeID      string
	PaymentIntent string
	AmountCents   int64
	Currency      string
	Reason        string
	// Status is the provider's status. Closed is true once the dispute is
	// decided, and Won or Lost says how; a closed dispute that is neither, such
	// as an inquiry that was closed, took no money.
	Status string
	Closed bool
	Won    bool
	Lost   bool
	// EventID is the provider event that led to this.
	EventID string
}

ChargeDispute is a dispute of a charge: a buyer asked their bank to take a payment back. It names the charge and payment intent, not the buyer, because a dispute carries no metadata of the sale; the host matches it to the purchase or subscription it recorded for that charge.

Open disputes withhold the amount; a host usually suspends what was bought while one is open, restores it when Won, and treats Lost like a full refund. Events can arrive out of order, so a sink keeps a dispute closed once it has seen it closed.

type CheckoutResult

type CheckoutResult struct {
	SessionID string
	// Mode is "subscription", "payment" or "setup".
	Mode string
	// Paid is true when the money has arrived; a checkout with no payment due,
	// such as a trial, also counts.
	Paid           bool
	SubscriptionID string
	PaymentIntent  string
	AmountCents    int64
	Currency       string
	// Reference is the client reference ID the session was created with.
	Reference string
}

CheckoutResult is a finished checkout session.

type Connect

type Connect interface {
	// CreateAccount returns the seller's connected account, creating it once.
	CreateAccount(ctx context.Context, seller Subject, country string) (string, error)
	// OnboardingLink returns a one-use page where the seller gives the provider
	// the details it needs. Call it again for a fresh link when one expires.
	OnboardingLink(ctx context.Context, accountID, refreshURL, returnURL string) (string, error)
	// Account reads a connected account's current state.
	Account(ctx context.Context, accountID string) (AccountState, error)
	// CheckoutSale starts a checkout whose proceeds go to the seller.
	CheckoutSale(ctx context.Context, s Sale) (Session, error)
}

Connect is what a host calls to sell for others. The Stripe adapter implements it.

type DisputeResult

type DisputeResult struct {
	ID            string
	ChargeID      string
	PaymentIntent string
	// AmountCents is the amount disputed, which the provider holds back from the
	// account while the dispute is open.
	AmountCents int64
	Currency    string
	// Reason is the provider's reason code, such as "fraudulent".
	Reason string
	// Status is the provider's status, such as "needs_response", "won" or "lost".
	Status string
}

DisputeResult is a disputed charge.

type DisputeSink

type DisputeSink interface {
	ApplyDispute(ctx context.Context, d ChargeDispute) error
}

DisputeSink is the host's side for disputes. Like EntitlementSink, ApplyDispute may be called more than once for the same event and must leave the same state.

type Entitlement

type Entitlement struct {
	// Subject is the host's account.
	Subject string
	// Plan is the plan the account should be on: the subscribed plan while it is
	// granted, the spec's default plan otherwise.
	Plan string
	// Granted is true when a subscription grants Plan; false means the account
	// is back on the default plan.
	Granted bool
	// SubscribedPlan is the plan of the subscription behind this, even when it
	// no longer grants; empty when the account has none.
	SubscribedPlan string
	// Status is that subscription's status, empty with none.
	Status string
	// ValidUntil is the end of the paid period, zero when the provider did not
	// say. It is information; the status decides whether the plan is granted.
	ValidUntil     time.Time
	SubscriptionID string
	CustomerID     string
	Origin         Origin
	// EventID is the provider event that led to this.
	EventID string
}

Entitlement is what an account should be on, as the provider's subscriptions say now.

type EntitlementSink

type EntitlementSink interface {
	Apply(ctx context.Context, e Entitlement) error
}

EntitlementSink is the host's side: it updates its own record of an account's plan.

Apply may be called more than once for the same event, because an event is recorded as seen only after Apply succeeds and the provider redelivers one that did not get a success response. Each call carries the account's whole current entitlement, not a change, so Apply must set it, and applying the same entitlement twice must leave the same state.

type Event

type Event struct {
	// ID is the provider's event ID, the key for processing an event once.
	ID string
	// Type is the provider's own event type, kept for logs.
	Type string
	Kind EventKind
	// Created is when the provider created the event. It is not the order the
	// provider will deliver events in.
	Created  time.Time
	Livemode bool
	// Account is the connected account the event happened on, empty for the
	// platform's own account.
	Account string

	// App, Subject, Plan and Product come from the object's metadata, or for
	// App and Plan from a price's lookup key when the metadata is missing. Empty
	// when the object carries none; an event for an object with a different App
	// belongs to another product sharing the account.
	App, Subject, Plan, Product string
	CustomerID                  string
	// Metadata is a copy of the object's metadata, so a host can read keys of
	// its own.
	Metadata map[string]string

	Checkout     *CheckoutResult
	Subscription *SubscriptionState
	Invoice      *InvoiceResult
	AccountState *AccountState
	Refund       *RefundResult
	Dispute      *DisputeResult
}

Event is a webhook reduced to the few fields the lifecycle needs. Only one of the detail pointers is set, matching Kind.

type EventKind

type EventKind string

EventKind says what an Event is about.

const (
	// EventCheckoutCompleted is a checkout session that finished. Paid says
	// whether the money arrived, which a delayed payment method confirms later.
	EventCheckoutCompleted EventKind = "checkout_completed"
	// EventCheckoutFailed is a checkout whose delayed payment, such as a bank
	// debit, failed after the session completed. Checkout carries it, with Paid
	// false. A host that granted a one-time purchase on completion revokes it.
	EventCheckoutFailed EventKind = "checkout_failed"
	// EventSubscriptionCreated, EventSubscriptionUpdated and EventSubscriptionEnded
	// carry the subscription's state. An ended subscription never grants.
	EventSubscriptionCreated EventKind = "subscription_created"
	EventSubscriptionUpdated EventKind = "subscription_updated"
	EventSubscriptionEnded   EventKind = "subscription_ended"
	// EventInvoicePaid and EventInvoiceFailed report a renewal's payment.
	EventInvoicePaid   EventKind = "invoice_paid"
	EventInvoiceFailed EventKind = "invoice_failed"
	// EventAccountUpdated is a connected account's state changing.
	EventAccountUpdated EventKind = "account_updated"
	// EventRefunded is a charge refunded, in full or in part.
	EventRefunded EventKind = "refunded"
	// EventDisputeOpened is a buyer disputing a charge with their bank (a
	// chargeback); EventDisputeClosed is the dispute decided. Dispute carries it.
	EventDisputeOpened EventKind = "dispute_opened"
	EventDisputeClosed EventKind = "dispute_closed"
	// EventIgnored is any other event. It has an ID and a Type and nothing else,
	// so a host can acknowledge it and move on.
	EventIgnored EventKind = "ignored"
)

type InvoiceResult

type InvoiceResult struct {
	ID             string
	SubscriptionID string
	AmountCents    int64
	Currency       string
}

InvoiceResult is a renewal's invoice.

type Lifecycle

type Lifecycle struct {
	Spec *pricingspec.Spec
	// Store remembers processed events and maps customers to accounts.
	Store Store
	// Sink receives the account's entitlement.
	Sink EntitlementSink
	// Provider, when set, is read for the customer's subscriptions instead of
	// trusting the event's, so a late or repeated event cannot undo a newer
	// state: after any subscription event the account gets what the provider's
	// subscriptions say now. Without it the event alone decides, and an event that
	// arrives out of order can leave a stale entitlement. Set it in production.
	Provider SubscriptionReader
	// Sales receives refunds of one-time sales. Without it a refund is ignored.
	Sales SaleSink
	// Sellers receives changes to a seller's connected account. Without it an
	// account change is ignored.
	Sellers SellerSink
	// Locker, when set, is held per account around each subscription, refund
	// and account event, from before the provider is read until the event is
	// recorded, so two events for one account handled at once by different
	// processes apply in turn and the later one applies the newer state.
	// Without it Handle does not serialize; set it when webhooks can be handled
	// concurrently.
	Locker Locker
	// Disputes receives disputes of charges. Without it a dispute is ignored.
	Disputes DisputeSink
	// Accounts, when set, is read for a connected account's current state
	// instead of trusting the event's, for the same reason as Provider. Set it
	// in production.
	Accounts AccountReader
	// LegacyPrices maps the ID of a price made before payforge, which has no
	// lookup key, to the plan it grants. It is empty by default, and then a price
	// without a lookup key never grants. Only the prices listed here grant, and
	// only the plan named here: a subscription's metadata is never trusted to name
	// the plan, because anyone who can edit it could name a better one. A plan the
	// spec does not have grants nothing.
	LegacyPrices map[string]string
}

Lifecycle turns webhook events into entitlements. It holds no state of its own: the Store remembers events, and the provider is the source of truth for subscriptions.

func (*Lifecycle) Handle

func (l *Lifecycle) Handle(ctx context.Context, ev Event) (Result, error)

Handle processes one event. It returns an error only when something failed that a retry could fix: the store, the provider or the sink. The host answers such an error with a 5xx so the provider redelivers, and everything else with a 2xx.

An event is recorded as seen only after the sink has applied it, so a failed attempt is retried and a repeated one is a no-op. An event with no ID is an error: the ID is what makes processing once possible.

Without a Locker Handle does not serialize events: two events for the same account handled at the same time can apply in either order, and the later Apply may carry the older state. A host that handles webhooks concurrently sets Locker.

type Locker

type Locker interface {
	Lock(ctx context.Context, key string) (unlock func(), err error)
}

Locker serializes work on one account across all of the host's processes, for example with a database advisory lock. Lock blocks until the lock for key is held or ctx is done, and returns the function that releases it. unlock is called exactly once; if releasing fails, the host's implementation logs it, and must make sure the lock cannot be held forever (a lock tied to a database session or transaction is released when that ends).

The lifecycle holds it around a subscription, refund or account event, and the Stripe adapter around a plan checkout, both under LockKey of the app and the account, so the two also exclude each other.

type MemoryLocker

type MemoryLocker struct {
	// contains filtered or unexported fields
}

MemoryLocker is a Locker for one process, for tests and for a host that runs a single instance.

func (*MemoryLocker) Lock

func (m *MemoryLocker) Lock(ctx context.Context, key string) (func(), error)

Lock implements Locker.

type MemorySink

type MemorySink struct {
	// contains filtered or unexported fields
}

MemorySink is an EntitlementSink, SaleSink, SellerSink and DisputeSink that keeps the latest entitlement for each account in memory, with the history of what it was given.

func NewMemorySink

func NewMemorySink() *MemorySink

NewMemorySink returns an empty MemorySink.

func (*MemorySink) Apply

func (m *MemorySink) Apply(_ context.Context, e Entitlement) error

Apply implements EntitlementSink.

func (*MemorySink) ApplyDispute

func (m *MemorySink) ApplyDispute(_ context.Context, d ChargeDispute) error

ApplyDispute implements DisputeSink. A closed dispute stays closed.

func (*MemorySink) ApplyRefund

func (m *MemorySink) ApplyRefund(_ context.Context, r SaleRefund) error

ApplyRefund implements SaleSink. It keeps the highest refunded total seen for a charge, and a full refund stays full.

func (*MemorySink) ApplySeller

func (m *MemorySink) ApplySeller(_ context.Context, a SellerAccount) error

ApplySeller implements SellerSink.

func (*MemorySink) Dispute

func (m *MemorySink) Dispute(disputeID string) (ChargeDispute, bool)

Dispute returns the latest state applied for a dispute.

func (*MemorySink) Get

func (m *MemorySink) Get(subject string) (Entitlement, bool)

Get returns the latest entitlement applied for an account.

func (*MemorySink) History

func (m *MemorySink) History() []Entitlement

History returns every entitlement applied, oldest first.

func (*MemorySink) Refund

func (m *MemorySink) Refund(chargeID string) (SaleRefund, bool)

Refund returns the state kept for a charge.

func (*MemorySink) Seller

func (m *MemorySink) Seller(accountID string) (SellerAccount, bool)

Seller returns the latest state applied for a connected account.

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is a Store that keeps everything in memory. It is for tests and for a host that does not need persistence across restarts.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore returns an empty MemoryStore.

func (m *MemoryStore) Link(customerID, subject string)

Link records the host's account for a provider customer.

func (*MemoryStore) MarkSeen

func (m *MemoryStore) MarkSeen(_ context.Context, eventID string) error

MarkSeen implements Store.

func (*MemoryStore) Seen

func (m *MemoryStore) Seen(_ context.Context, eventID string) (bool, error)

Seen implements Store.

func (*MemoryStore) SubjectForCustomer

func (m *MemoryStore) SubjectForCustomer(_ context.Context, customerID string) (string, error)

SubjectForCustomer implements Store.

type Origin

type Origin string

Origin says what caused an entitlement change, so a host that sets a plan by hand can decide whether to let the change through.

const OriginSubscription Origin = "subscription"

OriginSubscription is a change that follows the provider's subscriptions.

type Outcome

type Outcome string

Outcome says what Handle did with an event.

const (
	// OutcomeApplied: the sink was told the account's entitlement.
	OutcomeApplied Outcome = "applied"
	// OutcomeDuplicate: the event was already processed; nothing changed.
	OutcomeDuplicate Outcome = "duplicate"
	// OutcomeIgnored: the event is not one the lifecycle acts on.
	OutcomeIgnored Outcome = "ignored"
	// OutcomeSkipped: the event is relevant but cannot be acted on, such as one
	// for another product, from a connected account, or malformed. Retrying it
	// would not change that, so the host should acknowledge it and may log Reason.
	// An event for a customer no account is known for is also skipped, but is not
	// recorded, so it is applied when it is redelivered after the host links the
	// customer.
	OutcomeSkipped Outcome = "skipped"
)

type PlanCheckout

type PlanCheckout struct {
	Subject    Subject
	Plan       string
	Interval   pricingspec.Interval
	SuccessURL string
	CancelURL  string
}

PlanCheckout asks for a subscription to a self-serve plan.

type ProductCheckout

type ProductCheckout struct {
	Subject Subject
	Product string
	// AmountCents is the buyer's or seller's chosen amount, for a seller-set
	// product only; it must reach the product's minimum. Leave it zero for a
	// product with a fixed price.
	AmountCents int64
	// Reference is the host's own identifier for this purchase. Retrying a
	// checkout with the same reference returns the same session, so a double
	// click does not create two.
	Reference  string
	SuccessURL string
	CancelURL  string
}

ProductCheckout asks for a one-time purchase of a product from the spec.

type ProviderConfig

type ProviderConfig struct {
	// SecretKey is the provider's secret API key. Empty means billing is not
	// configured.
	SecretKey string
	// WebhookSecrets are the signing secrets of the webhook endpoints. A
	// marketplace needs a second, for the provider's connected-account events.
	WebhookSecrets []string
}

ProviderConfig is the host's provider configuration. It holds secrets: do not log it. The zero value means no provider, which is free mode.

func (ProviderConfig) Configured

func (c ProviderConfig) Configured() bool

Configured reports whether a provider key is set.

type ProviderPlan

type ProviderPlan struct {
	Products []ProviderProduct
	Prices   []ProviderPrice
}

ProviderPlan is what a provider account should hold for a spec. Seller-set products have no price here: their amount is chosen at checkout.

func ProviderPlanFor

func ProviderPlanFor(spec *pricingspec.Spec) ProviderPlan

ProviderPlanFor computes what a provider account should hold for the spec: a product and a price per interval for every plan with prices, and a product and a price for every fixed one-time product. The order is stable.

type ProviderPrice

type ProviderPrice struct {
	// LookupKey finds the price: "<app>/<plan>/<interval>" for a plan and
	// "<app>/<product>" for a fixed one-time product.
	LookupKey   string
	ProductKey  string
	Currency    string
	AmountCents int64
	// Interval is empty for a one-time price.
	Interval pricingspec.Interval
	Metadata map[string]string
}

ProviderPrice is a price the provider should hold. A provider price is immutable, so a changed amount is a new price that takes over the lookup key.

type ProviderProduct

type ProviderProduct struct {
	// Key identifies the product across syncs: "<app>/<plan>" or
	// "<app>/<product>".
	Key      string
	Name     string
	Metadata map[string]string
}

ProviderProduct is a product the provider should hold.

type RefundResult

type RefundResult struct {
	ChargeID      string
	PaymentIntent string
	// AmountCents is the charge's amount and RefundedCents the total refunded so
	// far. Full is true only when everything has been refunded.
	AmountCents   int64
	RefundedCents int64
	Full          bool
}

RefundResult is a refunded charge.

type Result

type Result struct {
	Outcome Outcome
	// Reason explains an ignored or skipped event.
	Reason string
	// Entitlement is what was applied, for OutcomeApplied.
	Entitlement *Entitlement
	// Refund, Seller and Dispute are what was applied for a refund, an account
	// change or a dispute.
	Refund  *SaleRefund
	Seller  *SellerAccount
	Dispute *ChargeDispute
}

Result is what Handle did.

type Sale

type Sale struct {
	// Buyer is the account that pays.
	Buyer Subject
	// SellerAccount is the seller's connected account ID.
	SellerAccount string
	// SellerPlan is the plan the seller is on, which can set its own fee. An
	// empty or unknown plan uses the marketplace fee. Take it from your own record
	// of the seller: a value from a request would let a seller choose their fee.
	SellerPlan string
	Product    string
	// AmountCents follows ProductCheckout.AmountCents.
	AmountCents int64
	// Reference follows ProductCheckout.Reference.
	Reference  string
	SuccessURL string
	CancelURL  string
}

Sale is a purchase from a seller who has a connected account. The money goes to the seller; the platform keeps its fee.

type SaleRefund

type SaleRefund struct {
	// Subject is the buyer's account. It is empty only when neither the sale nor
	// the customer names one, and such a refund is skipped.
	Subject string
	// Seller is the connected account that was paid, empty for a sale without one.
	Seller  string
	Product string
	// ChargeID and PaymentIntent identify the sale, to match the checkout the host
	// recorded.
	ChargeID, PaymentIntent string
	AmountCents             int64
	RefundedCents           int64
	// Full is true when everything has been refunded.
	Full bool
	// EventID is the provider event that led to this.
	EventID string
}

SaleRefund is a refund of a one-time sale, such as a purchase or a marketplace sale.

RefundedCents is the total refunded so far, not this refund's share, and it only grows. Apply may see events out of order, so a sink keeps the highest total it has seen for a charge and treats Full as permanent. A full refund ends the buyer's access to what was bought; the host keeps the data.

type SaleSink

type SaleSink interface {
	ApplyRefund(ctx context.Context, r SaleRefund) error
}

SaleSink is the host's side for refunds. Like EntitlementSink, ApplyRefund may be called more than once for the same event and must leave the same state.

type SellerAccount

type SellerAccount struct {
	// Subject is the seller's account in the host, from the account's metadata.
	Subject          string
	AccountID        string
	ChargesEnabled   bool
	PayoutsEnabled   bool
	DetailsSubmitted bool
	EventID          string
}

SellerAccount is a seller's connected account as the provider says now. A seller can sell only while ChargesEnabled, and be paid out only while PayoutsEnabled; either can turn off again after onboarding.

type SellerSink

type SellerSink interface {
	ApplySeller(ctx context.Context, a SellerAccount) error
}

SellerSink is the host's side for connected accounts. ApplySeller receives the whole current state, so applying it twice leaves the same state.

type Session

type Session struct {
	ID  string
	URL string
}

Session is a hosted page to send the buyer to.

type Store

type Store interface {
	// SubjectForCustomer finds the host's account for a provider customer, for an
	// event that does not name one. It returns ErrNotFound when there is none.
	SubjectForCustomer(ctx context.Context, customerID string) (string, error)
	// Seen reports whether an event was already processed.
	Seen(ctx context.Context, eventID string) (bool, error)
	// MarkSeen records that an event was processed.
	MarkSeen(ctx context.Context, eventID string) error
}

Store is the host's persistence for the lifecycle.

type Subject

type Subject struct {
	// ID is the host's identifier. It is kept on the provider's customer so
	// events can be matched to the account.
	ID    string
	Name  string
	Email string
}

Subject is the host's account that pays: an organization or a person.

type SubscriptionItem

type SubscriptionItem struct {
	PriceID string
	// LookupKey is the price's lookup key, which names the plan and interval
	// for a price payforge synced.
	LookupKey string
}

SubscriptionItem is one line of a subscription.

type SubscriptionReader

type SubscriptionReader interface {
	// Subscriptions returns the customer's subscriptions that have not ended.
	Subscriptions(ctx context.Context, customerID string) ([]SubscriptionState, error)
}

SubscriptionReader reads subscriptions from the provider. With one, the lifecycle decides from the provider's current state instead of the event's, which makes the order events arrive in irrelevant.

type SubscriptionState

type SubscriptionState struct {
	ID     string
	Status string
	// Items are every line of the subscription, not only the first.
	Items []SubscriptionItem
	// PeriodEnd is the end of the paid period; zero when the event did not say.
	PeriodEnd         time.Time
	CancelAtPeriodEnd bool
	TrialEnd          time.Time
	// Metadata is a copy of the subscription's metadata, which names the plan
	// when its price carries no lookup key.
	Metadata map[string]string
}

SubscriptionState is a subscription as of an event.

Directories

Path Synopsis
examples
catalog command
Command catalog loads a pricing spec and shows what syncing it to Stripe would do, or does it.
Command catalog loads a pricing spec and shows what syncing it to Stripe would do, or does it.
limits
Package limits shows how a host enforces a plan's limits: ask the spec, and act on the answer.
Package limits shows how a host enforces a plan's limits: ask the spec, and act on the answer.
pricingpage command
Command pricingpage serves a pricing page drawn from a pricing spec, so the page and the billing cannot disagree about what a plan costs or includes.
Command pricingpage serves a pricing page drawn from a pricing spec, so the page and the billing cannot disagree about what a plan costs or includes.
webhook
Package webhook shows a Stripe webhook endpoint: verify the request, hand the event to the lifecycle, and answer so Stripe retries only what a retry can fix.
Package webhook shows a Stripe webhook endpoint: verify the request, hand the event to the lifecycle, and answer so Stripe retries only what a retry can fix.
Package payforgetest holds conformance suites for the ports a host implements: run them against your Store and EntitlementSink to check they behave the way the lifecycle relies on.
Package payforgetest holds conformance suites for the ports a host implements: run them against your Store and EntitlementSink to check they behave the way the lifecycle relies on.
Package stripe is payforge's Stripe adapter.
Package stripe is payforge's Stripe adapter.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL