PaymentsEntity model

Entity model

What this is: how Loop represents the legal entities (LLCs) that collect and disburse money, why they are first-class, and how the routing layer uses them to keep every charge inside its underwritten product category.

Who it’s for: anyone adding a brand, a product line, a new LLC, or a payout rail; anyone who needs to understand why a charge can be rejected before any money moves.

What to read next: Routing & failover · The two seams · Architecture.

Three distinct concepts

It is easy to conflate these. They are different and keyed differently:

ConceptQuestion it answersKeyed by
CustomerWho pays?loopUserId (canonical, brand-independent — ADR-0066)
BrandWhat presentation / storefront?brand_id
EntityWhich LLC collects or disburses?entity_id

An entity sits above brand: one LLC can collect across multiple brands. That is exactly why payments.entities is not brand-scoped — entity_id, not brand_id, is the scoping key the routing engine keys on.

What an entity is

An entity is a typed object describing who moves money, which way, on which product categories, through which merchant accounts (MIDs), settling to which account, under which compliance regime. It is bidirectional and config-extensible: collecting entities take money in, disbursing entities pay it out, and you add more by inserting rows / extending the seed.

From services/payments/src/orchestration/entities.ts:

/** Money direction. Collecting entities take money IN; disbursing pay it OUT. */
export type EntityDirection = "collect" | "disburse" | "both";
 
/**
 * Product categories an entity may be underwritten for. Extensible — the
 * runtime list backs the route enum + any cart→category derivation (LOO-2190).
 */
export const PRODUCT_CATEGORIES = [
  "membership",
  "rx",
  "ruo_compounds",
  "supplements",
  "research_compounds",
  "coach_payout",
  "affiliate_payout",
] as const;

The Entity shape carries the MIDs the routing engine filters and cascades over:

export type Entity = {
  /** Stable id (snake_case), used as `entity_id` across payments + ledger. */
  id: string;
  name: string;
  legalLlc: string;
  direction: EntityDirection;
  /** Product categories this entity may collect/disburse on. */
  allowedProducts: ProductCategory[];
  /** Bank/settlement account ref (placeholder until Phase-0). */
  settlementAccount: string | null;
  complianceRegime: ComplianceRegime;
  /** Ordered MIDs/rails. The routing engine filters + cascades over these. */
  mids: Mid[];
};

EntityRegistry is the in-memory view the routing layer reads. It exposes the eligibility predicates the laundering guard depends on — canCollect, canDisburse, allowsProduct, collectingFor. Production loads rows from payments.entities (migration 0005_entities.sql); tests and routing use the typed seed by default.

The seven concrete entities

These were confirmed by Will (2026-06-20). Five collect (money in); two disburse (money out).

Entity (id)Legal LLCDirectionProductsCompliance regimeMIDs / rails
loop_health_subscriptionLoop Health, Inc.collectmembershipstandardStripe (active) → NMI (warm standby)
loop_health_rxLoop Health, Inc.collectrxtelehealth_rxStripe (active)
loop_bio_labsLoop Bio Labs LLCcollectruo_compoundshigh_risk_ruoNMI primary (active) → NMI standby
loyal_labsLoyal Labs LLCcollectsupplementssupplementsStripe (active)
leo_researchLeo Research LLCcollectresearch_compoundshigh_risk_ruoNMI primary (active) → own-gateway (warm standby)
coach_payoutsLoop Health, Inc.disbursecoach_payoutpayoutsBill.com (active)
affiliate_payoutsLoop Health, Inc.disburseaffiliate_payoutpayoutsBill.com (active)

Loop Health — Rx runs on Loop’s OWN Stripe account. Rimo operates the Rx flow, but it does not use its own Stripe — it operates on Loop’s Stripe account (mid_lh_rx_stripe). This is a deliberate ownership choice: Loop owns the merchant relationship, the descriptor, and the customer.

⚠️

