Skip to content

Commerce model ​

Status: step 1 (the restructure, migration 000064) applied to production 2026-10-31; step 2 (the integration sync, migration 000065) built on branch feat/commerce-sync, not applied to any environment; steps 3–8 designed, not built. The owner set the direction in a design conversation on 2026-10-26 → 2026-10-29. This doc is the authority for everything a clinic sells. Where it disagrees with an older doc, this doc wins and the older one is a rename target.

Supersedes:

  • the access_offer_* naming and the split between Stripe checkouts and shop orders (F14.1–F14.8, built)
  • the deferred F2.2 Offering packages (service_plans, patient_service_plans) and F2.3 Products
  • the shop_orders draft in shop-commerce.md → F14.9–F14.11

Built F14 behaviour (checkout, guest purchases, shop activation, refunds, FGO invoicing) is kept. What changes is the shape it is stored in.

Why ​

Commerce grew one channel at a time, and each channel brought its own tables:

  • external shop → access (F14.1–F14.3)
  • campaigns (F14.4)
  • Stripe checkout (F14.6)
  • guest purchases (F14.7)
  • FGO invoices (F14.8)

The result is 12 tables, with two concepts missing and their work spread across tables named for something else:

GroupTables today
What is soldaccess_offers, access_offer_items, access_offer_prices, access_offer_sku_bindings, access_offer_campaigns, access_offer_campaign_options
A purchaseaccess_offer_checkouts (Stripe attempt), access_offer_orders (shop order)
Moneypatient_payments, patient_payment_customers, patient_payment_invoices
Access receivedaccess_offer_fulfillments → patient_content_grants, patient_subscriptions, webinar_enrollments

The problems:

  1. There is no order. "Somebody bought something" exists twice:
    • access_offer_checkouts has buyer fields and an activation link (buyer_email, claim_token_hash, claim_expires_at).
    • access_offer_orders has the same again under other names (claim_email, claim_token_hash, claim_expires_at).
    • Neither records the products bought or their prices.
  2. Names that lie:
    • patient_payments has had a nullable patient_id since guest purchases (000062).
    • Checkouts and orders are purchases, not parts of an offer.
    • The invoices table will hold more than patient-payment invoices.
  3. Untyped links. access_offer_fulfillments.trigger_ref is free text pointing at a checkout, a shop order, a campaign or a legacy subscription. No foreign key checks it.
  4. Missing pieces:
    • Services can't be sold. Leo's packages ("3 consultations") have no home, and the "N sessions remaining" gap stays open.
    • Physical products don't exist in the platform, so a specialist cannot recommend one in an appointment.
    • Front-desk sales (cash, card terminal) exist only as "staff applied the offer".
    • There is no seller: two companies, two Stripe accounts, two FGO accounts.
    • Prices belong to Stripe (access_offer_prices: "the provider owns the amount"), so a desk sale or a shop sale has no price of ours.

