Shop Commerce Integration (WooCommerce + MerchantPro)
Status: Built end-to-end (committed on
staging, not pushed) as the connectivity slice (F14.1) of F14. Commerce & Access Offers. First real consumer of the Cat B (Connected Account, 1C.5) and Cat D (Inbound Webhook, 1C.6) frameworks.2026-10-26 — extended (spec only, nothing built): Shop orders, sellers and shop invoices (F14.9–F14.11) — a copy of every shop order in the platform, orders in the patient's account, a second legal entity selling through the clinic, and fiscal invoices issued from shop orders.
Scope of this doc — the connectivity slice only. A clinic connects its external shop(s); the platform reliably receives → verifies → dedups → pulls paid orders and emits a
commerce.order_paidinternal event (Cat E). What happens to that event — mapping order line items to access (grants / subscriptions) — is the provisioning slice, specified separately under the access-offers design. This doc stops at the clean seam: a verified, authoritative, deduped paid order has arrived.
Why this exists
RestartiX clinics sell appointments, programs, and physical-product bundles (which may include free programs/sessions) through external e-commerce shops — today WooCommerce and MerchantPro, two shops per clinic. The shops stay external forever: the platform never builds a cart, checkout, payment, inventory, or shipping. Its responsibility begins at the fulfillment boundary — an order exists and is paid; turn it into platform access.
This is the paid-order trigger of the "one provisioning core, two triggers" design (the other trigger being free marketing-campaign claims). Both triggers converge on the same downstream provisioner; they differ only in the front door. This doc builds the shop front door up to the trigger event.
Locked decisions
- Cat B
api_key, not OAuth. Both shops authenticate API reads with a consumer key + secret (HTTP Basic). The connected-accounts framework'sauth_type='api_key'path is fully built; no OAuth-callback machinery is needed. Credentials are stored encrypted inorganization_integrations.credentials_encrypted(AES-256-GCM via KMS). - Verify-by-pull is the trust boundary. Neither the post-checkout redirect nor a webhook body is trusted for whether money changed hands. Money-truth always comes from an authenticated pull of the order from the shop API. The redirect
order_keyis a lookup + lightweight ownership token only. - Per-connection inbound URL token + generated secret. Each connection gets a unique, unguessable inbound URL (
/webhooks/{provider}/{token}) and a platform-generated signing secret. The token selects the connection deterministically before the body is read; the connection's secret then verifies the HMAC. The clinic pastes both the URL and the secret into its shop's webhook settings. (Chosen over a shared per-provider URL that sniffs the org from an unsigned payload field.) - One normalized order contract. Woo and MerchantPro collapse to a single internal
NormalizedOrdershape behind a connector interface; downstream code never sees the provider difference. (Mirrors the legacyorder-provider.jsnormalization, hardened for multi-tenancy.) - Per-org everything. Unlike the legacy single-clinic system (global env credentials), every credential, token, and order record is
organization_id-scoped. The inbound URL token resolves which org + which credentials. - Multiple connections of the same provider are supported. A clinic can connect N WooCommerce shops and N MerchantPro shops. The framework's uniqueness key is
(organization_id, integration_service_id, external_account_id)—external_account_id(required, non-empty; the shop'sstore_url) is the per-shop discriminator. One catalog row + one stateless connector impl per provider serves all of that provider's connections; each shop is its ownorganization_integrationsrow with its own credentials, inbound URL token + secret, and displaytitle. The per-connection inbound token (decision 3) routes each shop's webhooks unambiguously — no payload sniffing, no store-URL collision risk. The unique key prevents connecting the same shop twice while allowing many distinct shops.
Architecture
Shop (Woo / MerchantPro) API
──────────────────────── ────────
order paid ──push──▶ POST /webhooks/{provider}/{token} ┌─ Cat D inbound ─┐
1. token → connection │ verify.go │
2. decrypt connection secret │ parse.go │
3. verify HMAC (raw body) │ handler.go │
4. dedup (provider, event_id) └─────────────────┘
5. PULL authoritative order ◀──── GET /orders/{id} ┌─ Cat B ─┐
(consumer key/secret) │connector│
6. emit commerce.order_paid (Cat E) └─────────┘
│
▼
[provisioning slice — separate doc]The redirect-driven post-checkout onboarding page (where the buyer signs up + consents, and provisioning completes with the buyer present) is part of the provisioning slice, not this doc. This slice is the machine-to-machine connectivity that makes paid orders reliably available.
Track A — Cat B connectors (connect the shop)
Follow connected-account-integration-guide.md.
- [ ] Seed catalog rows. Migration adding two
integration_servicesrows:slug='woocommerce'andslug='merchantpro',auth_type='api_key',oauth_client_capability=NULL,status='available',config_schemadescribingstore_url(+ provider-specific fields). Catalog mutations are AdminPool-only (written by migration). - [ ] Implement
integrations.Connectorfor each, ininternal/core/integrations/connectors/{woocommerce,merchantpro}/connector.go:Slug()→"woocommerce"/"merchantpro".ValidateCredentials→ requireconsumer_key,consumer_secret,store_url.ValidateConfig→ validatestore_urlshape.Healthcheck→ cheap authenticated GET (Woo:/wp-json/wc/v3/system_status; MerchantPro: a lightweight/api/v2read), ≤5s timeout. This is the/testconnection path.RefreshOAuthToken→nil, nil(non-OAuth).init()callsintegrations.Register(&Connector{}); import the package incmd/api/main.go.
- [ ] An order-fetch method on each connector (or a sibling
ShopClient) —FetchOrder(ctx, creds, orderRef) (NormalizedOrder, error)— used by the inbound handler's pull step. Woo:GET /wp-json/wc/v3/orders/{id}; MerchantPro:GET /api/v2/orders/{id}+ field normalization (see contract below). - [ ] No new connection endpoints —
POST/GET/PATCH/DELETE /v1/organizations/{id}/integrations+/testalready exist, gated byorganizations.manage_integrations.
NormalizedOrder contract
NormalizedOrder {
order_ref string // shop order id
order_key string // redirect token / Woo order_key / MP public_code
status string // normalized: paid | unpaid | refunded | cancelled | pending
paid bool // derived; the only field the trigger gates on
total, currency string
billing { first_name, last_name, email, phone }
line_items [ { product_ref, variation_ref, sku, quantity } ]
}Only paid == true orders emit the trigger. refunded/cancelled are recorded (for the future refund→revoke path) but do not provision. PII (billing.*) is persisted minimally and classified (see Compliance).
Track B — Cat D inbound (receive orders)
Follow inbound-webhook-guide.md. Reference consumer to copy: internal/integration/bunnystream/inbound/.
- [ ] Per-connection inbound identity (the one net-new bit of framework wiring).
- Add
organization_integrations.inbound_url_token TEXT UNIQUE(indexed; generated at connection create). This is the{token}in the URL. - Store a generated inbound signing secret inside the connection's encrypted credentials blob (alongside the consumer key/secret). Shown to the clinic admin once, at create/rotate.
- A small resolver:
token → organization_integrations row → decrypt → (inbound_secret, consumer_creds, store_url).
- Add
- [ ] Per-provider inbound packages
internal/integration/{woocommerce,merchantpro}/inbound/:verify.go—Verify(req, secret): base64 HMAC-SHA256 of the raw body,crypto/subtle.ConstantTimeCompareagainstX-WC-Webhook-Signature(Woo) /X-Webhook-Signature(MerchantPro). MerchantPro cannot sign (its webhook editor has only payload and header templates, confirmed on a live store 2026-10-21), so it also acceptsX-Webhook-Secret: <signing secret>set in the headers template, compared in constant time; the body is never trusted either way, since the order is re-fetched with the clinic's API credentials. Payload template:{% return {'id': order.id} %}.parse.go—Parse(body) (Event, error);Event.IDis the dedupevent_id(Woo delivery/webhook id or order id; MerchantPro event id).handler.go— the locked flow: resolve token → decrypt secret → Verify → dedupWasProcessed(early 200 on replay) → pull authoritative order via Cat B → gate onpaid→ emitcommerce.order_paid→MarkProcessed.
- [ ] Mount endpoints under the existing
/webhooksgroup (per-IP rate-limited, no auth, sibling of/v1):POST /webhooks/woocommerce/{token}andPOST /webhooks/merchantpro/{token}. Add aninbound_webhookrate-limit policy if not already present. - [ ] Register the Cat E event
commerce.order_paidin the 1C.3 events registry, emitted at the end of the flow. Payload carriesorganization_id,integration_id,order_ref, and the normalized line items — the contract the provisioning slice consumes. - [ ] Dedup uses the existing
inbound_webhook_deduptable (monthly-partitioned, AdminPool-only). Idempotency boundary is(provider, event_id). Retry is the shop's job (5xx → re-POST → dedup short-circuits).
Track C — Clinic UI (configure the connection)
No integrations surface exists in the Clinic app today (1D.2 L19, "not-built"). The Cat B API and packages/api-client methods (listIntegrationServices, createIntegration, testIntegration, …) already exist — this track is the missing frontend.
- [ ]
/integrationsroute inapps/clinic/app/(dashboard)/— list connected shops (service, status, last-used, error state) + "Connect shop" flow. Mirror the shipped SWR pattern incomponents/patient-tiers/patient-tiers-editor.tsx; gate onorganizations.manage_integrations(sidebar entry already stubbed). - [ ] Connect dialog — pick provider from the catalog → credential form (
consumer_key,consumer_secret,store_url) → create → run/test. - [ ] Write-only secret input (net-new shared pattern; none exists) — masked, never echoed back, with a "replace credentials" affordance. Note: the Cat B API has no credential-rotation endpoint yet (
PATCHis title/config only) — rotation is revoke + recreate unless we add it; flag for the guide. - [ ] Inbound webhook setup panel (net-new shared pattern; none exists) — display the per-connection webhook URL + signing secret with copy-to-clipboard, a "regenerate" control, and step-by-step "paste this into your WooCommerce/MerchantPro webhook settings" instructions.
Compliance & security
- Credentials (
auth_secretclass): per-org, KMS-encrypted, never returned on read, redacted in audit/logs. Consumer key/secret + the generated inbound secret all live incredentials_encrypted. - Signature verification is mandatory before any action; failed verification → reject + audit. Raw body is captured for HMAC.
- Idempotency: exactly-once via the dedup table; replays are no-ops.
- Verify-by-pull: provisioning never trusts a pushed payload's money fields — it re-reads the authoritative order.
- PII minimization & egress: order billing PII flows shop→clinic; clinic is controller, platform is processor. Persist only what identity-linking/profile needs. Every new column (
inbound_url_token, any order/fulfillment record fields) needs a data-classification.md registry entry in the same PR (CI enforces).inbound_url_tokenis a routing bearer token — classify accordingly (not egressable beyond the clinic-admin surface that displays it). - SOUP: prefer stdlib HMAC (no new dep). Any shop SDK added → SOUP row in the same PR.
- RLS:
organization_integrationsis already per-org RLS-gated onorganizations.manage_integrations. The inbound route is unauthenticated by necessity (the shop has no session) — its security is the unguessable token + HMAC, not RLS.
Follow-ups (out of scope here)
- Provisioning slice — consume
commerce.order_paid: per-org SKU→offer mapping, the shared provisioner mintingpatient_content_grants/patient_subscriptions, and the post-checkout onboarding page (buyer signs up via Clerk + consents, provisioning completes). This is where "one core, two triggers" converges with the marketing-campaign trigger. - Refund → revoke — SHIPPED (F14.5):
commerce.order_refundedlapses content grants precisely (viapatient_content_grants.fulfillment_id). Auto tier-revoke stays deferred (replace-semantics ambiguous). - Reconciliation sweep — periodic pull to catch orders whose webhook was missed. Specified in F14.9 (the sweep and the manual Sync share the worker).
- Credential-rotation endpoint for Cat B (
PATCHcurrently can't rotate secrets). Still deferred. - Registered as F14. Commerce & Access Offers in the implementation plan (2026-06-01).
Shop orders, sellers and shop invoices (F14.9–F14.11)
Data shape superseded by the commerce model (2026-10-29)
commerce-model.md replaces the tables named below:
shop_orders/shop_order_lines→commerce_orders/commerce_order_lineswithchannel = shoporganization_sellers→commerce_sellers- the
fiscal_invoicesrename →commerce_fiscal_documents
It also puts the restructure first in the build order. What stands from this section and is still the plan: the MerchantPro facts, the queue-then-worker receiver, the sync triggers (webhook, Sync button, sweep, import), the Clinic and Portal surfaces, and the invoicing rules. Superseded on linking: option 4 below (display by verified email) is rejected. External orders are never attached automatically (owner, 2026-10-29). This section is rewritten on the new tables when step 2 starts.
Added 2026-10-26 — owner decision. Spec only; nothing built. Build order: F14.9 → F14.10 → F14.11. F14.9 is additive and touches no existing table, so it can ship without a catch-up script. F14.11 needs F14.10, because the second store belongs to a different company.
Why
The clinic sells through two MerchantPro stores, owned by two companies with one owner:
| Store | Sells | VAT | Company |
|---|---|---|---|
magazin.zerodurere.ro | services, digital products | without | the clinic's own company |
magazin.restartix.ro | physical products (some bundle a plan) | with | a second company, same owner |
Each company has its own Stripe account and its own FGO account. The second company sells nothing clinical and the clinic receives nothing from its sales. It appears in the platform only so that its orders sit next to the patient's other purchases and its invoices go out from its own FGO account.
Today the accountant issues every shop invoice by hand in MerchantPro, separately from the Stripe invoices the platform issues (F14.8). Patients cannot see shop orders in their account. The platform only learns about orders that match an access offer, and keeps nothing about them but the reference (access_offer_orders).
MerchantPro stays the shop. Stock, fulfilment, MiniCRM (merchanpro-minicrm-worker) and its other integrations are untouched, and the platform never writes to a store. What moves here: a copy of every order, the orders in the patient's account, and invoicing (owner, 2026-10-26).
What MerchantPro provides
From MerchantPro's webhook docs and the MiniCRM worker's notes from live traffic (April–October 2026):
- Order events:
order.created,order.payment_completed,order.invoice_issued,order.fulfillment_created, andorder.shipping_{awaiting,confirmed,processed,shipped,delivered,cancelled}(cancelledcovers both canceled and returned). - No refund event. A payment status that changes to refunded fires nothing. The sweep below catches it.
- Delivery: POST, a 5-second timeout, at most 3 attempts 5 minutes apart. After 8 failed delivery cycles in a row the webhook is deactivated until someone reactivates it in the dashboard.
- The order object has everything an invoice needs:
- line items with
unit_price_net/_gross,product_tax_percentandline_tax_amount shipping_amountandshipping_tax_amountpayment_method_code(seen live:cash_delivery,wire,euplatesc,tbi),paid_amount,payment_datebilling_type(companyor an individual),billing_company_name,billing_company_vat(CUI) andbilling_company_number(trade register number)invoice_identifier, set when MerchantPro issued an invoiceawb_data(parcel tracking)
- line items with
- Rate limit is per minute, per store. The June 10 2026 flash sale (~980 orders in one evening) exhausted it for hours.
- Foreign orders carry the buyer country's VAT (OSS): 20% FR, 19% DE, and so on. Invoices pass the rate through unchanged.
- Not yet verified: the coupon / discount fields on a real coupon order, partial-refund amounts, and whether the list endpoint can filter by update time.
F14.9 Shop order mirror
Every order from every connected store is copied into the platform, whether or not it matches an access offer.
Receiving: the webhook only queues. Today's handler fetches the order and does the database work before answering, all inside MerchantPro's 5-second window. In a burst, three slow attempts lose the order, and eight such cycles switch the webhook off. From now on:
- The handler resolves the token, verifies the signature, parses the order id, queues a sync job and answers
200, with no call to MerchantPro. - The sync worker does the rest. It runs in-process in every API instance (like the FGO worker), so it adds no AWS cost. It claims a job with
FOR UPDATE SKIP LOCKED, allows one in-flight job per store across all instances, paces calls to about one a second per store, and on429waits forRetry-After(else backs off). - For each job it fetches the order (verify-by-pull, unchanged) and upserts the copy. Only then does it call the existing access-offer processor:
ProcessPaidOrderwhen the order is paid andProcessRefundedOrderwhen it is refunded or canceled. Both are already idempotent on (org, integration, order_ref), so a repeated call is harmless. Thecommerce.order_paid/commerce.order_refundedevents still fire after the work commits.
Every event the store sends means the same thing, "re-read this order", so the handler does not branch on X-Webhook-Event. The store subscribes order.created, order.payment_completed, order.invoice_issued and every order.shipping_* event. The payload template stays {% return {'id': order.id} %}. The webhook URL and the access-offer behaviour do not change; only where the work runs does.
Three ways an order is queued, all through the same job:
| Trigger | What it queues |
|---|---|
| Webhook | that order |
| Sync button | that order (on the order), or "recent orders" (on the store) |
| Sweep (daily) | orders created since the last listing (misses), plus re-reads of orders still open from the last 60 days. That second part is how a refund is found, since MerchantPro sends no event for it. At ~1,000 orders a month this is about 2,000 paced reads a day. |
| Import | every order in a date range the clinic picks, for history; paced, previewed with a count first |
Tables (new migration, all state, flat, RLS per org):
shop_orders, unique(organization_id, integration_id, order_ref):- identity:
order_ref,order_key(MPpublic_code) - status:
payment_status,shipping_status,payment_method_code,payment_method_name - money:
total_minor,shipping_minor,currency - buyer:
billing_type,buyer_name,buyer_email,company_name,company_tax_id,company_reg_number provider_invoice_ref(MPinvoice_identifier),tracking(fromawb_data)patient_id(nullable; see linking below)placed_at,paid_at,synced_at, timestamps
- identity:
shop_order_lines:shop_order_id,position,kind∈ {product,shipping,discount},product_ref,variation_ref,sku,name,quantity,unit_price_minor(gross),vat_rate,line_total_minor. Lines are replaced whole on each sync.shop_order_sync_jobs:integration_id,order_ref(null = "list recent"),reason,attempts,next_attempt_at,locked_until,last_error. One pending job per (integration, order_ref); the row is deleted on success.- The sweep's listing cursor, per store.
Personal data is minimised the way F14.8 does it. The copy keeps the buyer's name and email (needed for the list, search and linking) and a company's details (business data, needed on the invoice). The postal address is not stored: the order detail page and invoicing read it live from the store. Every new column gets a data-classification.md row in the same PR (CI enforces it). The CNP never appears; MerchantPro does not ask for it.
access_offer_orders stays as it is. It is the access-provisioning record (matched offers, activation link) and keeps working unchanged. Pointing it at shop_orders is a later clean-up, not part of this step.
Clinic UI: Sales → Comenzi, a server-paginated list across stores:
- filters by store, payment status, shipping status, payment method and date
- search by order number, buyer name or email (trigram +
unaccent) - a detail page with lines, statuses, tracking, the access the order gave (from
access_offer_orders), the linked patient, and a Sincronizează button
The patient's record gets a Comenzi card. Reading needs payments.view. Syncing one order needs payments.view too (it only re-reads). Importing a date range needs organizations.manage_integrations.
Portal: the patient sees an order as soon as it is placed (owner, 2026-10-26). Under My purchases, a Comenzi list shows number, date, items, total, payment status, shipping status and tracking. Each linked order shows only to its own patient (RLS on patient_id, caregivers through the existing family rule).
Linking an order to a patient follows the F14.7 rule: typing an email at a checkout proves nothing. An order is linked by:
- the
order_keyon the post-checkout page (/orders/[ref]?key=, F14.3), which already proves the buyer - the activation link, emailed to the order's address (F14.7)
- staff attaching it to a patient they know
- (decision, recommended) display only: a patient whose verified login email equals the order's email sees that order in their list. This discloses nothing the store has not already sent to that inbox with its own confirmation email. It grants no access and activates nothing; access still needs 1–3.
Emails: the platform sends no order emails in this step; MerchantPro keeps sending its own. Moving order confirmations to the platform (and turning off MerchantPro's) is a later choice, since it depends on MerchantPro allowing those emails to be switched off.
Production safety: new tables only, no catch-up script, existing rows untouched. The receiver change is the risky part, so it is rehearsed on staging against a real store before production.
F14.10 Sellers (merchant of record)
A seller is a company that sells through the clinic: the name on the order, the payment and the invoice. Every clinic has exactly one primary seller, its own company. It may add additional sellers. A clinic that never adds one notices nothing.
organization_sellers(state, RLS per org):legal_name,tax_id,reg_number,is_primary(exactly one per org, a partial unique index). The migration creates a primary seller for every existing org.- Connections belong to a seller:
organization_integrations.seller_id(backfilled to the primary seller) for kindspayments,invoicingandcommerce. The one-per-org indexes become one per seller:uq_org_integrations_payments_one_per_org(000061) anduq_org_integrations_invoicing_one_per_org(000063). This is a new forward migration; both are on production. - Offers belong to a seller:
access_offers.seller_id(default the primary). A Stripe checkout charges on that seller's account, and the offer price lives in it. Today checkout takes "the clinic's" account (patientpayments.ConnectionAdmin). - Money remembers its seller:
patient_payments.seller_id(resolved from the account the payment came in on; Stripe webhooks already resolve by account id) andshop_orders.seller_id(from the store), so filters and invoicing do not depend on a connection's later state. - Invoicing follows the money: a payment or order → its seller → that seller's invoicing connection. Today
patientinvoices.Repository.Connection(orgID)picks one per org at four call sites (reconcile, overview, issue, backfill). The FGO connection'stax_idmust equal its seller's. - Clinic UI:
- Setări → Firme: the primary seller plus "Adaugă firmă"
- a seller picker on connecting a Stripe account, an FGO account or a store, and in the offer editor (shown only when the clinic has more than one seller)
- a seller filter on Sales (Plăți, Comenzi, Facturi)
- The patient sees which company sold what, on orders, payments and invoice downloads.
- Not
organization_billing. That table is the clinic as the platform's customer (F12) and is unrelated.
For the owner and counsel — not a code decision:
- The clinic stays the data controller for care. An additional seller is a separate controller for its own sales, not a joint one.
- The patient-facing privacy notice and the terms must name the additional seller and say that its orders appear in the clinic's patient account.
- The platform processes the additional seller's order data, so that company needs to be covered by a DPA with RestartiX as well.
Settle these before an additional seller's orders appear in a patient's account in production.
F14.11 Fiscal invoices from shop orders
The F14.8 engine (reconcile → worker → FGO, invoice / storno / replacement chains, one in-flight call per company) issues invoices for shop orders as well. They appear on the same Facturi page as Stripe payments.
- Source: an invoice row points at either a
patient_paymentsrow or ashop_ordersrow (exactly one, enforced by a CHECK). Since the table then no longer holds only patient-payment invoices, this migration is the moment to renamepatient_payment_invoices→fiscal_invoices(the glossary's term). Decide when building it. - Connection: the order's seller's FGO account (F14.10).
- Lines from the order, not one line per payment: each product line with its own
vat_rate, a shipping line, and discounts once their shape is verified.invoicingprovider.Linegains a per-line VAT rate; the Stripe path keeps using the connection'scota_tva. - Buyer:
- a company order (
billing_type = company) goes out as a company buyer withcompany_tax_idandcompany_reg_number;invoicingprovider.Buyergains these - an individual goes out as today, without a CNP
- the address is read live from the store at issue time and not stored
- a company order (
id_extern: the MerchantPro order number, so the accountant finds the invoice from the order.- Already invoiced in MerchantPro: an order whose
provider_invoice_refis set shows "Facturată în MerchantPro" and cannot be issued here. This is the duplicate guard for the switchover, which is just the day the accountant stops issuing invoices in MerchantPro (they are manual there today). - Per-store settings (the store connection's
config):invoice_series: overrides the FGO connection's seriesauto_issue: invoice orders paid online as soon as they are paid; off by default, like F14.8- cash on delivery is invoiced manually (owner, 2026-10-26). Automatic invoicing at shipped or delivered is a later option for clinics; it is the reason the shipping events are already subscribed in F14.9.
- recording the payment on the invoice follows F14.8's
record_payment(off by default)
- Refunds: a canceled or refunded order after invoicing gets a storno of the current invoice. Partial refunds wait until a real one shows which fields MerchantPro uses.
- Email: the F14.8
invoice_readyemail to a linked patient, with the download on the order in the Portal. Open: how a buyer who is not linked to a patient receives their invoice (the activation link is the likely answer). - Permissions: unchanged from F14.8 (
payments.invoice,payments.view).
Open before building
- Discount / coupon fields and partial-refund amounts, from real orders.
- Whether MerchantPro's order list can filter by update time (it would make the refund sweep cheaper).
- Display-only linking by verified email (F14.9, option 4): owner's call.
- Rename to
fiscal_invoices(F14.11). - The legal items under F14.10.