Phase-0 placeholders. legalLlc, settlementAccount, and every MID id / status in the seed are structural placeholders — intentionally obvious, never real acquirer credentials. The real legal entity names, the entity↔product↔MID matrix, and warm-MID identifiers are confirmed by counsel + banking (LOO-2204 / 2205 / 2206) and swapped in before any code routes real money. The shape is real; the values are not yet.

Route-by-(entity, product)

A charge names an entity and a product. The registry answers a single question first: is this entity underwritten for this product? If not, the charge is rejected with zero attempts. This is the eligibility predicate the routing engine and the payment-session front door both call:

From services/payments/src/orchestration/routing-policy.ts:

export type RoutingRequest = {
  /** The collecting entity chosen for this charge. */
  entityId: string;
  /** Product category being charged — checked against entity eligibility. */
  product: ProductCategory;
  // ── reserved for future risk-scored routing (not used day-one) ──
  amountCents?: number;
  geo?: string;
  bin?: string;
  riskScore?: number;
};

The geo / bin / riskScore / amountCents fields are carried but not yet used — the seam for risk-scored routing and volume balancing is built so the smarts can be added later without a rewrite (“build the seam, not the smarts”, LOO-2190).

The laundering / MCC guardrail

This is the existential rule, not a nicety. Routing a charge through a MID underwritten for a different product category (or a different entity) is transaction laundering — MATCH reason code 03, potentially criminal, a 5-year blacklist for the LLCs and their officers. So the model makes it structurally impossible:

  • A charge can route only to MIDs of an entity underwritten for the request’s product.
  • Failover stays within that entity’s MIDs. There is deliberately no mechanism to fall back to another entity’s MID.
  • An ineligible request yields zero candidates ⇒ the charge declines, never cross-category.
🚫

Scope of the guarantee — read before wiring to a live charge. The registry enforces entity ↔ the *asserted* product. It does not yet bind product to the actual cart/intent (line items / plan / amount). That cart→category derivation — and rejecting any caller/derived disagreement — is a prerequisite on the orchestrator-wiring tickets (LOO-2190 / LOO-2227) before any real MID routes money, so a mislabeled product cannot launder. Today the charge path is not instantiated and all MIDs are Phase-0 placeholders, so the gap is latent.

LOO-2263 — the placeholder guard

Because the MID identifiers are deliberate placeholders, there is a guard so they can never silently become a live money path. In services/payments the direct-amount checkout lane is simulated in test mode precisely because “the real path would hit Stripe with the placeholder key” — the placeholders are inert by construction until Phase-0 swaps real credentials in. The orchestrator and entity registry are not instantiated on the live checkout path in this foundation; wiring them is a later ticket gated on Phase-0 completion.

Persistence

The typed seed is mirrored by payments.entities (migration 0005_entities.sql). Note three deliberate properties:

  • Not brand-scoped — an entity (LLC) sits above brand; entity_id is the scoping key.
  • entity_id is stamped on payments.payments and is immutable once set (the append-only payment-update trigger allows a first-set, then freezes it).
  • The ledger carries entity_id too — see Routing & failover and the recon-ready dimensions in accounting migration 0006_recon_dimensions.sql.

From services/payments/migrations/0005_entities.sql:

-- ── payments.entity_id ──────────────────────────────────────────────────────
-- Which legal entity collected the payment (ADR-0093). Nullable during backfill.
ALTER TABLE payments.payments
  ADD COLUMN IF NOT EXISTS entity_id TEXT REFERENCES payments.entities(id);
 
CREATE INDEX IF NOT EXISTS payments_entity_id_idx
  ON payments.payments (entity_id)
  WHERE entity_id IS NOT NULL;

How to add an entity

  1. Add the row to payments.entities (or extend the ENTITIES seed for tests).
  2. Set direction, allowedProducts, complianceRegime, and the ordered mids.
  3. Get the legal entity name, settlement account, and MID identifiers confirmed by counsel + banking (Phase-0) — these are not engineering decisions.
  4. The routing engine picks it up with no code change; eligibility is enforced automatically.

See also

Source

  • services/payments/src/orchestration/entities.ts · services/payments/migrations/0005_entities.sql
  • ADR-0093 (LOO-2187 — entity model)