Provision a New Registration Portal Tenant

Overview

This procedure onboards a new organisation onto the Event and Membership System so that its participants can register through the shared registration portal and pay through that organisation’s own WooCommerce site.

The registration portal is multi-tenant: a single deployment serves every organisation. Onboarding a tenant is therefore a configuration exercise (rows in databases, a WordPress site, one line in the portal’s deployment manifest) — not a new portal deployment. See Multi-Tenancy for the architecture this procedure operationalises.

Today this is a manual procedure executed through SQL, WP-CLI, and a Git-Ops manifest edit. It is defined by the outcome it produces, not the tools used. The future admin-portal use cases S01 Super-Tenant (create/manage tenants) and T01 Tenant Admin will automate these same steps. Keep this page current as the source of truth for those use cases.

What a tenant is made of

A fully provisioned tenant is the following chain, spread across two databases and a WordPress site:

Artefact Lives in Purpose

organisation row

admin-service Event DB

The multi-tenancy root. Owns events, memberships, orders.

registration_system row(s)

admin-service Event DB

Payment gateway + banking config for the organisation. One per payment_methods configuration; each maps to exactly one tenant hostname.

api_key (+ api_key_permission) row

admin-service Event DB

Authenticates the event-payment plugin’s calls into admin-service (ApiKeyFilter); api_key_permission scopes the key to the organisation.

process_definition (+ steps)

admin-service Event DB

Drives the registration form/workflow for the organisation’s events.

Default WooCommerce product + product row

WordPress site + admin-service Event DB

A default/general product for the plugin’s Default-product setting, mirrored by an admin-service product row (external_id = WooCommerce product ID). Per-event entry/fee products are created per event — see Configure an Event.

WordPress + WooCommerce site

The organisation’s own hosting

Money-of-record for online payment; event-payment-plugin-woocommerce wires it back to admin-service.

tenant row

registration portal Gateway DB

Maps the public hostname → registration_system_id (+ optional per-tenant OIDC/theme).

Deployment host + DNS

ArgoCD manifest + DNS

Adds the tenant’s hostname to the shared portal ingress and points DNS at it.

The runtime resolution chain is Tenant (Gateway DB) → RegistrationSystem (Event DB) → Organisation (Event DB): on each request TenantResolutionFilter matches the hostname to a tenant row, carries registration_system_id to admin-service /api/authenticate, and admin-service mints a JWT scoped to the resolved orgId.

Databases and access

Both databases sit on the idealogic-prod MySQL cluster (reached by SSH tunnel — see the Number & Tag Runbook for the tunnel recipe), but they are separate schemas, and this is the single most common mistake:

Rows Schema (ems-prod) Notes

organisation, registration_system, api_key, api_key_permission, process_definition, product

wpca_prod

The admin-service Event DB. In the 2026 H2 parallel run the ems-prod admin-service points at wpca_prod.

tenant

event_registration_ui_ems_prod

The portal’s own fresh UI-local Gateway schema.

Confirm the live schema names against the ArgoCD manifests (ems-admin-service-prod.yml, ems-registration-portal-prod.yml) before running any INSERT — the parallel-run topology is described in design-journal/2026-07/2026-h2-release.adoc and will change at cutover.

Prerequisites

