# Per-Tenant Rate Negotiation PRD

**Status:** Draft for implementation
**Last updated:** 2026-04-16
**Priority:** P2 — enables enterprise sales motion

---

## 1) Purpose

Allow Guild ops to set custom economic rates per tenant for commercial deals with enterprise-scale marketplaces that don't fit the default 70/21/9 split.

Today, `calculatePoolBreakdown` in `@/Volumes/PivotNorth/the-guild/apps/backend/src/domain.ts` is called from `@/Volumes/PivotNorth/the-guild/apps/backend/src/service-referral.ts` with no override parameters — every tenant pays the same default rate. The function itself already accepts `programAllocationPct` and `protocolFeePct` as parameters, so the per-call override mechanism exists; what's missing is the per-tenant configuration, validation, governance, and versioning.

---

## 2) Scope

### In scope

- Per-tenant configurable `program_allocation_pct` and `protocol_fee_pct`
- Bounded by hard min/max enforced centrally (ops cannot set arbitrary values)
- Versioning: a rate change applies only to transactions reported **after** the effective date. Historical transactions keep their original rate.
- Audit trail in immudb for every rate change
- Guild ops admin tooling for setting rates
- Operator-facing read-only view of their current rate
- Automatic fallback to defaults if no custom rate is configured

### Out of scope

- Self-service rate changes by operators (always requires Guild ops approval)
- Time-of-day or volume-tiered rates (flat per-tenant rate only in v1)
- Retroactive rate changes to historical transactions
- Automatic rate discounts based on reported volume (manually negotiated only)

---

## 3) Rate bounds

Hard bounds enforced by the system (not overridable without a code change):

| Parameter | Default | Min | Max | Notes |
|---|---|---|---|---|
| `program_allocation_pct` | 30 | 10 | 40 | Below 10% removes user-reward viability; above 40% strains marketplace margins |
| `protocol_fee_pct` | 30 | 10 | 50 | Below 10% doesn't sustain Guild operations; above 50% makes user rewards uncompetitive |

Effective Guild take range: 1.0% to 20.0% of marketplace commission. Defaults sit at 9.0%.

Any proposed rate outside these bounds requires a code change to lift the bound — not an ops console action.

---

## 4) Data model

### Postgres schema addition

```sql
ALTER TABLE tenants
  ADD COLUMN program_allocation_pct INTEGER,
  ADD COLUMN protocol_fee_pct INTEGER;

-- NULL means "use the default from calculatePoolBreakdown"
-- NOT NULL means "use this specific override"

CREATE TABLE tenant_rate_history (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  program_allocation_pct INTEGER NOT NULL,
  protocol_fee_pct INTEGER NOT NULL,
  effective_at TIMESTAMPTZ NOT NULL,
  set_by_admin_id UUID NOT NULL,
  reason TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX ON tenant_rate_history (tenant_id, effective_at DESC);
```

### Transactions also record the rate they were processed at

```sql
ALTER TABLE transactions
  ADD COLUMN applied_program_allocation_pct INTEGER NOT NULL,
  ADD COLUMN applied_protocol_fee_pct INTEGER NOT NULL;
```

This lets the wallet, settlement, and export flows all reconstruct the exact split for any historical transaction, even after a rate change.

### immudb events

- `TENANT_RATE_CHANGED`: new rate, effective_at, admin_id, reason, predecessor_rate

---

## 5) Service layer changes

### `calculatePoolBreakdown` call site

In `@/Volumes/PivotNorth/the-guild/apps/backend/src/service-referral.ts` (around the existing `calculatePoolBreakdown` call):

```ts
// Current
const poolBreakdown = calculatePoolBreakdown(input.platformCommissionCents);

// New
const rate = await getEffectiveTenantRate(client, input.tenantId, input.settledAt);
const poolBreakdown = calculatePoolBreakdown(
  input.platformCommissionCents,
  rate.programAllocationPct,
  rate.protocolFeePct
);
```

`getEffectiveTenantRate` reads the current tenant row; if `program_allocation_pct` / `protocol_fee_pct` are NULL, returns defaults.

The **settled_at** parameter matters: even if a rate change is in flight, transactions settled before the effective date use the prior rate.

### New service interfaces

```ts
interface TenantRate {
  programAllocationPct: number;
  protocolFeePct: number;
  isDefault: boolean;
  effectiveAt: Date;
}

// Read
async function getCurrentTenantRate(tenantId: string): Promise<TenantRate>;
async function getEffectiveTenantRate(client: PoolClient, tenantId: string, asOf: Date): Promise<TenantRate>;

// Write (admin-only)
async function setTenantRate(input: {
  tenantId: string;
  programAllocationPct: number;
  protocolFeePct: number;
  effectiveAt: Date;
  setByAdminId: string;
  reason: string;
}): Promise<TenantRate>;
```

---

## 6) API surface

### Operator-facing (read-only)

```
GET /v1/tenants/:tenant_id/rate
Authorization: Bearer <tenant_api_key>

→ 200
{
  "program_allocation_pct": 30,
  "protocol_fee_pct": 30,
  "is_default": true,
  "effective_at": "2025-11-01T00:00:00.000Z"
}
```

