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:
| Concept | Question it answers | Keyed by |
|---|---|---|
| Customer | Who pays? | loopUserId (canonical, brand-independent — ADR-0066) |
| Brand | What presentation / storefront? | brand_id |
| Entity | Which 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 LLC | Direction | Products | Compliance regime | MIDs / rails |
|---|---|---|---|---|---|
loop_health_subscription | Loop Health, Inc. | collect | membership | standard | Stripe (active) → NMI (warm standby) |
loop_health_rx | Loop Health, Inc. | collect | rx | telehealth_rx | Stripe (active) |
loop_bio_labs | Loop Bio Labs LLC | collect | ruo_compounds | high_risk_ruo | NMI primary (active) → NMI standby |
loyal_labs | Loyal Labs LLC | collect | supplements | supplements | Stripe (active) |
leo_research | Leo Research LLC | collect | research_compounds | high_risk_ruo | NMI primary (active) → own-gateway (warm standby) |
coach_payouts | Loop Health, Inc. | disburse | coach_payout | payouts | Bill.com (active) |
affiliate_payouts | Loop Health, Inc. | disburse | affiliate_payout | payouts | Bill.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_idis the scoping key. entity_idis stamped onpayments.paymentsand is immutable once set (the append-only payment-update trigger allows a first-set, then freezes it).- The ledger carries
entity_idtoo — see Routing & failover and the recon-ready dimensions inaccountingmigration0006_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
- Add the row to
payments.entities(or extend theENTITIESseed for tests). - Set
direction,allowedProducts,complianceRegime, and the orderedmids. - Get the legal entity name, settlement account, and MID identifiers confirmed by counsel + banking (Phase-0) — these are not engineering decisions.
- The routing engine picks it up with no code change; eligibility is enforced automatically.
See also
- Routing & failover — how the registry’s candidates become a safe cascade
- The two seams — the provider/vault interfaces a MID maps onto
- Architecture — where the registry sits in the whole
Source
services/payments/src/orchestration/entities.ts·services/payments/migrations/0005_entities.sql- ADR-0093 (LOO-2187 — entity model)