Meridian handbook
Destination platform (engine-dmo)

Paid listings

How to sell membership tiers and promotions: prices in config/paidlistings.yaml, Stripe Checkout or staff invoices, the portal's billing screen, the Stripe webhook, orders and their statuses, and how a paid order grants, renews, refunds and lapses a membership or a promotion.

engine-dmo

This feature is part of the DMO bundle (engine-dmo).

Paid listings takes payment for what a site sells its partners: a membership tier for a period, and promotions in its priced slots. A partner pays in the portal through the provider’s hosted checkout (Stripe Checkout is the one provider), or staff raise an invoice and record its payment. Either way the result is an order, and a paid order is fulfilled: a tier order makes the organisation’s membership active at that tier until the end of the period it bought, and a promotion order approves the promotion. A refund takes it back, and a tier order’s membership lapses when its period ends.

Without config/paidlistings.yaml, or with provider: none, nothing is sold in the portal; staff can still raise invoices for priced promotions.

Configuration

config/paidlistings.yaml
provider: stripe
stripe:
  secret_key: ${STRIPE_SECRET_KEY}
  webhook_secret: ${STRIPE_WEBHOOK_SECRET}
sites:
  main:
    promotions: checkout
    tiers:
      standard: { amount: "120.00", currency: GBP, months: 12 }
      gold: { amount: "300.00", months: 12 }
KeyDefaultMeaning
providernonestripe takes payment through Stripe Checkout; none keeps orders to staff invoices
stripe.secret_keyRequired for stripe. Must be a ${VAR} interpolation; a literal key is refused.
stripe.webhook_secretRequired for stripe. The webhook endpoint’s signing secret, as a ${VAR}.
stripe.api_basehttps://api.stripe.comOnly for a test double: an https address, or http on a loopback address
sites.<site>.tiers.<tier>.amountThe price, a quoted decimal such as "120.00"
sites.<site>.tiers.<tier>.currencythe site’s default_currencyA three-letter code
sites.<site>.tiers.<tier>.months12The period bought, 1 to 36 months
sites.<site>.promotionsinvoicecheckout: a priced promotion request is paid in the portal. invoice: staff invoice it. checkout needs a provider.

A tier on sale must be declared in config/memberships.yaml and sold on the site (memberships.tiers in site.yaml). A promotion’s price comes from its slot in promotions.yaml (see Promotions), never from this file.

engine-dmo check validates the file without needing the secrets; the server reads the two variables at start and refuses to start when either is unset. A key beginning sk_live_ or rk_live_ is live mode; any other is test mode.

Setting up Stripe

Create a restricted or secret API key in the Stripe dashboard and set it as STRIPE_SECRET_KEY in the engine’s environment.

Add a webhook endpoint in Stripe at https://<site host>/paidlistings/webhook/stripe, listening for checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired and charge.refunded. Set its signing secret as STRIPE_WEBHOOK_SECRET.

Write config/paidlistings.yaml, run engine-dmo check, and restart the server.

The engine creates one Checkout Session in payment mode per order, for one line at the order’s amount, naming the order as the client reference and in the payment’s metadata. It never sees card details.

The webhook

POST /paidlistings/webhook/stripe checks the Stripe-Signature header and its age (at most 5 minutes) before reading the body, refuses an event it has already applied (event ids are kept 90 days), holds a payment’s amount to the order’s, and then fulfils the order. A delivery that fails its signature counts against its address’s allowance and is answered 429 past it; one that verifies is never limited.

Stripe eventEffect
checkout.session.completed with payment status paid, checkout.session.async_payment_succeededThe order is paid and fulfilled
checkout.session.expired, checkout.session.async_payment_failedThe open order is cancelled
charge.refundedWhen the charge is refunded in full, the order is refunded and what it granted is taken back. A partial refund changes nothing; record it from the Orders screen if it should.

The billing screen

The portal’s billing screen, {portal path}/billing/{org}, is for people whose partner role grants billing.pay (the built-in billing and owner roles). It shows:

  • the organisation’s membership on the site;
  • with a provider, each tier on sale with its price and period;
  • on a site with promotions: checkout, its promotion requests waiting for payment, priced from their slot;
  • its orders and their statuses.

Each offer is a plain form that posts to {portal path}/billing/{org}/checkout, which records the order at the price in configuration (the form only names the offer) and answers 303 to the provider’s checkout page. It works without JavaScript. The screen renders the portal template billing.html, which engine-dmo portal scaffold --site <site> copies for restyling.

A promotion request on a checkout site is requested until the partner starts checkout, then awaiting_payment while its order is open; payment approves it, with the slot’s capacity checked again. If the order is cancelled or abandoned, the promotion goes back to requested.

Orders

PropertyMeaning
Kindtier or promotion
Methodcheckout (paid through the provider) or invoice (raised and recorded by staff)
Amount, currency, descriptionFixed when the order is made
ReferenceThe order’s reference, written on the membership or promotion it fulfils
StatusMeaningMoves to
openMade, not yet paidpaid, cancelled
cancelledAbandoned or cancelledpaid (a late payment)
paidPayment received, not yet fulfilledfulfilled, failed, refunded
failedFulfilment could not go ahead (for example the slot filled meanwhile); staff retry or refundfulfilled, refunded
fulfilledThe tier or promotion was grantedrefunded, lapsed
lapsedA tier order’s period endedrefunded
refundedRefunded; what it granted was taken back
stateDiagram-v2
  [*] --> open : checkout started or invoice raised
  open --> paid : payment received
  open --> cancelled : abandoned or cancelled
  cancelled --> paid : a late payment
  paid --> fulfilled : tier or promotion granted
  paid --> failed : fulfilment could not go ahead
  paid --> refunded
  failed --> fulfilled : staff retry
  failed --> refunded
  fulfilled --> lapsed : a tier order's period ends
  fulfilled --> refunded
  lapsed --> refunded
  refunded --> [*]

What fulfilment does

OrderFulfilledRefundedPeriod ends
TierThe organisation’s membership on the site becomes active at the tier, renewing on the period’s end. With no current membership one is created, joined today. A renewal of an active membership at the same tier runs on from its current renewal date. The membership records the amount, currency and order reference.The membership lapses, if it still carries this order’s referenceThe membership lapses, unless a later order or staff renewed it under another reference
PromotionThe promotion is approved (capacity checked again with the slot locked)The promotion is cancelled with the reason the business reads

Paid orders whose fulfilment was interrupted are retried every minute; ended periods are lapsed every hour, by the site’s timezone.

Staff

Staff with paidlistings.manage on the site (the built-in administrator and partnership-manager roles) use the Orders screen under Members to:

  • raise an invoice for a tier on sale or for a promotion waiting for payment;
  • record an invoice’s payment, which fulfils it;
  • cancel an open order;
  • retry a failed fulfilment;
  • record a refund made in Stripe or in the finance system.

Troubleshooting

MessageFix
stripe.secret_key is a secret and must be an environment interpolation, not a literalWrite secret_key: ${STRIPE_SECRET_KEY}.
provider "..." is not one of stripe, noneUse stripe or none.
promotions: checkout needs a payment providerSet provider: stripe, or promotions: invoice.
site <site> does not sell tier "<tier>"Add the tier to the site’s memberships.tiers.
a tier on sale needs the install's tier listDeclare tiers in config/memberships.yaml.
months <n> is not between 1 and 36Fix months.
The server will not start: environment variable STRIPE_SECRET_KEY (stripe.secret_key) is not setSet both variables in the environment.