### Guild admin-facing (write)

```
POST /v1/admin/tenants/:tenant_id/rate
Authorization: Bearer <admin_session_token>

{
  "program_allocation_pct": 25,
  "protocol_fee_pct": 20,
  "effective_at": "2026-06-01T00:00:00.000Z",
  "reason": "Hooks.ly enterprise deal — 5% Guild take capped by contract"
}

→ 201
{ "rate_history_id": "...", "effective_at": "2026-06-01T00:00:00.000Z" }
```

```
GET /v1/admin/tenants/:tenant_id/rate-history

→ 200
{
  "rates": [
    { "program_allocation_pct": 25, "protocol_fee_pct": 20, "effective_at": "2026-06-01T...", "set_by_admin_id": "...", "reason": "..." },
    { "program_allocation_pct": 30, "protocol_fee_pct": 30, "effective_at": "2025-11-01T...", "set_by_admin_id": "...", "reason": "tenant created (default)" }
  ]
}
```

---

## 7) Governance

### Who can change a rate?

- Only Guild ops admins with the `rate_change` capability
- Capability is granted per-admin by a super-admin
- Every change must include a `reason` (min 20 chars, recorded in audit log)

### Effective date constraint

- `effective_at` must be >= now + 24 hours (prevents surprise same-day rate changes)
- Exception: Guild ops can set `effective_at = now` only for **tenant onboarding** (setting the initial rate)

### Notification

- Upon any rate change, the tenant's designated billing/ops contacts are emailed 24h before effective date
- Email includes old rate, new rate, effective date, reason, and how to escalate if they disagree

### Self-service read path

- Operators can see their current rate via `GET /v1/tenants/:id/rate`
- Operators cannot see other tenants' rates
- Rate history is not operator-facing in v1 (admin-only)

---

## 8) Testing

- Unit: `calculatePoolBreakdown` with custom rates produces mathematically correct splits
- Unit: rate bounds are enforced at the service layer (< min, > max rejected)
- Integration: transaction uses tenant-specific rate when set; uses defaults when not
- Integration: rate change on date D is applied to transactions settled at D+, not D-
- Integration: `applied_program_allocation_pct` on transactions matches the rate that was effective at their settled_at
- Scenario: rate history audit trail matches what ops set
- Scenario: monthly invoice correctly uses effective rate for each transaction in the period
- Regression: defaults still work when a tenant has no custom rate

---

## 9) Commercial guidelines (non-engineering)

These are business rules, not code, but should be documented alongside the implementation:

- **Rate floors:** never below 5% effective Guild take on any deal
- **Volume thresholds:** discounts only considered for tenants reporting >$1M/month in settled commission
- **Commitment period:** rate discounts require a 12-month minimum commitment
- **MFN clause:** rate discounts do not include a most-favored-nation clause; Guild retains the right to offer different rates to different operators without triggering cross-tenant reset

---

## 10) Risks and open questions

### Risks

- **Transparency vs lock-in.** If operators can see their own rate, they can compare in whispered conversations and demand parity. This is a normal tension in SaaS. Accept it.
- **Rate change bugs.** A misconfigured rate could over-charge or under-charge for an entire month. Mitigate with (a) 24h notification before effective, (b) operator-visible rate endpoint, (c) bounded min/max, (d) reason-required field.
- **Retroactive disputes.** Operator claims "we agreed on rate X but you charged Y." Mitigate with tenant_rate_history + immudb audit trail as source of truth.

### Open questions

- **Minimum commitment enforcement?** If an operator negotiates a discount with a 12-month commitment and leaves after 6 months, do we claw back? Likely yes via contract, not code.
- **Multi-rate for different transaction types?** Some operators will want different rates for different classes of transactions (e.g., subscription renewal vs first-purchase). Deferred to v2.
- **Volume-based auto-tiers?** Automatically drop the rate when a tenant crosses a threshold. Deferred to v2 — keep it manual in v1 to preserve commercial flexibility.

---

## 11) Rollout plan

1. Ship Postgres schema additions (backfill tenant rows with NULL for both new columns)
2. Add `applied_program_allocation_pct` + `applied_protocol_fee_pct` to transactions table (backfill with current defaults for historical rows)
3. Implement `getEffectiveTenantRate` + wire into `service-referral.ts`
4. Add read-only `GET /v1/tenants/:id/rate` endpoint for operators
5. Add admin write endpoints and audit trail
6. Add admin console UI for rate management
7. Update `docs/operator/faq.md` to reflect the new capability
8. Commercial team onboards with the admin console before first enterprise deal

---

## 12) Acceptance criteria

- A Guild admin can set a custom rate for a specific tenant with a documented reason
- The rate change takes effect on the specified date; transactions before that date use the prior rate
- Every rate change is auditable via `tenant_rate_history` and the immudb event stream
- Operators can read their own current rate; they cannot see others'
- Monthly protocol fee invoices reflect the effective rate for each transaction in the period, even if the rate changed mid-month
- Rate bounds are enforced at the service layer; invalid values are rejected before reaching the database
