/*
[RFC-1222] Canonical builder for the `/r/.json` machine-readable
disclosure twin. Single source for both the emitted endpoint and the
`referral.json.parity.validate` check — the validator recomputes the expected
document through this builder, so drift means "the emitted bytes differ from
what the PBP facts say", never two hand-maintained shapes.
Closed shape: pending and retired partners emit exactly {type, slug, status}
— no identity, no economics.
- No PBP imports — facts arrive pre-resolved (ReferralProgramFacts), keeping this module pure and testable.
- No display-phrase economics — callers must resolve amounts from PBP entities (DNA-103).
- RFC-1222: created — disclosure twin builder shared by the generated endpoint and the parity validator.
- RFC-1222 pending-landing amendment (operator, 2026-10-07): pending partners emit the nameless closed document — the route exists while consent is unrecorded, identity does not.
- REVIEW-CODE-2026-10-08-02: closed-doc status union tightened back to the
emitted states ("retired" | "pending") — a paused partner never reaches the
builder, so the build path returns null instead of minting a paused document.
- RFC-1258: active doc gains statement/statementConfirmedAt/validUntil/basis/scope — absent rather than invented; closed docs stay {type, slug, status}.
- RFC-1277: review fixes: JSON-twin honors display.*; drop orphaned landing: copy facts; heroAbout requires statement gate; CSS class + dead ghost CTA; v6 wording in validators/docs; REVIEW_TOKENS word boundaries; schema-gate tests (81+73 green)
*/
import {
referralBasisVisible,
referralDisclosureKind,
referralFitsVisible,
referralStatementVisible,
type ReferralCompensation,
} from "./copy.ts";
import type { ReferralPartner } from "./partners.ts";
/** Program economics resolved from PBP entities — every field is absent
rather than invented. */
export interface ReferralProgramFacts {
readonly rewardAmount?: number;
readonly rewardCurrency?: string;
readonly rewardTrigger?: string;
readonly qualifyingPeriod?: string;
readonly benefit?: { readonly freePeriod?: string; readonly priorityStart?: boolean };
readonly policyEffectiveFrom?: string;
}
/** The retired and pending documents are deliberately closed — three fields,
no identity. Pending keeps the route alive while consent is unrecorded;
retired preserves the historical slug after the active window. Paused
partners emit no document at all — the slug is absent from the union. */
export interface ReferralPartnerClosedDoc {
readonly type: "referral-partner";
readonly slug: string;
readonly status: "retired" | "pending";
}
export interface ReferralPartnerActiveDoc {
readonly type: "referral-partner";
readonly slug: string;
readonly status: "active";
readonly compensation: {
readonly mode: ReferralCompensation;
readonly reward?: { readonly currency: string; readonly amount: number };
readonly trigger?: string;
readonly qualifyingPeriod?: string;
};
readonly referredCustomerBenefit?: {
readonly freePeriod?: string;
readonly priorityStart?: boolean;
};
readonly customerPriceIncrease: false;
readonly disclosure: "paid" | "unpaid";
readonly referrerDisplayName: string;
readonly policyEffectiveFrom?: string;
readonly contactUrl: string;
/** RFC-1258: the recommender's dated voice — absent rather than invented
when unset; mirrors the partner record (statement {de,uk}, dates flat,
basis/scope verbatim). */
readonly statement?: { readonly de?: string; readonly uk?: string };
readonly statementConfirmedAt?: string;
readonly validUntil?: string;
readonly basis?: readonly {
readonly claim: { readonly de?: string; readonly uk?: string };
readonly verifiedBy: "recommender" | "warpgogol";
readonly date: string;
}[];
readonly scope?: {
readonly fits: readonly Record[];
readonly doesNotFit: readonly Record[];
};
}
export type ReferralPartnerDoc = ReferralPartnerActiveDoc | ReferralPartnerClosedDoc;
/**
* Builds the emitted `/r/.json` document. Pending and retired partners
* emit the closed nameless document — the route exists, identity does not.
*/
export function buildReferralPartnerDoc(
partner: ReferralPartner,
facts: ReferralProgramFacts,
): ReferralPartnerDoc | null {
if (partner.status !== "active") {
// Paused partners emit no document — the route never reaches the builder.
if (partner.status === "paused") return null;
return { type: "referral-partner", slug: partner.slug, status: partner.status };
}
const reward =
facts.rewardAmount !== undefined && facts.rewardCurrency
? { currency: facts.rewardCurrency, amount: facts.rewardAmount }
: undefined;
return {
type: "referral-partner",
slug: partner.slug,
status: "active",
compensation: {
mode: partner.compensation,
...(reward ? { reward } : {}),
...(facts.rewardTrigger ? { trigger: facts.rewardTrigger } : {}),
...(facts.qualifyingPeriod ? { qualifyingPeriod: facts.qualifyingPeriod } : {}),
},
...(facts.benefit ? { referredCustomerBenefit: facts.benefit } : {}),
customerPriceIncrease: false,
disclosure: referralDisclosureKind(partner.compensation),
// The schema guarantees displayName on active partners (consent covers a
// name that must exist) — the `?? ""` branch is unreachable but keeps the
// builder total under the widened optional type.
referrerDisplayName: partner.displayName ?? "",
...(facts.policyEffectiveFrom ? { policyEffectiveFrom: facts.policyEffectiveFrom } : {}),
contactUrl: `/r/${partner.slug}`,
// RFC-1258: the recommender's dated voice — absent rather than invented.
// RFC-1277: the same publication predicates gate the JSON twin as the
// page — a `display` switch hides a block from publication everywhere,
// it only never deletes the registry data or consent.
...(referralStatementVisible(partner) && partner.statement
? { statement: partner.statement }
: {}),
...(referralStatementVisible(partner) && partner.statementConfirmedAt
? { statementConfirmedAt: partner.statementConfirmedAt }
: {}),
...(partner.validUntil ? { validUntil: partner.validUntil } : {}),
...(referralBasisVisible(partner) && partner.basis?.length ? { basis: partner.basis } : {}),
...(referralFitsVisible(partner) && partner.scope ? { scope: partner.scope } : {}),
};
}