v0.1.0¶
Release Date: 2026-10-28
The first release: 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, verifies webhooks, and turns a paid subscription into an entitlement through ports the host supplies. Stripe is the provider today; the core knows nothing about it.
Highlights¶
- The spec is the source. payforge reads plans, prices, one-time products
and the marketplace fee from the pricing spec and keeps nothing of its own
beside it.
ProviderPlanForcomputes what a provider account should hold, found again by stable lookup keys and tagged withpayforge_appso several products can share one account. - Safe to start, safe with no provider.
CheckStartupstops a host whose billing is half configured: a key without a webhook secret, a secret without a key, a marketplace without its second secret, or nothing to sell. Errors name what is missing and never contain a secret. With no key,Disabledputs every account on the spec's default plan. - A Stripe adapter. Customers keyed to the host's subject, plan and one-time checkout, customer portal sessions, Connect Express sellers with onboarding links and destination-charge sales with a platform fee, and catalog sync with a dry run. See the catalog, checkout and marketplaces.
- Verified webhooks. Signatures are checked against several secrets, so a
platform and a Connect endpoint can share a handler, and events whose mode
does not match
Webhook.Modeare ignored. What is left is reduced to a small normalized event. See webhooks. - Order does not matter, with a provider and a locker. The lifecycle is
idempotent by event. With
Lifecycle.Providerset it decides an account's entitlement from what the provider holds now, not from the order webhooks arrived in; without it the event alone decides, and a late event can leave a stale entitlement. WithLifecycle.Lockerset it serializes work per account; without it two events handled at once can apply the older state last. Refunds, seller account changes and disputes reach sinks the host supplies. A failed delayed payment (EventCheckoutFailed) is ignored by the lifecycle: the host handles it, revoking a one-time purchase it granted on completion, or grants only onceCheckout.Paidis true. See the lifecycle. - Check your own ports. In-memory stores, sinks and a locker for tests,
and conformance suites in
payforgetestto run against your own.
What is in it¶
| Part | Contents |
|---|---|
payforge |
The provider-neutral core: Billing, Connect, CheckStartup, Disabled, ProviderPlanFor, the normalized event, Lifecycle, the ports and in-memory versions of them |
payforge/stripe |
The Stripe adapter: Client (customers, checkout, portal, Connect, sync, subscription reads), Webhook, KeyMode |
payforge/payforgetest |
Conformance suites for stores, entitlement sinks, sale sinks and seller sinks |
examples/ |
A catalog sync, a pricing page, a webhook endpoint and limit checks, each run by a test without a Stripe account |
docs/ |
A user guide: the catalog, checkout and the portal, webhooks, the lifecycle, marketplaces, known limitations |
if err := payforge.CheckStartup(spec, payforge.ProviderConfig{
SecretKey: key, WebhookSecrets: []string{endpointSecret},
}); err != nil {
return err
}
// With a key; without one, use payforge.Disabled(spec) instead.
billing, err := payforgestripe.New(spec, payforgestripe.Config{
SecretKey: key,
AllowedURLHosts: []string{"app.example.com"},
})
if err != nil {
return err
}
report, err := billing.Sync(ctx, true) // dry run
hook := payforgestripe.Webhook{Mode: billing.Mode(), Secrets: secrets}
ev, err := hook.Read(r)
if err != nil {
return err // answer 4xx or 5xx: see examples/webhook
}
res, err := life.Handle(ctx, ev) // applied, duplicate, ignored or skipped
Rules the runtime enforces¶
- A subscription grants while it is
active,trialingorpast_due, so a plan is kept while a payment is retried. A price the spec does not have never grants. - Sync never deletes and never edits an amount: a changed amount creates a new price, moves the lookup key to it and leaves the old price for its subscribers.
- A subject that already has a granting subscription cannot start another plan checkout, and a new plan checkout expires the subject's other open ones.
- Idempotency keys cover every parameter Stripe compares, so a changed request is never refused or answered with an earlier response.
- Events from a seller's own Stripe account never change entitlements, and a sale refuses a seller this app did not create or that cannot take charges.
Known limitations¶
The full list is in Known limitations. The ones to know before adopting this release:
- Not yet run against real Stripe. Every test uses a stand-in for the
Stripe API or hand-written payloads. An opt-in live test against Stripe's
test mode exists (
stripe/live_test.go) but has not been run yet; see running the live test for the behavior it is meant to confirm. - Keyless legacy prices only through
LegacyPrices. A price without a lookup key grants nothing unless the host lists its ID, with the plan it is for, inLifecycle.LegacyPrices. Metadata never names a plan. See the catalog. - A redirect host allowlist is required. Checkout success and cancel URLs,
the portal return URL and onboarding links must be https and name a host in
stripe.Config.AllowedURLHosts. With a test-mode key,localhost,127.0.0.1and::1are also accepted, over http too, for local development; with a live key they are refused, and a live client whose list names one is refused when it is made. A client used only to sync the catalog may leave it empty. See checkout.
Installation¶
Requires Go 1.26. It depends on systemspec-pricingpackaging v0.1.0 and
stripe-go v87.
Upgrade notes¶
None: this is the first release.