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:

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):

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 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:

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:

  1. the contract merges to core main;
  2. core's Continuous Delivery seals a platform set that contains it;
  3. the dependent repo moves its pins as one gate — MW_PLATFORM_SET, MW_IMAGE_DIGEST, MW_PORTAL_IMAGE_DIGEST, MW_PLATFORM_REF, every uses: ref and every platform-image-digest: / tester-image-digest: literal (check-platform-pins.py --check-tags is what refuses a half-move);
  4. 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.