The Payment Provider Contract
🚨 Card payments are an optional module. The commerce content binds an abstraction the platform
always ships — IPaymentProvider —
and a portal with no payments module mounted must compile, install and run, with the payment
features absent or inert rather than broken.
The same shape as IEmailSender, and for the same reason: a provider-neutral contract in
MeshWeaver.Mesh.Contract, the concrete implementation in a module that a deployment may or may not
mount.
Why this is not a style preference
A NodeType's C# is stored as data and compiled by the mesh at runtime
(NodeType Compilation) against the portal image's reference set plus the
module assemblies that deployment actually mounts. A using of a module namespace in that source
is therefore not a compile-time dependency the build can check — it is a runtime requirement on
every consumer's module set, and dotnet build is blind to it.
When the Store's Store/Catalog, Store/Order, Store/Plugin and Store/Maintenance sources took
using MeshWeaver.Payments.Stripe;, every deployment that did not mount that module got both halves
of the failure at once:
Prebuilt assembly for Store/Catalog DECLINED: dependency record mismatch —
'MeshWeaver.Payments.Stripe' built against mvid:ac387a93…, live is absent — compiling instead
Compilation failed for 'Store/Catalog': CS0234 — the type or namespace name 'Payments'
does not exist in the namespace 'MeshWeaver'
The prebuilt assembly is declined and the local fallback compile fails, because the namespace
genuinely is not there. The fallback exists to survive a version skew; it cannot conjure an absent
assembly. Every instance of those node types then reads back with an empty content, so pages that
have nothing to do with money render blank.
Nothing in a deployment's own configuration can fix that. Its only escape is to mount the payment module — which is the opposite of optional, and makes an outbound-call surface to a card processor mandatory on portals that sell nothing.
The contract
MeshWeaver.Mesh.Contract/Payments/ carries the whole seam, and nothing in it names a processor:
| Member | What it answers |
|---|---|
Sells |
does this portal take money at all? |
Unavailable |
the reason a payment cannot be taken right now, in the viewer's language — null when one can |
CreateCheckout(PaymentCheckoutRequest) |
open a hosted checkout; Recurrence non-null makes it recurring |
CancelAtPeriodEnd(subscriptionId) |
stop renewing at the end of the paid period, never immediately |
OffersBillingPortal / OpenBillingPortal(PaymentBillingPortalRequest) |
can the subscriber be sent to the processor's own hosted billing page (card, invoices, cancel) — and where; defaults to no portal, so an older provider still compiles |
ReadDelivery(signature, body, now) |
verify a webhook delivery and parse it, in one answer |
DescribeDeliveryPath(hookUrl) |
can the processor actually reach this portal? |
ManagesDeliveryEndpoints / EnsureDeliveryEndpoint(PaymentEndpointRequest) / RemoveDeliveryEndpoint(PaymentEndpointRequest) |
may this portal register, extend or remove its OWN delivery endpoint at the processor — the governed write behind a broken delivery path; defaults to not supported, so an older provider still compiles |
SecretSettingName / WebhookSecretSettingName / DeliverySignatureHeader |
the names an operator finding has to quote |
PaymentMetadata holds the keys a checkout stamps and the delivery reader looks for
(orderPath, pluginPath, buyer, couponCode, planTier, cadence, and for setups and
deferred renewals claimReference, setupReference, termsHash, intendedUse, siteId). They are on the wire
at the processor: a session created before a rename hands the old key back weeks later, so they are
durable contract exactly like a configuration key — and they live in the platform because the
mesh-compiled content that stamps them must bind something present in every image.
PaymentSettings.BaseUrlConfig (Commerce:BaseUrl) is the portal's own public URL. It is
provider-neutral on purpose: a deployment states it once, and swapping providers does not restate
it.
What a subscription delivery can say
PaymentSubscriptionEvent.Change is one of Renewed (a period was paid — the plan is topped up),
Ended (the processor stopped billing — the plan ends) and PaymentFailed (a charge failed — the
plan goes past-due while the processor retries; the next Renewed clears it, and a processor that
gives up reports Ended). A delivery also carries what makes those facts safe to act on more than
once: InvoiceId, and PaidThrough — the end of the period the invoice paid for. A renewal
applied as "paid through T" is the same answer however often the same payment is delivered, where
"one more period from now" grants a second period for every duplicate
(Plugins#2405). CustomerId on both
event kinds is the handle a billing portal is opened for.
A cancellation made on the processor's own page ends the plan the same way: the processor stops
billing at the period end, and the ordinary Ended delivery ends the plan — the same rule
CancelAtPeriodEnd follows, so the portal never revokes access on a guess.
A requested cancellation is reported when it is requested
Between the request and the ending lies a whole paid period, and during it the plan must say
"ends on …", not "renews on …" — wherever the subscriber asked
(Plugins#3548). So a subscription
delivery also carries Renewal, an open vocabulary (PaymentSubscriptionRenewal):
CancelsAtPeriodEnd (the subscription will not renew) or Renews (a requested cancellation was
withdrawn). It rides beside Change, which stays None on such a delivery — the period and the
entitlement do not change, only what the plan says. The enum is deliberately not widened (policy
open-vocabulary-string-constants).
Two rules make it safe to act on:
- Only a change is reported. A processor sends "the subscription was updated" for many reasons,
each carrying the current flag. A provider sets
Renewalonly when the delivery says the flag itself changed, so an unrelated update never touches the plan. OccurredAtorders it. It is when the processor says the change happened. The caller applies a renewal-state change only when it is newer than the last one it applied, so a redelivery is a no-op and a delayed older delivery cannot undo a newer request.
ChangesRenewal counts towards IsActionable (and so NamesWork): a delivery reporting a request is
retained like any other, and an unknown word is retained so the caller can log it by name — it is
never read as either known word.
For Stripe this is the customer.subscription.updated event, which the provider adds to the events
its endpoint must carry; an existing endpoint gains it through the governed EnsurePaymentEndpoint
run (below), never by hand.
Verify and parse come back together
ReadDelivery returns a PaymentDelivery carrying both Authentic and what the delivery is
about. That is not a convenience — it is the only shape in which a caller can say "this did not
authenticate AND it names a paid order", which is the distinction that keeps someone paid and was
not served from looking exactly like the processor never called
(Plugins#1069). An unauthenticated
delivery that names consequential work is retained as evidence and replayed once the secret is
fixed; junk is discarded so nobody can fill the container and drown the signal.
A read that failed is not an empty account
DescribeDeliveryPath never faults. An unreachable processor, a refused credential, a truncated
listing and an unparseable body all produce "nothing found", and collapsing them into "there is no
endpoint" is what left a portal taking money and fulfilling nothing for days
(Plugins#1109). So ReadProblem and
Truncated are carried separately from Endpoints, and a caller that cannot tell must say so
rather than report health.
Registering the delivery endpoint is a governed write
DescribeDeliveryPath only READS. The write that repairs what it finds is a separate pair of
members, called only from a governed task a global admin triggers (policy
payment-endpoint-governed-task; the Store's EnsurePaymentEndpoint /
DeregisterPaymentEndpoint maintenance tasks, MeshWeaver.Plugins#1881):
- The request carries no free values.
PaymentEndpointRequest.Urlis this instance's own hook URL andEventsthe provider's own required set (an immutable array); the caller composes both from configuration, never from a field typed on a node.OwnerandRequestPathgo into the endpoint's metadata, which is what later lets a run recognise its own endpoint and whatRemoveDeliveryEndpointrestricts itself to.Executefalse (the default) is a dry run;ConfirmedModemust repeat the account mode for an executed run;IdempotencyKeyis recorded on the request node BEFORE the create is sent, so a retried run cannot create a second endpoint. - The answer is an outcome, never a fault.
PaymentEndpointChange.Outcomeis an OPEN vocabulary (PaymentEndpointOutcome:WouldCreate/Created,WouldAddEvents/EventsAdded,AlreadyComplete,SecretNotRecovered,WouldRemove/Removed/NothingToRemove,Refused,Indeterminate,NotSupported); a caller reports a word it does not know verbatim and as a failure, never as a known one.Refusedmeans nothing was written — a failed precondition, or a call that failed before any write was sent. A write call that was sent and got no answer (a timeout, a dropped connection, a 5xx) isIndeterminate: the provider may have applied it, so the caller never reports "nothing written" for it, and reconciles by running again — a dry run reads the account's listing, which is the truth, and the recordedIdempotencyKeymakes a retried create safe.Refusalis aLocalizableText: the two defaults carry their own catalog keys (payments.endpoint.ensureNotSupported,payments.endpoint.removeNotSupported), each naming its own manual remedy. - Additive only. An ensure never removes an event, changes a URL, or disables or re-enables an endpoint; a processor that replaces an endpoint's event list on update is sent the current events plus the missing ones. A removal deletes only endpoints whose metadata names this instance.
- The secret crosses ONE boundary, once. Only
Createddiscloses a signing secret (DisclosedSecret). It is[JsonIgnore]— absent from every serialized form — and masked inToString. The caller shows it once to the person who ran the task and writes it nowhere: not to a node, an activity, a log line or an exception message. Whoever holds it can forge a completed checkout.
Saving a method without a payment, and a consented renewal
IPaymentMethodSetupProvider is a second, optional capability beside IPaymentProvider. It
saves a payment method without taking a payment, and schedules a renewal that charges nothing today
only when the buyer separately consents to it. The contract contains no provider call, no processor
name and no configuration. The first consumer is the coupon journey in MeshWeaver.Plugins (#2063),
and the first implementation will be the Stripe module (#2064).
Why a second interface and not a zero-amount checkout. CreateCheckout models an amount and an
optional recurrence, and its completed delivery fulfils an order. Expressing "save only" as an amount
of zero would make a finished setup indistinguishable from a paid purchase. A separate interface also
keeps the capability optional twice over: a portal can mount a provider that sells and cannot save
methods, and every existing provider keeps compiling untouched. hub.PaymentMethodSetupProvider()
returns null in that case, and the caller says this site cannot save a payment method. It never
falls back to charging and never waives a setup that an offer requires.
| Member | What it answers |
|---|---|
SetupUnavailable |
why a method cannot be saved here now, in the viewer's language (null when it can) |
DescribeSetup(intendedUse, currency) |
per method kind, whether a saved method is reusable for that use on this account |
CreateSetup(PaymentMethodSetupRequest) |
open a setup session: never a charge, an invoice or a subscription |
RetrieveSetup(setupId) / CancelSetup(setupId) |
reconcile a setup from the processor; cancel an unfinished one |
RemovesPaymentMethods / RemovePaymentMethod(customer, method) |
detach a saved method. Defaults to not supported |
SupportsDeferredSubscription / CreateDeferredSubscription(DeferredSubscriptionRequest) |
schedule one renewal with zero due today. Defaults to not supported |
RetrieveDeferredSubscription(id, claim) / CancelDeferredSubscription(id) |
reconcile it; turn off its future billing |
Two consents, never one
PaymentMethodSetupRequest carries a required storage consent and an optional, separate
renewal consent (PaymentConsentReference: kind, text version, text hash, accepted-at, terms
hash). Its IntendedUse is CustomerPresentPurchaseOnly or AuthorizedRenewal (an open
vocabulary). AuthorizesOffSession is the one predicate a provider uses to decide whether to ask its
processor for permission to charge in the buyer's absence. It is true only when the use is
AuthorizedRenewal and a consent of kind RecurringRenewal travels with the request.
Problems() checks the pairing in both directions:
- a renewal use without a renewal consent is refused;
- a renewal consent on a customer-present setup is refused, so a changed checkbox cannot quietly upgrade an existing setup;
- a renewal consent given against different terms is refused.
A storage consent offered as the renewal consent authorizes nothing.
A setup request has no amount field. It cannot express a charge.
Wallet reuse is purpose-specific
DescribeSetup answers per kind (Card, ApplePay, GooglePay, or a provider's own word) with
Reusable, FreshAuthorizationEachPurchase, Unsupported or Unverified. A wallet that can pay is
not thereby a stored method for a customer-present purchase. A kind the provider did not list reads
as Unverified, and unverified is never treated as reusable. This is the server's half of the
answer. Whether a wallet shows on the buyer's device is for the provider's own in-page controls to
establish, never a guess from the operating system.
The client secret crosses one boundary
PaymentMethodSetupSession.ClientSecret is for the intended buyer's page only. Like
PaymentEndpointChange.DisclosedSecret, it is [JsonIgnore] and masked in ToString. It is never
written to a node, an activity, a log line, a URL, a cache or browser storage.
ClientPublicKey is public by nature and is serialized.
A verified fact must be this request's own
PaymentMethodSetupFact comes from an authenticated delivery (PaymentDelivery.Setup) or from
RetrieveSetup, never from a browser return. It is not a payment and not an entitlement.
Satisfies(request, session) holds only when the status is Succeeded, the fact names the saved
method (PaymentMethodId), and nothing in Mismatches(…) differs. The fields compared are the
setup id, buyer, the caller's reference, claim, site, terms hash, intended use, customer, account
mode, and off-session permission, which must equal exactly what the request authorized. Permission the request never asked for is a mismatch, not a
bonus.
A finished setup is reported on PaymentDelivery.Setup and never on Checkout, so it cannot
reach paid-order fulfilment. A setup or deferred-subscription delivery that names its claim
NamesWork. If it does not authenticate, it is retained as evidence, the same rule as for a paid
order.
Zero due today, proven
DeferredSubscriptionRequest carries exact, immutable terms: claim, buyer, verified setup,
customer, method, plan, cadence, currency, the tax-inclusive recurring total, the exact
FirstChargeAt instant, the renewal consent, the terms hash and one idempotency key. There is no
field through which a caller could ask for a charge now. DueToday is the constant zero. A provider
refuses a request with Problems(now), a setup that did not authorize off-session use, and any
account state that would make today's amount positive or change the accepted total. Every field a
fact is later matched on must be non-blank in the request: a blank one is stamped and then read
back as absent, so the created object could never satisfy its own request. A consent counts only
with its kind, text version, text hash and an acceptance instant. It never omits
the first-charge instant as a fallback and never charges and refunds.
DeferredSubscriptionFact.Satisfies(request) holds only when the status is Scheduled, a
subscription id is named, every term matches exactly (the stamped setup reference included, so a
subscription created from another setup never stands for this one), and zero-due is proven from the
processor's own numbers: InitialInvoiceAmountDue == 0 and AmountCharged == 0. An amount the
processor did not report is not a zero.
The answer is an outcome, never a fault (open vocabulary DeferredSubscriptionStatus). Refused
means nothing was created. Indeterminate means a write was sent and got no answer, so the caller
reconciles by reading (RetrieveDeferredSubscription) and never by creating again. Only
Cancelled means future billing is off. Compensation for a claim that failed after the renewal was
scheduled stays open, and visible to an operator, until that word is read.
A zero invoice is not a paid period
A trial-style subscription normally produces an initial invoice of zero. That invoice is reported on
PaymentDelivery.DeferredSubscription and never as PaymentSubscriptionChange.Renewed. As a second
guard, PaymentSubscriptionEvent.AmountPaid carries what the invoice collected. IsPaidRenewal is
false for a renewal that states it collected zero, and true for a positive amount or an amount the
provider did not report (a provider written before the field existed keeps its meaning). Paid-period
accounting starts with the first verified paid renewal and never with the existence of a
subscription.
Who owns what
| Concern | Owner |
|---|---|
| Signature verification of a setup or renewal delivery | the provider, in ReadDelivery (Authentic beside Setup / DeferredSubscription) |
| Matching a fact to the caller's own request | the caller, through Satisfies / Mismatches |
| Replay | the caller: one outcome per provider EventId; a duplicate or late event converges, and an old setup event arriving after a cancellation cannot re-enable a renewal |
| Reconciliation after a missed delivery, a return that arrived first, or a restart | the caller, through RetrieveSetup / RetrieveDeferredSubscription |
| Cancellation and compensation | the caller decides; the provider's CancelDeferredSubscription answer is the evidence |
| Provider failure | an outcome (Refused, Indeterminate) or a viewer-language error, never a silent success |
The new metadata keys (claimReference, setupReference, termsHash, intendedUse, siteId) are
on the wire at the processor, like the existing ones. StampedMetadata() on both requests is the
one composition, so the side that stamps and the side that reads months later cannot drift.
Absence is a modelled state
hub.PaymentProvider() returns IPaymentProvider?. The null is the design: it makes "no
payments module mounted" a state a caller answers in code, instead of a missing type a caller cannot
compile against.
🚨 These three are forbidden, and each was ruled out explicitly:
- a
try/catcharound a payment type — the failure is at compile time, not at call time, so there is nothing to catch; - a reflection probe for the module assembly — it re-creates the runtime coupling the abstraction removes, and answers differently on a deployment that mounts the module later;
- a
nullprovider held and dereferenced at first use — that is not degradation, it is aNullReferenceExceptionon the payment path, which is worse than the compile error it replaced.
The correct shape is the same everywhere: resolve, ask, and have a sentence ready for "no provider".
// The reason this portal cannot take a payment, or null when it can.
public static string? PaymentsUnavailable(IMessageHub hub) =>
hub.PaymentProvider() is { } provider
? provider.Unavailable
: Texts(hub).NoPaymentProvider;
A portal with no provider therefore refuses a checkout with a sentence, reports its payment path as out of scope rather than healthy or broken, and leaves an unverifiable delivery in place instead of discarding it — all states the commerce surfaces already had for an unconfigured provider.
Everything reactive, everything cold
Nothing on the contract returns a Task. Every method returns IObservable<T> and the side effect
runs on Subscribe, never on the call (Asynchronous Calls). An
implementation bridges its HTTP leaf through the mesh's bounded IIoPool
(Controlled IO Pooling) and never Observable.FromAsync, which would run
the prologue on the subscribing thread and bound nothing.
Landing an abstraction like this across the fleet
🚨 The contract has to be IN THE IMAGE before any consumer can bind it, and a dependent repo's
compile gate proves that against the image rather than against core's source. MeshWeaver.Plugins'
Compile every NodeType (vs core) takes its whole reference set from the pinned platform image —
- name: Take the framework assemblies from the platform image
run: docker cp "$id:/app/." refs/ # MW_IMAGE_DIGEST
— so MW_PLATFORM_REF (which core commit is built and tested) does not move it. A pinned digest is
immutable and no CD run changes it. The consequence is a strict order, and skipping a step reads as a
defect in the content rather than as a missing pin:
- the contract merges to core
main; - core's Continuous Delivery seals a platform set that contains it;
- the dependent repo moves its pins as one gate —
MW_PLATFORM_SET,MW_IMAGE_DIGEST,MW_PORTAL_IMAGE_DIGEST,MW_PLATFORM_REF, everyuses:ref and everyplatform-image-digest:/tester-image-digest:literal (check-platform-pins.py --check-tagsis what refuses a half-move); - only then does the content that binds the contract compile there.
Between (1) and (3) the dependent's PR is structurally red, which is the mechanism working. Hold it as a draft — draft is the opt-out from auto-arm, so it cannot enqueue while a required gate cannot pass — and say in the body which of the four steps it is waiting on.
The verdict that can be had immediately is the one that matters most, and it needs no image at all:
compile the dependent's node types against a core-only reference set
(compile-check.py --refs <core>/src). That set is precisely "a mesh with no optional modules", so
it answers the acceptance question — does this content compile where the module is absent? — before
any pin moves.
Adding a second provider
Implement IPaymentProvider in a new module assembly, register it as a singleton from the module's
MeshNodeProviderAttribute, and ship it as its own package. Nothing in the commerce content
changes, and no deployment that does not mount it is affected — which is the property this contract
exists to buy.
The one thing a new provider owns beyond the calls is its vocabulary: the events its delivery
endpoint must be subscribed to, the word it uses for an enabled endpoint, and the remedy an operator
follows. Those ride on PaymentDeliveryPath rather than being hard-coded by the caller, so an
operator health board written once reads correctly for any provider.