Gather these before you start:

  • Organisation details — legal/display name.

  • Public registration hostname — e.g. register.<org-domain> — and control of its DNS.

  • WooCommerce site — a WordPress site (existing or new) you can install plugins on, and its base URL (typically https://<site>/wp-json/payment-api/v1).

  • Payment methods — which of Online (O), EFT (E), Manual (M) the organisation will offer. EFT requires banking details.

  • OIDC/identity — if the tenant uses a dedicated identity provider (e.g. a distinct Azure B2C user flow), its issuer URL, client id and secret. Otherwise the portal default applies.

  • Cluster access — SSH tunnel to idealogic-prod, and write access to the ~/dev/idl-xnl-jhb-rc01/argocd/ Git-Ops repo.

Phase A — Admin-service data (Event DB, wpca_prod)

A1. Create the Organisation

INSERT INTO organisation (name)
VALUES ('<Organisation display name>');
-- note the generated id → <organisation_id>

A2. Create the RegistrationSystem

One row per payment configuration. type identifies the integration (WP2 = WordPress v2, the current WooCommerce integration); base_url is the WooCommerce payment-API root; auth_key is the plugin’s callback API key, which the event-payment plugin auto-generates at start-up — you copy it in once the plugin is installed (B7); payment_methods is a comma-separated set of O (Online/WooCommerce), E (EFT — needs banking columns), M (Manual/pay-at-counter).

INSERT INTO registration_system
    (name, type, base_url, auth_key, payment_methods, organisation_id)
VALUES (
    '<Org> Website',                                       -- descriptive name
    'WP2',                                                 -- integration type
    'https://<site>/wp-json/event-payment-api/v1',         -- WooCommerce payment API root
    NULL,                                                  -- auth_key: set from plugin epa_callback_api_key (B7)
    'O',                                                   -- O / E / M (comma-separated)
    <organisation_id>
);
-- note the generated id → <registration_system_id>
auth_key cannot be filled here yet — it is the value the plugin generates for epa_callback_api_key at start-up. Insert the row now, then in B7 copy the plugin’s callback key back into this row (UPDATE registration_system SET auth_key = '<value>' WHERE id = <registration_system_id>;).

For EFT (payment_methods includes E) also populate the banking columns:

UPDATE registration_system
SET bank_name = '<Bank>', account_name = '<Account holder>',
    account_number = '<Account number>', branch_code = '<Branch code>'
WHERE id = <registration_system_id>;

The type code has drifted across sources: the payment-stub README says WC/WP2, an older validation doc says WP, and the seeded prod rows use WP2. Match the existing production rows — query SELECT DISTINCT type FROM registration_system; first and use WP2 unless the gateway factory (PaymentGatewayFactory) tells you otherwise.

A3. Create the admin API key for the event-payment plugin

The event-payment plugin calls admin-service (order status sync) with an X-API-KEY header; ApiKeyFilter validates it against the api_key table, and api_key_permission scopes the key to the organisation so calls made with it only reach that org’s data. Generate a fresh UUID v4 for key_value; this same value goes into the plugin’s Admin API Key field (B7).

-- 1. The key itself
INSERT INTO api_key (key_value, description, active, created_date)
VALUES (
    '<uuid-v4>',                              -- generate: uuidgen
    'Event payment plugin - <Org>',
    true,
    NOW()
);
-- note the generated id → <api_key_id>

-- 2. Scope it to the organisation
INSERT INTO api_key_permission (api_key_id, organisation_id, role)
VALUES (
    <api_key_id>,
    <organisation_id>,                       -- MUST match the tenant's organisation
    'ADMIN'                                  -- role granted for that organisation
);

The organisation_id here must be the same organisation the tenant resolves to (via its RegistrationSystem), otherwise the plugin’s calls are scoped to the wrong org’s data. Use the ADMIN role for the organisation.

This admin API key (plugin → admin-service) is distinct from registration_system.auth_key (A2), which secures the reverse direction (portal → WooCommerce, the plugin’s epa_callback_api_key).

A4. Create the ProcessDefinition(s)

Each event registration flow is driven by a process_definition and its ordered process_step rows. Definitions may be global or organisation-scoped. Follow the "Creating a Process Definition" steps in Process Entities and clone an existing organisation’s definition as the starting point rather than building from scratch.

A5. Create the default admin-service product

Mirror the default WooCommerce product (B3) with a product row whose external_id holds the WooCommerce product ID. Create the default product now; the per-event entry/fee products are created per event — see Configure an Event, step E7.

Create the WooCommerce product (B3) first — you need its ID to populate external_id here.

INSERT INTO product (name, external_id, price)
VALUES (
    'Default Entry',                  -- general/default product
    '<woocommerce-product-id>',       -- the ID WooCommerce assigned to the product
    <price>
);
The product table has no registration_system reference (it is keyed only by external_id), so product IDs must be kept distinct across tenants — see Configure an Event, step E7, for detail.

Phase B — WordPress / WooCommerce site

Stand up (or reuse) the organisation’s WordPress site and install the stack. The dev bootstrap in design-journal/2026-01/wp-deployment.adoc is the canonical, idempotent recipe — the same WP-CLI steps apply to production with production values. The commands below can equally be done through the WordPress/WooCommerce admin UI.

Two distinct payment layers are configured in this phase, and it helps to keep them separate:

  • The money processor — a WooCommerce payment gateway (Payfast or Paygate) that actually takes the customer’s money (B4–B6).

  • The EMS event-payment pluginevent-payment-plugin-woocommerce, which creates the WooCommerce order from a registration and syncs its status back to admin-service (B1, B7). It does not process money; it hands checkout to whichever WooCommerce gateway is enabled.

B1. Install the plugins

WooCommerce itself, the EMS event-info plugin, and the EMS event-payment plugin:

wp plugin install woocommerce --activate
wp plugin install "https://${GITHUB_TOKEN}@github.com/christhonie/event-info-plugin/releases/download/${EIP_PLUGIN_VERSION}/event-info-plugin-${EIP_PLUGIN_VERSION}.zip" --activate
wp plugin install "https://${GITHUB_TOKEN}@github.com/christhonie/event-payment-plugin-woocommerce/releases/download/${EPA_PLUGIN_VERSION}/event-payment-plugin-${EPA_PLUGIN_VERSION}.zip" --activate

B2. Configure the WooCommerce store

Set the organisation’s store identity, address, currency and base pages. Via the admin UI this is WooCommerce → Settings → General (plus the setup wizard); the equivalent options are:

# Store identity & address (WooCommerce → Settings → General)
wp option update woocommerce_store_address  "<Street address>"
wp option update woocommerce_store_city     "<City>"
wp option update woocommerce_default_country "ZA:WC"           # Country:State
wp option update woocommerce_store_postcode "<Postcode>"
wp option update blogname                   "<Organisation display name>"

# Currency & selling options
wp option update woocommerce_currency        "ZAR"
wp option update woocommerce_enable_coupons  "yes"

# Core pages (created idempotently by the bootstrap: Shop / Cart / Checkout / My Account)

The bootstrap script creates the Shop/Cart/Checkout/My-Account and legal pages and wires their IDs into the matching woocommerce_*_page_id options — reuse it rather than hand-creating pages.

B3. Create the default WooCommerce product

Create one Simple product — a default/general product — for the plugin’s Default product setting (B7). Make it Virtual, give it a name/description, and tick Inventory → "Limit purchases to 1 item per order" (quantity is always 1 because participant metadata attaches per line item). Set its Catalog visibility to Hidden so it does not appear in the public shop.

Record its WooCommerce product ID — A5 needs it for product.external_id and B7 uses it as the Default product.

The per-event entry products and fees (one per distance/category, the CSA day licence, the Entries / CSA product categories, the exact product mechanics and how to link products to event categories and the day-licence field) are created per event — see Configure an Event, step E7.

B4. Install and enable the payment provider

Install the gateway plugin for the chosen provider and activate it. The organisation uses either Payfast or Paygate — install only the one in use. (Paygate is a Payfast affiliate; its WooCommerce plugin is published by Payfast — Payfast/woocommerce-gateway.)

# Payfast  (plugin by WooCommerce)
wp plugin install woocommerce-payfast-gateway --activate
# — or — Paygate  (PayWeb, plugin by Payfast)
wp plugin install paygate-payweb-for-woocommerce --activate

Then enable it as a payment option and make sure the dev-only Direct Bank Transfer (BACS) stub is not enabled in production: WooCommerce → Settings → Payments, toggle the provider on. The enabled gateway is what the customer is redirected to at checkout after the event-payment plugin creates the order.

B5. Create / obtain the provider account and retrieve credentials

Each provider needs a merchant account and exposes an integration credential set with provider-specific field names. Myriad Events already holds accounts with both providers — obtain the credentials from the provider’s merchant dashboard (or from the Myriad Events account owner) rather than creating a new account unless this organisation bills separately.

Provider Integration key field(s) API secret

Payfast

Merchant ID, Merchant Key

Passphrase (salt passphrase)

Paygate (PayWeb3)

PayGate ID

Encryption Key

The field names differ per provider, but the roles are the same: one or more public merchant/identity fields, plus one secret used to sign/verify the payment handshake. Treat the secret (Payfast Passphrase / Paygate Encryption Key) as sensitive — store it in the secret manager, never in this guide or in Git. Use the provider sandbox credentials for a test run before switching to live.

B6. Configure the payment provider in WooCommerce

Enter the B5 credentials into the gateway settings (WooCommerce → Settings → Payments → <provider>): Payfast’s Merchant ID / Merchant Key / Passphrase, or Paygate’s PayGate ID / Encryption Key. Set the return/notify URLs to this site, and leave sandbox/test mode on until the end-to-end verification in Phase E passes, then switch to live.

B7. Configure the EMS event-payment plugin

Configure the plugin (WooCommerce → EMS Event Payment, or the plugin settings screen) so it can reach admin-service and so admin-service accepts its calls. The key fields, with the option key each maps to:

Plugin field Option key Value

Portal URL

epa_entry_portal_url

This tenant’s registration portal host, e.g. https://register.<org-domain>;.

Default product

epa_default_prod_id

The general/default WooCommerce entry product (B3) used when no specific product maps.

Admin API URL

epa_admin_api_url

The admin-service public URL, e.g. https://admin-service.idealogic.co.za.

Admin API Key

epa_admin_api_key

The UUID v4 created in the api_key table (A3) — the key the plugin sends to admin-service.

The equivalent WP-CLI form (full option reference in WordPress Payment Integration):

wp option update epa_entry_portal_url "https://register.<org-domain>"
wp option update epa_default_prod_id  "<default-woocommerce-product-id>"
wp option update epa_admin_api_url    "https://admin-service.idealogic.co.za"
wp option update epa_admin_api_key    "<uuid-v4 from A3>"
wp option update epa_enable_logging   "yes"

Do not set epa_callback_api_key by hand — the plugin auto-generates it at start-up. Read the generated value from the plugin admin screen and copy it into the registration_system.auth_key column for this tenant (A2):

UPDATE registration_system SET auth_key = '<plugin epa_callback_api_key>'
WHERE id = <registration_system_id>;

Option-key naming differs between the bootstrap script and the architecture doc (epa_callback_api_url vs epa_redirect_url, and whether epa_admin_api_url carries a /api suffix). Treat WordPress Payment Integration as the contract of record, set the keys the installed plugin version reads, and verify with wp option list --search='epa_*' after install.

The inbound/outbound contract the event-payment plugin honours (order create in, status sync out) is:

  • Inbound order create: POST /wp-json/payment-api/v1/order/event/create/{ redirectURL, orderNumber, orderExternalId }

  • Outbound status sync: PATCH /api/orders/participant/{orderId} (event) / POST /api/memberships/order (membership), X-API-KEY header

  • Status mapping: processing/completedPAID, cancelled/failedCANCELLED, refundedREFUNDED

See payment-stub/README.md for the dev-mock equivalent of this contract.

Phase C — Registration portal tenant row (Gateway DB, event_registration_ui_ems_prod)

Map the public hostname to the RegistrationSystem created in A2. This is what TenantResolutionFilter looks up on every request.

INSERT INTO tenant (name, domain, registration_system_id)
VALUES (
    '<Organisation display name>',
    'register.<org-domain>',          -- full hostname; matched by TenantResolutionFilter
    <registration_system_id>
);

For a dedicated identity provider, also set the OIDC columns:

UPDATE tenant
SET issuer_url = '<issuer-url>', client_id = '<client-id>', client_secret = '<client-secret>'
WHERE domain = 'register.<org-domain>';

The tenant table’s original organisation column was dropped (migrations 20241210000001 / 20241210000002) in favour of registration_system_id. The fake-data/tenant.csv seed still shows an organisation column — it is stale. The current schema is tenant(name, domain, registration_system_id, issuer_url, client_id, client_secret, scope, theme, header_image_url). Insert registration_system_id, never organisation.

Phase D — Deployment host and DNS

The tenant’s hostname is added to the shared portal ingress — no new deployment.

  1. Edit ~/dev/idl-xnl-jhb-rc01/argocd/ems-registration-portal-prod.yml and add the hostname under ingress.extraHosts:

    ingress:
      hostname: registration-v2.myriadevents.co.za
      extraHosts:
        - register.<org-domain>          # <-- new tenant
  2. Commit and let ArgoCD sync (this app is manual sync during the parallel run — sync it).

  3. Point DNS for register.<org-domain> at the cluster ingress. cert-manager (letsencrypt-prod) issues TLS for the new host automatically once DNS resolves.

  4. If the tenant uses a shared/central identity provider (e.g. the Myriad Events Azure B2C user flow), add the new host’s redirect URI to that app registration.

Phase E — Verify

  1. https://register.<org-domain>; resolves, serves the portal over valid TLS, and the correct organisation’s branding/events appear (confirms Tenant → RegistrationSystem → Organisation).

  2. Create a test registration and reach checkout — confirm the order is created in WooCommerce and registration_system_id is set on the admin-service order.

  3. Complete/cancel a sandbox payment through the provider (Payfast/Paygate) and confirm the status callback reaches admin-service through ApiKeyFilter (order transitions to PAID/CANCELLED).

  4. Only once the sandbox run is clean, switch the WooCommerce provider from sandbox/test mode to live (B6) and disable any dev-only Direct Bank Transfer (BACS) gateway.

Worked example: Tour de Worcester (2026)

Input Value

Public WooCommerce site

https://tourdeworcester.co.za

Registration portal host

register.tourdeworcester.co.za

Portal deployment

extraHosts entry on ems-registration-portal-prod.yml (host registration-v2.myriadevents.co.za)

Payment plugin

event-payment-plugin-woocommerce

Register CTA pattern

register.tourdeworcester.co.za/register?orgId=…&eventId=… (built by the plugin’s SC_EPARegBtn)

Online processor

WooCommerce → Payfast gateway (Paygate is a Payfast affiliate; the Payfast gateway is in use)

WooCommerce products

Default product created here; per-event entries + CSA day licence are set up per event — see Configure an Event (Rooibos-style categories/courses/races).

organisation_id / registration_system_id

assigned during provisioning — not recorded here by policy

Discount-code / UTM carry-through for this tenant (landing ?discount=CODE, host-only epa_discount_code cookie, 72h TTL) is covered separately in design-journal/2026-07/registration-discount-code-carry.adoc; coupon values themselves are owned by the WooCommerce coupon plugin, not by EMS.