Principles ​

  1. Doing ≠ selling ≠ holding. Three separate layers:

    • offerings is what the clinic does: the operational spine of a service (specialists, duration, forms, calendars, appointments).
    • commerce_products is what it sells.
    • patient_* is what a patient holds: content grants, a plan, webinar enrollments, service credits.

    A holding can come from a sale, a prescription or a staff decision. It links to the sale when there was one.

  2. One sale is one order, whatever the channel: Portal, campaign, online booking, external shop, front desk, legacy migration. Free is an order at 0, so the patient has one history of everything they received and bought.

  3. Every product has one owner. Either the platform or the integration it came from (a MerchantPro physical product). The other platforms hold listings of it:

    • synced automatically where the integration allows it (Stripe)
    • imported read-only where the integration owns it (MerchantPro; also a price staff made in the Stripe dashboard, until it is mapped to a product)
    • linked by hand where neither works (today's SKU bindings)
  4. A sale of an integration-owned product is placed in that integration. Stock belongs to the shop, and the platform never keeps stock. A front-desk sale of a MerchantPro product creates the order in MerchantPro, and the webhook brings back the same order.

  5. Prices are ours. Stripe receives copies. An order line records the price actually paid, which can differ: the shop's own price, a campaign, a discount.

  6. An order belongs to a patient only by proof (owner, 2026-10-26):

    • a purchase made while signed in
    • the post-checkout link or the activation link
    • staff attaching it, including a customer link staff set to the patient (commerce_customer_links): that customer's later orders and invoices attach on their own (owner, 2026-10-30), because the proof is the staff link, not the email

    Never by matching a typed email, even when it matches.

  7. Every sale has a seller: the company on the order, the payment and the fiscal document.

  8. Provider-neutral. No provider name in a table, column or CHECK list. A provider appears only in its catalog row and its adapter.

    • A reference to an object in another system is always external_*_ref (external_customer_ref, external_payment_ref, external_session_ref, …). Never provider_*: in the glossary a provider is a Cat A curated provider, and payment, shop and invoicing integrations are Cat B connected accounts.
    • Which system a row came from is its integration_id, never a copied provider-name column.
  9. Nothing is dropped (owner, 2026-10-30: "we need to see everything, even if it is not linked properly, to act on it"). Every object an integration sends us or lets us list is stored:

    • what we can match is linked
    • what we can't is kept unlinked, with the reason, in a list staff act on

    No webhook ever answers "ignored" and forgets. The 2026-10-27/28 production cases came from exactly that: Stripe invoices and a subscription made by hand in the clinic's Stripe dashboard, which the platform received and discarded.

  10. Naming: every table in this domain is prefixed commerce_. That keeps clinic → patient selling apart from F12's platform → clinic billing, which will have its own invoices and payments. What a patient holds keeps its own prefix (patient_*, webinar_*).

  11. No integration is required (owner, 2026-10-30). A clinic with no payment, shop or invoicing integration uses products, prices, free orders, desk sales, service credits and the patient's history fully. Three rules make that true:

    • An integration column is NOT NULL only on rows that exist because of an integration (listings, customer links, sync jobs). Prices, orders, payments and fiscal documents allow none.
    • Online purchase is an explicit check: Buy appears in the Portal and on campaign pages only when the product's seller has an online payment connection. Today a clinic without Stripe shows nothing to buy only by accident, because a price can't exist without a Stripe price.
    • A fiscal document issued outside the platform can be recorded (no integration, series and number typed by staff), so the accountant still sees every document in one place. Cash at the desk without a cash-register integration: the platform records the payment, and the clinic issues the fiscal receipt on its own device. The UI says so and does not imply otherwise.

    A clinic with zero integrations is an acceptance check for every build step.

The model ​

Who sells   commerce_sellers
Catalog     commerce_products  (owner · seller · kind · VAT · delivery)
              ├─< commerce_product_prices      one-time / recurring · optional specialist / location
              ├─< commerce_product_components  a bundle's contents
              └─< commerce_product_listings    Stripe / MerchantPro / Woo: synced | imported | linked
            commerce_discounts                  reserved
            commerce_campaigns ─< commerce_campaign_options (→ product + price)
Sale        commerce_orders ─< commerce_order_lines (→ product · price paid · VAT · appointment)
Money       commerce_payments (→ order) · commerce_customer_links (patient ↔ external customer)
Documents   commerce_fiscal_documents (→ order, + payment) · invoice | storno | receipt
Delivery    commerce_fulfillments (→ order line)
              → patient_content_grants · patient_subscriptions · webinar_enrollments · patient_service_credits
Sync        commerce_sync_jobs · commerce_review_items ("De rezolvat")

commerce_sellers ​

A company that sells through the clinic. Exactly one primary per org, its own company, and optionally additional sellers: another legal entity, often with the same owner, that sells through the clinic's channels with its own accounts.

  • Columns: legal_name, tax_id, reg_number, is_primary, default_vat_rate.
  • The migration creates a primary seller for every org.
  • Payment, invoicing and shop connections each belong to a seller (organization_integrations.seller_id). The one-per-org indexes (uq_org_integrations_payments_one_per_org, uq_org_integrations_invoicing_one_per_org) become one per seller.
  • Not organization_billing, which is the clinic as the platform's customer (F12).

commerce_products ​

One row per thing the clinic sells. kind decides what it delivers:

kindDeliversTarget columns
servicecredit_quantity service credits for an offering (default 1)offering_id
contentaccess to a program, session or webinar for a termcontent_type, content_id, term_kind, term_count, term_unit
plana patient plan for a termtier_id, term columns
physicalnothing in the platform: shipped by the shop or handed over at the desk—
bundleits componentscommerce_product_components

Other columns:

  • name, invoice_name (today's access_offers.invoice_name), description
  • seller_id, vat_rate (null = the seller's default)
  • status (draft · active · archived)
  • owner_integration_id: null = the platform owns it; otherwise the integration that owns it, whose catalog is the truth for its name, price and VAT

A single item and a bundle are both products. "The program has a price" is a UI shortcut: Vinde acest program / Setează preț on the program, webinar or service page creates a one-item product. This avoids price columns spread over programs, webinars and offerings, and a price is never just a number (variants, currency, VAT, seller, sync).

A service's price list is the offerings list with a price column, read from each offering's product or products.

commerce_product_components ​

bundle_id, component_product_id, quantity, optional term_count / term_unit override (3 months inside a bundle, 12 alone), position.

  • One level only: a component is never a bundle.
  • A component can be free inside the bundle. The bundle has one price, and its invoice line is the bundle.

commerce_product_prices ​

product_id, kind (one_time · recurring), recurring_interval + recurring_interval_count, amount_minor, currency, active.

  • Optional scope: specialist_id, location_id. The most specific price wins: specialist + location → specialist → location → base. Scope applies only where they are known (booking, front desk). Bundles, content and plans have base prices only.
  • A recurring price is allowed only on a product that delivers exactly one plan or one content item, the thing a renewal extends (today's rule for offer prices, 000061).
  • Many prices per product, as in Stripe: one-time, monthly and yearly side by side, plus scoped ones. Today an offer has exactly one (uq_access_offer_prices_offer).
  • An amount never changes once the price exists (owner, 2026-10-30). Changing a price adds a new row and deactivates the old one; the UI does this behind a plain edit. Existing subscriptions stay on what they bought, and the Stripe copy never disagrees, since Stripe prices can't be edited either.
  • Stripe gets one Stripe price per row of ours, through the listing.

commerce_product_listings ​

A product's presence in another system: product_id, integration_id, price_id (when the external object is a price), external_product_ref, external_variation_ref, external_price_ref, mode, sync_status, last_error, synced_at. A shop SKU is listed once per connection; a price once per connection (uq_commerce_product_listings_price on price + integration). The same external price may list more than one product: the old binding allowed it, and an incoming object is matched to a product through the order that sold it.

modeMeaningExample
syncedthe platform owns it and pushes itStripe product + prices, created and updated on the clinic's account
importedthe integration owns it; the platform reads ita MerchantPro physical product: name, price, VAT and stock-free details kept in step from the store (product webhooks + a sweep)
linkedmatched by hand; nothing syncsa MerchantPro product that grants a program (today's SKU binding)

Replaces access_offer_sku_bindings and the provider ids on access_offer_prices.

commerce_discounts: reserved ​

Not built until needed. code, kind (percent · amount), applicable products, validity, usage limit. Built from the start: discount_minor on orders and lines (0 until discounts exist), so a recorded total never needs migrating. discount_id comes with the table: a reference to nothing would be a column without a meaning.

commerce_campaigns, commerce_campaign_options ​

A public page that sells products: today's campaigns, renamed. An option references a product and a price instead of an offer. catalog_entries.access_offer_id (the page offer) becomes product_id.

commerce_orders ​

One per sale.

  • Who and where: seller_id, channel (portal · campaign · booking · shop · desk · staff · legacy · external). staff = given by staff: an offer applied from the patient's Subscription tab (trigger ref apply:<idempotency key>), or a gift sent to an email address and activated through its link. Offers applied before 2026-11-02 were applied at prescription hand-over; those orders keep protocol_id naming the prescription and the ref protocol:<id>, and new ones leave protocol_id null. external = made directly in an integration's own dashboard, outside the platform (see Integration sync). For shop orders, integration_id + external_order_ref + external_order_key (MerchantPro public_code, Woo order_key). campaign_id / campaign_option_id for attribution.
  • Where the buyer came from: utm_source, utm_medium, utm_campaign, utm_term, utm_content, the standard UTM parameters on the link that led to the order, one column each so clinic analytics group and filter on them. Written by every path that starts from a visitor's link (a checkout, a free campaign claim, signed in or not), cleaned by internal/core/attribution: the five keys only, trimmed, lowercased (so Facebook and facebook are one source), at most 200 characters; null when not given. A repeated free claim keeps the first order's. Shop, staff, legacy and external orders have none. They are never stored anywhere but the order: carrying them across visits (a cookie) would be measurement needing consent. Staff read them in the Sales order dialog (GET /payments/orders/{id}).
  • For whom: patient_id (null until linked, see principle 6). Buyer snapshot: buyer_name, buyer_email, buyer_phone, billing_type, company_name, company_tax_id, company_reg_number. No postal address: it is read from the source (Stripe, the shop) when a document needs it, as F14.8 does today. Never a CNP.
  • Money: currency, subtotal_minor, discount_minor, total_minor. A 0 total is a free order. Totals are null only on a shop order whose amounts were never read (the old shop table never stored them); the shop sync fills them in.
  • Hosted checkout and recurring billing: integration_id, external_session_ref, external_subscription_ref, subscription_ends_at, carried over from access_offer_checkouts. A renewal is a payment on the same order, so the order is where an integration's subscription lives.
  • State: status is the order's own lifecycle: pending (waiting for payment) · paid (paid, waiting to be delivered or activated) · completed · expired · failed · canceled · refunded · unmatched (a shop order with nothing of ours in it). paid_at, completed_at (kept when a completed order is refunded), expires_at, needs_refund. draft arrives with the front desk. How much money moved is read from payments, never duplicated here. external_status / shipping_status (a shop's own states) come with the shop sync.
  • Activation: claim_email (where the activation link goes; staff may correct it, buyer_email keeps what was typed), claim_token_hash, claim_expires_at, claim_sent_at. One mechanism for guest purchases and shop orders alike.
  • Idempotency: idempotency_key makes a free order exactly-once: a claim, a staff hand-over or a copied legacy subscription is keyed trigger:ref:product:patient, the key the old fulfillments table was unique on. Hosted checkout and recurring billing carry external_session_ref, external_subscription_ref, external_customer_ref, subscription_ends_at.

commerce_order_lines ​

order_id, position, kind (product · shipping · fee), product_id (null for an external line with no product of ours, such as shipping), price_id (null only on a line older than its price's record), snapshot name (the SKU and external refs come with the shop sync), quantity, unit_amount_minor, discount_minor, vat_rate, total_minor, appointment_id (a consultation paid at booking or at the desk).

commerce_payments ​

Money that moved, renamed from patient_payments. Today's columns stay (amounts, refunds, provider IDs, periods, payouts), plus:

  • order_id (required): a renewal is a new payment on the same order
  • method (card_online · cash · card_terminal · bank_transfer · cash_on_delivery · external)
  • integration_id, nullable: cash has no integration. The copied provider column and its CHECK (provider IN ('stripe')) are dropped (principle 8), and provider_* ids become external_*_ref.
  • recorded_by_principal_id, for desk payments
  • seller_id and patient_id, denormalised for filters

Refunds stay a running total (refunded_minor) for integration payments. A desk refund needs who, when and how, so step 7 adds commerce_refunds, additively.

Shop payments become rows with method = external when the shop reports the order paid. That makes Sales → Plăți the clinic's whole incoming money. Open: a shop refund has no amount from MerchantPro, so it is recorded as full.

Renamed from patient_payment_customers. Not a customer list: the patient is the only person, and the buyer of one order is a snapshot on that order. A row links a patient to their customer in one external account (patient_id, integration_id, external_customer_ref), one per account, so two Stripe accounts give two rows.

  • Why it exists: a returning patient reuses the same Stripe customer (saved card, one person in the clinic's dashboard), the Portal's "manage subscription" opens that customer's billing page, and the sync recognises renewals and dashboard-made invoices by customer.
  • patient_id becomes nullable: a customer created in Stripe's dashboard arrives unlinked and waits in "De rezolvat" until staff link it. Linking it attaches its later orders (principle 6).
  • Not a column on patients: one per account, and no provider ids on the patient record.

commerce_fiscal_documents ​

Renamed from patient_payment_invoices. order_id (required), payment_id (when the document is for one payment, such as a renewal), kind (invoice · storno · receipt), and the F14.8 chain and worker columns as built.

  • The connection is the seller's invoicing integration, or none for a document issued outside the platform and recorded by staff (principle 11).
  • receipt reserves the fiscal receipt (bon fiscal) a cash register issues for a desk sale: a cash-register integration later adds it with no restructuring.

commerce_fulfillments ​

Renamed from access_offer_fulfillments. order_line_id replaces trigger_type + trigger_ref, a real foreign key. Campaign claims, staff gifts and legacy copies are orders too (principle 2), so every fulfillment has a line. Each one mints its holdings, and they point back here: patient_content_grants.fulfillment_id (built), plus the same column on patient_subscriptions, webinar_enrollments and patient_service_credits.

commerce_sync_jobs ​

The integration sync's queue: one row per webhook event, object to re-read, sweep or Sync-button run. integration_id, kind, external_ref, status, attempts, next_attempt_at, locked_until, last_error. One in-flight row per account (the F14.8 worker shape). A work queue: finished rows are purged after about 30 days, so it is a flat table, not partitioned (P41 applies to multi-year event logs).

commerce_review_items ​

The "De rezolvat" list: something the sync stored but could not link, or an order where something went wrong after it was paid. One typed reference per row (order_id · customer_link_id · listing_id, exactly one set: a real foreign key, as commerce_fulfillments.order_line_id replaced a free-text ref), reason (no_reference, unknown_checkout, awaiting_activation, fiscal_document_failed, refunded_still_billing on an order; unknown_customer; unmapped_price), resolved_at, resolved_by_principal_id (null = the system), resolution (linked · mapped · adopted · activated · voided · dismissed · issued · ended) and resolution_note, required on dismissed. One table rather than a flag on three, so the list is one paged query and keeps who resolved what. A payment or a fiscal document is never an item on its own: every one belongs to an order, and the order is the item.

Problems with a paid sale (000065). Three reasons are not links the sync could not make but something that went wrong after payment, and each card says what to check and what each button will do; every button asks first. Linking or adopting the order never closes them (ResolveReviewItemsAdmin skips them); each has its own resolution:

  • fiscal_document_failed — a fiscal document of the order failed at the invoicing provider. Kept by a trigger on commerce_fiscal_documents: opened (or reopened, however it was closed) when one fails, resolved issued once none has failed and the one that moved is done. The card lists the documents not done. A refused document is sent again after its cause is fixed. A storno whose answer was lost (outcome_unknown) cannot be looked up and the provider refuses a second one, so staff look in the provider and either send it again (none there) or record its series and number (record-found, checked at the provider first).
  • refunded_still_billing — a subscription payment was refunded in full while the subscription still renews: a refund alone does not stop the next charge. Opened when a refund arrives from the provider (made in its dashboard) and the subscription read then still renews; a refund made in the Clinic is not flagged, since staff chose there whether it stops. Resolved ended when the subscription stops renewing, from the card's Stop (payments.refund, reads the state first) or anywhere else. Kept on purpose (a goodwill refund) is closed with a note.
  • invoice_not_emailed — an issued fiscal document (invoice or storno) has an "invoice ready" email that did not go: failed, bounced, marked as spam, switched off, or nobody to send it to. The document carries what became of its email (email_status, email_at, email_count, 000065 §2c): the invoicing worker writes pending when it queues one and reads notify's outcome back while email_watch_until is ahead, since a bounce arrives after the send. notify stays the record of truth; the columns exist because the payments list filters on them at scale. A trigger opens the card when a document's email turns bad (reopened by a later failure), and resolves it emailed once none of the order's has and one is on its way. Closed by hand, it stays closed until another email fails. Only emails that fail from 000065 §2c on open a card; earlier documents were backfilled before the trigger existed. The card sends the email again (payments.invoice, asks first).

Who gets the invoice email. The patient registered for the payment gets a sign-in link to their Portal payments page, never the document. A purchase nobody activated has no Portal, so it goes to the address the order was bought or claimed with, carrying the invoicing provider's public download link (owner, 2026-11-04). Both follow the seller's send_email setting when a document is issued; staff can send either again from the row, the card, or the bulk action, which skips documents whose email went or is on its way.

patient_service_credits ​

New. What a patient holds of a service: patient_id, offering_id, quantity, valid_from, valid_until, and its source, either fulfillment_id (bought, or free in a bundle) or patient_subscription_id + period (a plan's inclusion).

  • patient_service_credit_uses (credit_id, appointment_id, used_at, released_at) records each use. A cancellation inside the rules releases it.

  • Rules are clinic settings with defaults, overridable per product and snapshotted onto the credit when it's minted, so changing a setting never changes credits already sold:

    • consume_on: booked · completed
    • noshow_consumes
    • late_cancel_hours
    • default_validity_days

    They live in organization settings.

  • Pay at booking is the same mechanism: buying one credit and using it on that appointment in one step. A consultation paid online, at the desk or from a bundle then reads the same way on the appointment.

  • Plan inclusions ("this plan includes 4 consultations a year", the planned patient_tier_inclusions in patient-catalog-and-access.md) mint credits each plan period. One counter for both sources, which is what that doc asked for ("services → limits / inclusions, counted").

The flows ​

FlowWhat happens
Portal / campaign checkoutorder pending (patient set when signed in) → Stripe checkout → paid webhook → payment → completed → fulfillments. A guest's order waits for activation (F14.7, unchanged).
Online booking with paymentthe appointment holds its slot → order with one service line (appointment_id, the specialist / location price) → checkout → payment → a credit minted and used on the appointment.
External shopwebhook queues a sync (F14.9's worker, unchanged in shape) → the order upserted by (integration, external_order_ref) with its lines → lines with a linked / imported product fulfil through the activation flow → method = external payment when paid.
Front deskstaff build a draft order (from an appointment, or free-standing), with the patient known. Lines for products owned by an integration are placed in that integration (MerchantPro order via its API), and its webhook returns the same order. Payment recorded by method. A fiscal document per the seller's settings.
Free (campaign claim, staff gift)an order at 0. Signed in, or a gift to a patient picked from the list: completed at once, fulfilled. Signed out, or a gift by email: pending until the activation link is used (see Activation).
Renewala new payment on the original order, extending the holding.
Refundon the payment, as today; fulfillments revoked as today (F14.5); storno + replacement per F14.8.
Legacy migrationan order per legacy purchase, channel = legacy, so the 20k patients arrive with a history.
Made in Stripe's dashboardthe sync stores it as an external order (invoice or subscription → order, charge → payment, customer → customer link, price → imported listing), unlinked, in Sales → "De rezolvat". Staff link the customer to a patient and map the price to a product. Access is then fulfilled and the payment invoiced in FGO like any other.

Activation ​

Specified 2026-11-01 with the owner; not built yet. Today the Portal still has two activation pages (/activate/[token] for our checkout's guest purchases and dashboard sales, /claim/[token] for shop orders) over two endpoints, built before this model, and a free claim that goes through account sign-up instead. This section is what replaces them.

One rule: getting something without an account always ends in the same activation link. Every order a buyer did not make signed in (paid on any channel, or free) carries the activation fields on commerce_orders (claim_email, claim_token_hash, claim_expires_at, claim_sent_at) and an email with one link, /activate/[token]. A signed-in purchase or claim never needs it: it activates on the spot.

How each kind of order arrives ​

Arrives fromSigned outSigned in
Campaign, paid option[Cumpărați] → Stripe (email + card) → back: „Verificați e-mailul" → email → activation link[Cumpărați] → Stripe → back → activated directly
Campaign, free option[Revendicați] → their email, or our own Google / Facebook buttons → email → activation link (with Google / Facebook: signed in, so claimed at once)[Revendicați] → activated directly
Direct checkout link (/buy/…, straight to payment)as a paid campaign option, without the campaign pageas a paid campaign option
The clinic's shop (MerchantPro)pays in the shop → our order page: „Verificați e-mailul" → email → activation linklands signed in → activated directly when it is the same email, else the "which account" question below
Made in Stripe's dashboardemail → activation link; until staff say what was sold: „Plata confirmată, clinica finalizează"the same link
Gift by email (staff, Clinic app): newstaff pick the offer and type an email → a 0-lei order (channel = staff) → email „{clinic} v-a oferit …" → activation linka patient picked from the list can be given it at once, as today, with an email saying so
Library (signed-in patient)n/aStripe → back → activated directly (unchanged)

A free claim is the same flow with the payment step left out: a 0-lei order, as The flows already says.

The activation page (/activate/[token]) ​

One page for every order, whatever its channel. /claim/[token] links already sent redirect to it. One public endpoint resolves a token to the order (what was bought, its state, the masked email), and one activates it.

  • Signed out: [Activez achiziția] creates or finds the account for the link's email, with no password: the link reaching that inbox is the proof. Under it, „sau", then our own Google and Facebook buttons (SocialSignInButtons, a shared component: our styling, the providers' logos, never Clerk's drawn UI). They start the provider's sign-in through Clerk's API and return to this page signed in. Someone new to the clinic joins it first (details and the clinic's documents), then activation runs.
  • Signed in as the link's email (or just signed in from this page with Google / Facebook): activates at once.
  • Signed in as another account: „Sunteți conectat ca X. Activați achiziția pe acest cont?" [Da, pe acest cont] / [Folosesc alt cont]. Opening the link proves the inbox; which account receives the purchase is the inbox owner's choice, and the activation is recorded like any other. (The one-tap sign-in link keeps its stricter rule, sign out first, because it opens one specific existing account.)
  • Afterwards: „Achiziție activată", centred, with the way into the account.
  • Bought for somebody else (owner, 2026-11-02): signed out, before activating, after the ways in and another „sau": a button „Oferiți achiziția cadou". The buyer types the recipient's address; the recipient is emailed their own link, worded as a gift, and the buyer's link stops working (a new token, as a resend). One action for every channel (our checkout, the shop, a dashboard sale), because it sits on the page they all share; the link in hand is the proof, as for staff correcting an address. Paid purchases only: a free claim's recipient claims it themselves, and a clinic's gift is for the person it went to. Built (POST /v1/public/purchases/forward, any proof).
    • The recipient gets the access, never the payer's billing (owner, 2026-11-02, found in the first live test). Activating a guest purchase attaches its Stripe customer to whoever activates, so a forwarded purchase would hand the recipient the payer's card, invoices and billing page (and, for a dashboard sale, every later sale of that customer). So the forward marks the order (commerce_orders.forwarded_at, in 000065): activating it then never attaches the customer (our checkout) and links that sale alone, not its customer (a dashboard sale); the payer's return address shows it as sent on and no longer resends or claims it (purchase_forwarded, the payer signed in included), so the recipient's link is the only way in. Sending it on again, to correct an address, stays possible from the recipient's link or the return page.
    • A purchase paid automatically is never forwarded (recurring_not_forwardable; the page does not offer it): its card and invoices are the payer's and every renewal charges them, so whoever activates it would control the payer's payment. Paying monthly for somebody else needs a model where the payer keeps the payment; not designed yet.

A Google or Facebook account whose address differs from the purchase email is normal (the reason the one-tap link compares account ids, never emails), and activating on it is allowed by the rule above.

Social sign-in buttons ​

Custom buttons over Clerk's API, not Clerk's components: the button starts the provider's sign-in (oauth_google / oauth_facebook) with a return address; a small callback (the page itself, or /sso-callback) completes it with Clerk's UI-less redirect handler, including the sign-up Clerk makes of a first-time Google sign-in. Checked 2026-11-01 against clerk-js 6.42.1 (what @clerk/nextjs 7.2.5 loads): useSignIn().signIn.sso({ strategy, redirectCallbackUrl, redirectUrl }) creates the sign-in and sends the browser to the provider; the callback page renders <AuthenticateWithRedirectCallback />, which completes it, turns a first-time Google or Facebook account into a sign-up by itself (transfer), activates the session and goes on to redirectUrl. The method returns { error }, as the rest of v6's sign-in resource does. The callback takes the return address from our own query parameter, checked to be a path on this host. A clinic's own domain is a Clerk satellite and cannot sign anyone in; the proxy already forwards /activate and /claim to the main domain with ?back= (TICKET_LINK_PATH), so the buttons always run there and the patient returns to the clinic's domain afterwards. The dev instance has Google and Facebook enabled and asks only for an email at sign-up, so no Clerk screen appears in between; the production instance must match (owner check).

Rules the one path keeps ​

  • Links already sent keep working. Production has emailed both /claim/[token] (shop) and /activate/[token] links. Both hash the raw token the same way (SHA-256 of the base64url string) into commerce_orders.claim_token_hash, so one resolve keyed on that hash serves both. One hash function, and a test proving a token issued by the old shop code resolves through the new endpoint.
  • A link survives its activation. Reopening a used link shows the purchase; only a resend (a new token) ends the old one. Pressing the button records claim_opened_at (the first press only; merely opening the page changes nothing, because mail scanners prefetch links); a resend clears it.
  • A signed-out free claim cannot be used to send email. The endpoint is public and mails whatever address is typed in, so a wrong delivery is a real person receiving someone else's message. It is rate-limited per IP and per email, and idempotent per (campaign option, normalised email): a repeat reuses the pending order and resends only after a cooldown, never a new order and a new email each time.
  • One per patient is decided at activation. A free offer is one per patient, and a signed-out order has no patient yet. Activating on an account that already holds the offer ends cleanly: "already yours", no second fulfillment, the order closed and its review item cleared. The same holds on the "another account" path.
  • Unchanged from the current pages: a dashboard sale waiting for staff shows „{clinic} finalizează"; names shown to the buyer never show a payment provider's price or line reference; an unusable link still offers the way into the account.
  • Not on Sales → "De rezolvat": free claims and staff gifts are left out of the awaiting-activation review items on purpose (staff sent them); changing that is a change to the one existing trigger, never a second one.

What it replaces ​

  • /activate/[token] (purchase links) and /claim/[token] (shop order links) → one page, one resolve + activate path over commerce_orders. Done for paid orders: /v1/public/purchase-links/* and /v1/me/purchases/claim serve checkouts, dashboard sales and shop orders; /v1/public/order-links/* is gone; shop emails link to /activate; /claim/[token] redirects.
  • The free claim's account-first path (sign up, join the clinic, come back, claim) → the email or social step above. Backend done: POST /v1/public/campaigns/{campaignId}/claim (a waiting 0-lei order, idempotent per option and address, resend after 10 minutes, rate-limited per IP and per address); claimed, the order takes the signed-in claim's key, so one per patient is the existing unique index.
  • Gifts only to an existing patient → also to an email. Backend done: POST /access-offers/{offerId}/gift (patient_subscriptions.manage); the email is the activation email worded as a gift. Clinic done: Sales → fulfillments has „Oferă acces" (an active offer and an address) for staff with that permission; a gift joins the ledger once claimed. Not yet shown anywhere: gifts sent and not claimed.
  • A shop's activation email is a setting of its connection (send_activation_email in the connection's config, off by default; Clinic → the shop's integration page → „Comenzi plătite"). On, a matched paid order's buyer is emailed the activation link as the order arrives, at the shop's billing address, once (the guard is the same UPDATE that sets the link). Off, staff send it from Sales → Magazin, as before. Built.
  • The two after-payment landings, /purchase/[id] (back from our Stripe) and /orders/[ref] (back from the shop), are the same duplicate and merge next, after activation. Both addresses keep answering: Stripe returns to the first for checkouts already open, and the second is the shop's return address, may sit in the shop's own email, and is what Sales → Magazin's copy-link button hands out (which switches to the activation link). Merge design (owner, 2026-11-02): one page with /activate's card; the proof is the address's own (Stripe session id, or the shop's order reference + key, both resolving to one commerce_orders row) and it keeps today's power: activating on whichever account the buyer signs in with. The shop's address stays shop-shaped because the shop builds it and knows only its own reference. A buyer can land before the shop's webhook has delivered the order, which today reads as „Pagina nu este disponibilă"; the merged page waits for it („Confirmăm comanda") as it waits for Stripe. Built: both routes render ActivateView; the shop side reads GET /v1/public/shop-orders/{ref}?key=, waits on …/stream (published when the webhook stages the order), resends with …/resend, and claims through /v1/me/purchases/claim with order_ref + order_key. GET /v1/public/orders/{ref} and POST /v1/me/orders/{ref}/fulfill are gone.

Unchanged and separate: /open/[token], the one-tap link that signs an existing patient in and opens a page; nothing is bought.

Portal done (2026-11-01): /activate/[token] words itself by PurchaseView.kind, offers our own Google and Facebook buttons under the email button (SocialSignInButtons, /sso-callback), and asks „Activați pe acest cont?" when the signed-in account is not the link's (POST /v1/me/purchase-links/account), except straight after a social sign-in from the page (?via=social). A campaign's free option takes an email from a signed-out visitor, with the social buttons on the main domain only (a clinic's own domain is a Clerk satellite; there it offers the sign-in link instead).

The pages' states are previewed on the dev server at /dev/link-states.

Sales in the Clinic ​

Decided by the owner 2026-11-03; built in step 3. Each Sales tab answers one question, so a sale is never missing from the list it belongs on, nor listed twice.

TabAnswersRows
Comenziwhat was sold or given, to whomevery commerce_orders row, any channel (portal, campaign, free claim, staff gift, shop, Stripe dashboard, and later booking, desk, legacy) and any status
Istoric acceswhat each patient receivedcommerce_fulfillments, the delivery log
Plățiwhat money came incommerce_payments
De rezolvatwhat the sync could not linkcommerce_review_items
  • Comenzi replaces today's list of waiting purchases. That list showed only paid orders not yet activated and some subscriptions, so an activated one-time purchase, a free claim or a staff gift appeared nowhere in it (hit live, 2026-11-02).
  • Magazin folds into Comenzi as the shop channel filter. Its actions (re-check against the shop, send or copy the activation link) move onto the order dialog, and the Magazin tab goes away.
  • Unclaimed free links and gifts show as waiting orders (pending, 0 lei), with resend on the order dialog. Until now they were shown nowhere.
  • Gated on payments.view, as Plăți and the order dialog are. Istoric acces stays open to specialists under patient_subscriptions.view_org; Comenzi is not widened to match it.
  • One status vocabulary across channels. The shop's old pending / fulfilled / no_match translation ends here.
  • An unlinked Stripe-dashboard order links to its "De rezolvat" item; it is resolved there, never from Comenzi.
  • Server-side: paged, channel / status / date / patient (typeahead) / text filters through apiquery, the count from the same WHERE. The indexes exist since 000064 (org_created, org_channel, patient, waiting).
  • The plans list stays: it shows the state of each subscription, not the sale.

Order activity ​

Decided by the owner 2026-11-03. The order dialog has an Activity section: one timeline of everything that happened to the order, so staff never reconstruct it from the shop or Stripe. Raised by MerchantPro order 61399619 (paid, payment returned, paid again), which left nothing on our side but updated_at.

Sources, merged at read time (nothing is copied into a second table): the audit log under all three entity names an order has carried (access_offer_checkout for our checkouts, access_offer_order for shop orders, commerce_order for Stripe-dashboard sales; same id, since orders kept their checkout ids), commerce_payments (paid, refunded, renewals), commerce_fiscal_documents (invoice, storno), commerce_fulfillments (activated, revoked), and the activation emails in notifications with their delivery status (idempotency key order-activation:<order id>:<expiry>, so every resend is its own row). Built on core/activity with one vocabulary registration, as the program, protocol and session Activity tabs are. Read under the order dialog's own permission, payments.view (GET /payments/orders/{orderId}).

Recorded from now on (a separate change shipped before step 3, so history starts accumulating):

  • Every state change a shop webhook makes is audited under the system principal: paid (staged), refunded, canceled, unmatched, carrying the shop's raw status and the matched products. A delivery that changed nothing is recorded as such, so the history explains itself.
  • Each opening of an activation link is recorded (today claim_opened_at keeps only the first opening of the latest link).

Orders before that change show only what was already recorded: their timestamps, payments, invoices, activations, emails and the staff and checkout audit rows. A shop order's paid and refunded transitions before it are not recoverable.

Paid again after a refund restores the activation (owner, 2026-11-03). A refund revokes the line's fulfillment; when the same order is paid again (webhook or Re-check), that fulfillment is restored for the same patient and its grants re-minted, rather than a second fulfillment being created. One fulfillment per order line stays true. Before this, ClaimOrderLine found the revoked fulfillment and granted nothing, with no way for staff to fix it.

Shop order lines (step 3) ​

Found 2026-11-03; to build in step 3. Two gaps in how a shop order is stored today:

  • Every shop line is stored, matched or not. Today only matched products become commerce_order_lines; a line that matches nothing is dropped, so an unmatched order (24 of 248 MerchantPro orders in the 30 days to 2026-11-03, e.g. 16189792) keeps only its reference, code, status and timestamps, and staff open MerchantPro to see what it held. Step 3 stores each line with the shop's product id, name, quantity and price (the line SKU / external refs the restructure deferred), product_id null when unmatched, so Comenzi shows why an order did not match. Receiving every paid order stays deliberate: it is the safety net for a product the clinic forgot to link.
  • Re-check all unmatched. Linking a shop product never re-matches past orders, and Re-check is per order. After a link is added, staff can re-check every unmatched order of that connection at once.
  • Quantity is ignored. matchOffers returns each matched offer once and UpsertOrder writes quantity = 1, so 2 × „Plată pentru 1 an" (or two variants mapped to one product) activates once, for one year. What N means is an open decision (below); quantities are stored from step 3 either way, so the decision can act on them.

Integration sync ​

How the platform keeps up with Stripe, MerchantPro and any later integration. One mechanism for all of them.

  • Webhooks are the main path, and they are instant. An integration's event is verified, written to a sync queue, and answered at once. A background worker in every API instance (no extra AWS cost) processes it within seconds. Stripe Connect delivers events for the clinic's whole account, so objects staff create in the Stripe dashboard arrive the same way as the platform's own.
  • Platform → integration is a direct API call at the moment of the action (create a price, start a checkout, refund, place a MerchantPro order). No cron.
  • Safety nets, not the main path:
    • an hourly sweep that re-reads recent objects and fills anything a webhook missed (an outage, a deploy, an event type not yet subscribed; Stripe gives up redelivering after 3 days)
    • a Sync button for staff
    • a Sync from a chosen day, the last 30 days by default. Any day may be picked (owner, 2026-10-31: the 2026-10-01 limit was one clinic's first import, not a rule).
  • Matching:
    • Platform-made objects carry metadata.checkout_id, which is the order's id after the restructure.
    • Metadata alone is not enough (verified against production, 2026-10-30). The sync matches on four signals, in this order:
      • metadata.checkout_id on the object
      • for a subscription invoice, the subscription's metadata. invoice.metadata is empty there; checkout_id arrives only through parent.subscription_details.metadata.
      • a payment intent's payment_details.order_reference = cs_…, the Checkout Session id (stored today as access_offer_checkouts.provider_session_id). Subscription-mode Checkout creates the payment intent before the subscription and invoice. A failed card or an abandoned page leaves a payment intent with no metadata and no invoice, and this is its only link back. In the first prod run that was 108 of 116 false positives.
      • a payment intent's order_reference = in_… ties it to that invoice, even when invoice.payments no longer lists it (for example, a deleted draft).
    • Never match on a payment intent's description. FGO labelling overwrites it with the invoice number (for example "CIOZD 10100").
    • A customer is matched through a staff link to a patient.
    • A price is matched through a listing.
    • Everything else is stored unlinked (principle 9).
  • Per integration: the worker allows one in-flight call per account, paces calls and honours 429 / Retry-After (MerchantPro's limit is per minute).
  • Selling from the Stripe dashboard is not a supported workflow (owner, 2026-10-30). The sync is a safety net for when staff do it anyway: it detects, shows and lets staff fix. The UI says "prefer the platform" and offers nothing that makes dashboard selling convenient.

Stripe events to subscribe, beyond today's list: customer.subscription.created, invoice.created / finalized / sent / voided / marked_uncollectible, payment_intent.succeeded (one-time charges without an invoice). Not customer.*, price.* or product.* (owner, 2026-10-31): a customer or a price matters only through a sale of it, and a sale brings it in. A dashboard price no sale used never reaches "De rezolvat". The full list is stripe.SubscribedEvents().

Interim, before the restructure (built 2026-10-30; kept after step 2 as the Console's anonymised cross-check): a read-only "Not in the platform" report. It reads live from Stripe and lists everything since 2026-10-01 the platform didn't create, with no tables and no stored state, so nothing in it is redone. Staff handle the few cases by hand.

  • Adapter, which becomes the sync's Stripe adapter:
    • provider-neutral patient_payment.AccountObject (kind invoice | subscription | payment, provider status, customer, amount, metadata, subscription and payment ids)
    • Provider methods ListInvoices / ListSubscriptions / ListPayments(ctx, accountID, since) and ObjectURL
    • Stripe implementation in billing/patient_payment/stripe/stripe.go: invoices of any status, with the subscription's metadata merged in so a platform subscription's checkout_id reaches its invoices
  • Matching in domain/patientpayments/offplatform.go (Service.OffPlatform):
    • an object is ours when metadata.checkout_id names a checkout of this org, or (for an invoice) when its subscription is ours, or (for a payment intent) when order_reference names one of our Checkout Sessions (Repository.CheckoutBySessionAdmin) or an invoice. AccountObject.CheckoutID / InvoiceID are parsed from order_reference by prefix (commit 95728dee).
    • payment intents an invoice took are deduplicated against the invoice
    • reasons: no_reference, unknown_checkout. The sync's unlinked reasons start from these.
  • First production result (2026-10-30): 547 objects checked, 7 not in the platform. Two subscriptions with no payment, one paid invoice (RON 79, the only off-platform money since 1 October) and three drafts, two of them RON 0. All 324 cs_ references among unlabelled payment intents exist in our database. This is roughly the size of the first import's "De rezolvat" list.
  • Surface: GET /v1/admin/organizations/{id}/payments/off-platform?since= (superadmin), the Console card "Not in the platform". It shows a customer id, not a name or email: the Console is cross-tenant, and cross-tenant views are anonymised. The person is seen through the Stripe dashboard link. The clinic-facing "De rezolvat" list in step 2 is in-tenant and may show them; the adapter already carries them.

Mapping from today ​

TodayBecomesHow
access_offerscommerce_productsrename + add columns. A one-item offer becomes that item's kind; a multi-item offer becomes a bundle whose components are one-item products created from its items
access_offer_itemsproduct delivery columns / commerce_product_componentsbackfill, then drop
access_offer_pricescommerce_product_prices + a synced Stripe listingsplit
access_offer_sku_bindingscommerce_product_listings (linked)move
access_offer_campaigns, …_campaign_optionscommerce_campaigns, commerce_campaign_optionsrename; option → product + price
access_offer_checkoutscommerce_orders (portal / campaign)the order keeps the checkout's id: Stripe sessions carry it as client_reference_id and as the idempotency key, so sessions still open at migration time resolve
access_offer_orderscommerce_orders (shop)the order keeps its id; matched offers become lines
access_offer_fulfillmentscommerce_fulfillmentsrename; trigger_ref resolved to a line, with a 0 order created for campaign / staff / legacy rows that had none
patient_paymentscommerce_paymentsrename + order_id (from checkout_id) + method; provider column and its CHECK dropped
patient_payment_customerscommerce_customer_linksrename; patient_id nullable; provider_customer_id → external_customer_ref
patient_payment_invoicescommerce_fiscal_documentsrename + order_id; integration_id nullable (a document issued outside the platform); provider column dropped
catalog_entries.access_offer_idcatalog_entries.product_idrename
patient_subscriptions.payment_providerpatient_subscriptions.billing_integration_idthe connection that bills the plan (none = billed outside the platform); its CHECK named stripe / stripe_connect. The API still says payment_provider, read through the connection

Permissions follow: offers.* → products.*, payments.* stays, plus order codes (orders.view, orders.create for the desk). Exact codes are settled when the migration is written.

Production ​

Every table above is on production with real data: Stripe payments since 2026-10-21, FGO invoices since 2026-10-23. The restructure is therefore one forward migration, not a fold.

  • ALTER TABLE … RENAME where the shape is kept, so data, RLS and indexes carry over. Constraints, indexes and policies are renamed too.
  • Backfills as in the mapping, then the old tables are dropped.
  • Rehearsal is mandatory: restore a production dump into a scratch database (the restore drill), run the migration, and verify invariants. Every payment has an order; every fiscal document has an order; every fulfillment has a line; order totals equal their lines; counts are preserved.
  • A classification registry entry for every new and renamed column in the same PR (CI enforces it).
  • The Go domains (accessoffers, patientpayments, patientinvoices) move to the new names in the same change.
  • As always, the owner applies it to production and deploys.
  • Not carried over: the late-paid first invoice. The late-pay branch of settleInitialInvoice (1ab5be33) is unreachable from our checkout. Stripe Checkout creates the subscription and its first invoice only after payment succeeds. Production since 2026-10-01 shows 108 failed or abandoned subscription checkouts and zero subscriptions in incomplete / incomplete_expired (verified 2026-10-30). The new code drops that branch and its "page still open" / 503 handling. Kept: the resync of paid first invoices whose webhooks were dropped. The integration sync (step 2) takes it over as part of the hourly sweep.

Build order ​

  1. Restructure: the mapping above, behaviour unchanged. Sellers are created (primary only), so everything downstream can rely on them.
  2. Integration sync, Stripe first (owner, 2026-10-30): the shared queue, worker, sweep and Sync button; Stripe events and objects stored, matched or unlinked; the "De rezolvat" list with link-customer and map-price actions; FGO for adopted payments.
  3. Shop orders on the same sync, and one Comenzi: MerchantPro (the F14.9 mirror); Sales → Comenzi becomes the list of every order with Magazin folded in, and the order dialog gains its Activity section (see Sales in the Clinic); paid again after a refund restores the activation; Comenzi in the Portal. Before it: the order-event recording described under Order activity.
  4. Additional sellers: seller CRUD, connections per seller, two Stripe and two FGO accounts.
  5. Fiscal documents from orders: invoices for shop orders, VAT per line, company buyers.
  6. Services: products of kind service, prices per specialist / location, service credits, pay at booking.
  7. Physical products + front desk: imported listings from MerchantPro, desk orders, orders placed in MerchantPro, commerce_refunds for desk refunds. Cash-drawer closing (end-of-day count) belongs with a cash-register integration and is designed then.
  8. Discounts, when needed.

Step 1 as built ​

Migration 000064_commerce_model (with a down that restores the old shape, data included) and the code on top of it. Applied to production 2026-10-31 (as master with SET ROLE restartix; pre-migration snapshot restartix-production-pre-000064-20261031-1029).

  • Verified on local data: every fulfillment has a line, every payment an order, order totals equal their lines, patients agree across order, payment and fulfillment. Up then down reproduces the original schema and data exactly (columns, constraints, indexes, policies, triggers, functions, permissions, role grants, and the rows of every table touched). The whole chain also builds from scratch. All Go unit and integration tests pass, as does make check.
  • The HTTP API and the UI keep their words. Offers, checkouts, price bindings and shop-order statuses (pending / fulfilled / no_match) are what the routes and screens still say; the repositories read and write the new tables in those shapes. Each surface moves to the new vocabulary when its feature is rebuilt (steps 2–7), so the frontend changes once. The Go packages (accessoffers, patientpayments, patientinvoices) and the audit entity types keep their names for the same reason; audit history already uses them.
  • One permission renamed: offers.manage → products.manage, grants carried over.
  • Database functions added:
    • commerce_order_trigger_type(o) / commerce_order_trigger_ref(o): a fulfillment's trigger in the API's vocabulary, derived from its order.
    • commerce_fulfillment_sources(patient) (security definer): the provenance of the fulfillments a staff member can read. The ledger is open to staff who cannot see sales (a specialist under patient_subscriptions.view_org), and the order behind it is behind payments.view.
    • integration_provider_slug(integration) (security definer, org-scoped): the provider's name for a row that carries only its connection. Reading a payment must not require manage_integrations.
  • Triggers added: every new clinic gets its primary seller; a new payment, invoicing or shop connection defaults to the primary seller; a bundle holds no bundle; a price is never edited.
  • Kept narrower than the full model, added when their step needs them: product kinds service / physical and their columns, price scope (specialist, location), default_vat_rate on sellers (their company columns exist, empty until the seller UI), price_id on campaign options, listing sync columns, line appointment_id / SKU / external refs, discount_id, order draft, payment recorded_by_principal_id, fiscal document receipt.
  • A multi-item offer's components take the bundle's status and are left out of the offers list, as items were.
  • Rehearsed on a production dump (2026-10-31, production at 000063): up in 0.4 s, down in 0.2 s; every invariant holds; up then down reproduces production's schema and data exactly; the derived triggers and provider names match the originals (288 campaign, 178 checkout, 32 legacy, 97 shop, 116 staff fulfillments; "stripe" on 224 payments, "fgo" on 224 invoices). The 4 multi-item offers became bundles of 2. Production is applied as master with SET ROLE restartix so the owner creates the new tables and restartix_app inherits its grants.

Step 2 as built ​

Migration 000065_commerce_sync (with an exact down) and the code on top of it, on branch feat/commerce-sync. Not applied to any environment.

  • Owner decisions (2026-10-31): an off-platform payment is invoiced in FGO only once its customer is linked to a patient, and later payments of a linked customer invoice on their own; imported listings only for prices a sale uses; an item can be closed by hand with a reason, and reopens when its order changes.
  • Schema: commerce_sync_jobs and commerce_review_items (above). commerce_product_listings.product_id and created_by_principal_id are nullable on an imported listing (an external price waiting for its product, made by the sync); the shop-SKU unique index no longer covers price listings. commerce_order_lines gains external_line_ref (the sync's idempotent upsert) and external_price_ref (what a mapping fills the product in by); a product line of an external order may have no product until then. commerce_order_trigger_type / _ref name an external order's fulfillment external, referenced by the order. No permission is added.
  • Queue and worker (domain/commercesync): a webhook is verified, written once (unique on connection + event id) and answered; the worker in every API instance wakes at once in the instance that queued it, else polls every 5 s. One job per account in flight across all instances (the connection row is locked while a lease is taken); the worker pauses for the integration's pace after each job (Stripe: 50 ms). Lower priority first: webhooks, then a staff Sync, then sweeps. An event that came early is retried in 30 s; a rate-limit refusal backs the whole account off 30 s; anything else backs off from 30 s to an hour, and a job is given up after 72 hours (Stripe's own redelivery window). Finished jobs are purged after 30 days. P52 gained a queued variant for this, which cmd/check-inbound-webhooks enforces.
  • Safety nets: an hourly sweep of the last 72 hours on every connection (one per account across all instances), and Sync (POST /v1/organizations/{id}/sales/sync {since?}, payments.assign) from any day staff pick, the last 30 days by default. A scan lists invoices, subscriptions and payments and queues only what is behind: a paid invoice with no payment recorded, an outside object not stored or stored in another state.
  • Matching is the four signals above, through invoiceReason / paymentReason. A platform object whose events were missed is settled as they would have: a paid first invoice settles its purchase from its checkout, which reads paid. The late-paid branch of settleInitialInvoice, its 503 and the superadmin resync (POST /v1/admin/…/payments/resync, the Console card) are removed.
  • Storing what is not ours: a subscription → an external order (external_subscription_ref) whose invoices are its payments; an invoice without a subscription → an order of its own (external_order_ref = the invoice); a charge outside any invoice or checkout → an order of its own, re-read two minutes after it was made so an invoice that took it records it first; a payment taken on a checkout page the clinic made (a payment link) is read through that page, which says whether an invoice or a subscription owns it, and otherwise is stored at once with the page's lines. Lines keep the Stripe line and price; a price a listing maps gets its product, one nothing maps gets an imported listing. A customer gets a link without a patient, and a linked one brings its patient (the proof is the staff link). A paid order is never moved back; a deleted draft is cancelled; a voided or deleted object with no money closes its item as voided.
  • Adoption: once an external order has a patient and every line a product, its item closes as adopted. A sale with nothing saying what it was for (a charge, a line with no price, an empty invoice) stays open until staff choose its product (set-product, products.manage: the whole sale, or that line) or close it with a reason; once it is also paid, each line is delivered (TriggerExternal, for the period Stripe billed when it is a subscription), a plan's subscription is linked to the patient's plan so renewals extend it as for a checkout, a webinar's renewals extend its grants through the order, a refund of a one-time order withdraws what it delivered, and the order completes. From then on its subscription is the patient's like one bought on the Portal: on their subscriptions list (cancel and resume included), on the webinar's card as the monthly payment, and stoppable by staff from the patient's access (subscriptionRows; a checkout's own pages, links and webhooks keep reading checkouts only).
  • "De rezolvat" (Clinic → Sales, GET /v1/organizations/{id}/payments/review?state=&q=, payments.view): each item with who it bills (in-tenant, so the buyer's name and email are shown), the linked patient, the amount and the Stripe dashboard link. Actions: link to a patient (payments.assign, patient typeahead; on a customer, or a sale whose customer is known, the customer is linked so its later sales attach too; a sale with no customer, such as a payment link that made none, is linked alone), map price (products.manage; an active product, and a recurring price only on one that renews one plan or one webinar; the price is recorded inactive, never put on sale), mark resolved with a reason (payments.assign). Each is audited.
  • Purchases nobody activated (a guest's checkout, a shop order: paid, no patient) are on the list too, as awaiting_activation. A trigger on commerce_orders keeps the item true whichever path moves the order: open while it is paid with no patient, activated once it has one (the buyer's link, staff attaching it), voided once it is no longer paid. They stay on the Purchases tab as well; "De rezolvat" is another view of what needs attention.
  • Activation link (POST …/payments/review/{itemId}/send-link {email?}, payments.assign): for a waiting purchase, a shop order or a dashboard sale whose buyer is not a patient yet. A dashboard sale's link opens the same Portal page as a guest purchase's (/activate/{token}); claiming it links the sale as staff linking would (its Stripe customer too, so the buyer's later sales follow). The link keeps working for its buyer afterwards, showing the purchase as theirs, or as being finished by the clinic while its product is unknown (awaiting_clinic). commerce_orders.claim_opened_at records the first sign-in through the last link sent (any channel), reset when a new one is sent, so staff can tell an unopened link from one opened by somebody who has not finished joining.
  • How often it bills: a dashboard subscription keeps its billing interval on the order (recurring_interval, recurring_interval_count, as Stripe holds it), so staff see „pe lună” under the amount before they say what was sold; an order of ours reads it from its price.
  • To do / waiting: an open item is waiting when staff have nothing left to do on it (every product known, and a patient known or an activation link out that still works, so it waits for the buyer to activate or to pay), else action. Derived on read, never stored: a link expiring moves an item back to action without anything writing. The list filters by stage and by what an action item lacks (need: a product, a patient, or a new link after one expired), and …/payments/review/counts counts both for the tabs.
  • On the card, linking and choosing are a draft until staff confirm it (one "are you sure", then the products and the patient are applied in that order). Sending a link is immediate: the dialog is its confirmation.
  • Invoicing: a payment of an external order with no patient that is on the list is held: not invoiced automatically or by backfill, though staff can still issue it; linking the customer releases it.
  • Tested against the real schema with a fake Stripe (rlstest/commerce_sync_test.go): the queue and its dedup, a dropped checkout completion settled by its invoice, a dashboard subscription stored unlinked and held, linked, mapped, adopted and renewed, a voided draft, a charge closed by hand staying closed, search, a scan with nothing behind and with one missed invoice, and the off-platform report.

Open decisions ​

  • Shop quantity N (raised 2026-11-03): N people (N activations, one per recipient) or N× the period (one activation, N terms stacked). Production cannot say whether it has happened: shop line quantities were never stored; MerchantPro holds them.
  • Shop payments as payment rows (recommended yes; full-refund assumption for shop refunds).
  • MerchantPro's write API: confirm that orders can be created and marked paid through it (step 7), and whether products can be listed by update time (steps 3 and 7).
  • Credit rule defaults: the clinic chooses; the platform's defaults are still to be picked.
  • Recurring billing is delegated to the payment integration (Stripe runs renewals and retries). An integration without subscriptions would need the platform to run renewals. The model already allows it (a renewal is a payment on the same order), but that path is not built and is not needed until such an integration is added.
  • Legal, deferred by the owner until the build: naming additional sellers in the privacy notice and terms, and their DPA.