All files / dal/partners config.dal.ts

95.89% Statements 70/73
93.47% Branches 43/46
100% Functions 13/13
97.05% Lines 66/68

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311        18x                                   18x                                                                                       18x                                                     18x                               32x 32x 32x         9x   9x     9x                             18x 18x     26x 26x 18x 2x   16x       1x   15x 15x 3x         12x   8x       192x           153x           151x 151x                                   112x 110x 104x 104x     104x   6x 6x 112x 2x         4x       10x 10x 1x 1x     1x   9x 9x 5x 1x               2x         2x         1x 10x 1x 1x 10x 10x   1x                                   58x 58x 58x   26x 26x 20x 58x           58x       20x                             2x           2x      
import { and, desc, eq, lte } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "../../db/schema";
 
const CONFIG = schema.programConfig;
 
/**
 * Programme economics: what a referral is worth, how long money is held, how
 * many referrals a partner may submit. FR-A-8, DM-8.
 *
 * Everything here is a number an owner changes without a deploy, and every
 * change is a commercial decision someone has to be able to defend later. So
 * unlike `rrm_config` — which upserts, because only the current send cap
 * matters — this one APPENDS. See the table comment for why version history is
 * load-bearing rather than tidy.
 *
 * NO CACHE. Same reasoning as the RRM config DAL, plus one more: a stale rate
 * read does not produce a stale *display*, it produces a wrongly-priced ledger
 * row that outlives the cache by seven years (NFR-8).
 */
 
/** The key namespace, typed rather than free strings. */
export const PROGRAM_CONFIG_KEYS = {
	/** Paid when a referred homeowner is verified (FR-M-2). */
	/**
	 * RETIRED — read `rrm_config.validated_amount_paise` instead.
	 *
	 * The ledger has always priced a validated referral from the `rrm_config`
	 * row (`services/rrm/earnings.service.ts`). This key was a second, separate
	 * number that only the verification screen read, and the two agreed solely
	 * because both defaulted to ₹100. Setting it changed what an operator was
	 * shown and not a paisa of what was paid. Kept in the map so an existing
	 * row is still readable and typed, with no reader in the code.
	 */
	verifyAmountPaise: "verify_amount_paise",
	/** Paid when that homeowner converts and the advance is confirmed. */
	convertAmountPaise: "convert_amount_paise",
	/** The ₹500 bonus at 5 verified/month. §19 Q3 — present, default OFF. */
	bonusEnabled: "bonus_enabled",
	bonusAmountPaise: "bonus_amount_paise",
	/** Days a `verify_100` sits at `held` before it may be approved (FR-M-3). */
	coolingDays: "cooling_days",
	/** FY total at which approval blocks pending PAN last-4 (FR-M-7). */
	tdsThresholdPaise: "tds_threshold_paise",
	/** Referral caps until a partner has 3 that reached `contacted` (FR-F-3). */
	referralCapPerDay: "referral_cap_per_day",
	referralCapPerMonth: "referral_cap_per_month",
	/** Hours a `draft` survives without a forward before expiring (FR-P-3.5). */
	draftExpiryHours: "draft_expiry_hours",
	/**
	 * FR-A-9's "config threshold". An ACCRUAL above this cannot be both
	 * verified and paid by the same account — see `lib/partners/two-person.ts`.
	 */
	twoPersonThresholdPaise: "two_person_threshold_paise",
} as const;
 
export type ProgramConfigKey =
	(typeof PROGRAM_CONFIG_KEYS)[keyof typeof PROGRAM_CONFIG_KEYS];
 
/**
 * Starting values, from the SRS.
 *
 * These are the values used when a key has never been written. They are NOT a
 * silent fallback for a malformed row — see `getNumber`, which throws rather
 * than guessing, because every number here prices a payment.
 */
export const PROGRAM_CONFIG_DEFAULTS = {
	[PROGRAM_CONFIG_KEYS.verifyAmountPaise]: 10_000, // ₹100   (FR-M-2)
	[PROGRAM_CONFIG_KEYS.convertAmountPaise]: 100_000, // ₹1,000 (FR-M-2)
	[PROGRAM_CONFIG_KEYS.bonusEnabled]: false, // §19 Q3 default: OUT
	[PROGRAM_CONFIG_KEYS.bonusAmountPaise]: 50_000, // ₹500, inert while disabled
	[PROGRAM_CONFIG_KEYS.coolingDays]: 5, // FR-M-3: 5 days, NOT 48h
	[PROGRAM_CONFIG_KEYS.tdsThresholdPaise]: 1_500_000, // ₹15,000 (FR-M-7)
	[PROGRAM_CONFIG_KEYS.referralCapPerDay]: 5, // FR-F-3
	[PROGRAM_CONFIG_KEYS.referralCapPerMonth]: 15, // FR-F-3
	[PROGRAM_CONFIG_KEYS.draftExpiryHours]: 72, // FR-P-3.5
	/**
	 * ₹500. Chosen so it sits BETWEEN the two amounts the programme pays: a
	 * ₹100 verification stays a one-person job, and a ₹1,000 conversion — the
	 * one worth inventing — needs a second pair of eyes. Requiring two people
	 * for every ₹100 would stop the programme rather than protect it.
	 */
	[PROGRAM_CONFIG_KEYS.twoPersonThresholdPaise]: 50_000,
} as const;
 
/**
 * Sanity ranges. A write outside these is refused.
 *
 * The ceilings are not arbitrary: they are the point past which a typo stops
 * looking like a policy change. ₹1,00,000 for a verification is not a generous
 * programme, it is a missing decimal — and the whole reason these values are
 * editable without a deploy is that a deploy is not there to catch it.
 */
const RANGES: Record<ProgramConfigKey, { min: number; max: number } | null> = {
	[PROGRAM_CONFIG_KEYS.verifyAmountPaise]: { min: 0, max: 10_000_000 },
	[PROGRAM_CONFIG_KEYS.convertAmountPaise]: { min: 0, max: 10_000_000 },
	[PROGRAM_CONFIG_KEYS.bonusEnabled]: null, // boolean
	[PROGRAM_CONFIG_KEYS.bonusAmountPaise]: { min: 0, max: 10_000_000 },
	[PROGRAM_CONFIG_KEYS.coolingDays]: { min: 0, max: 90 },
	[PROGRAM_CONFIG_KEYS.tdsThresholdPaise]: { min: 0, max: 100_000_000 },
	[PROGRAM_CONFIG_KEYS.referralCapPerDay]: { min: 1, max: 1_000 },
	[PROGRAM_CONFIG_KEYS.referralCapPerMonth]: { min: 1, max: 10_000 },
	[PROGRAM_CONFIG_KEYS.draftExpiryHours]: { min: 1, max: 8_760 },
	// 0 means "every accrual needs two people"; the ceiling is the point past
	// which the rule is switched off rather than relaxed.
	[PROGRAM_CONFIG_KEYS.twoPersonThresholdPaise]: { min: 0, max: 10_000_000 },
};
 
export class ProgramConfigAttributionError extends Error {
	code = "ATTRIBUTION_REQUIRED";
	constructor(public key: ProgramConfigKey) {
		super(`Config key "${key}" cannot be changed without a reason and actorId`);
	}
}
 
export class ProgramConfigValueError extends Error {
	code = "INVALID_CONFIG_VALUE";
	constructor(
		public key: ProgramConfigKey,
		detail: string,
	) {
		super(`Config key "${key}": ${detail}`);
	}
}
 
export type ProgramConfigValue = string | number | boolean;
 
export type ProgramConfigVersion = {
	key: ProgramConfigKey;
	version: number;
	value: string;
	reason: string | null;
	actorId: string | null;
	dateCreated: Date;
};
 
const FALSE_VALUES: ReadonlySet<string> = new Set(["false", "0", "off", "no"]);
const TRUE_VALUES: ReadonlySet<string> = new Set(["true", "1", "on", "yes"]);
 
function serialise(key: ProgramConfigKey, value: ProgramConfigValue): string {
	Iif (typeof value === "boolean") return String(value);
	if (typeof value === "number") {
		if (!Number.isFinite(value)) {
			throw new ProgramConfigValueError(key, `${value} is not a finite number`);
		}
		if (!Number.isInteger(value)) {
			// Every number here is paise, a day count or a cap. None of them is
			// meaningfully fractional, and a float in the money path is what
			// FR-M-1 exists to forbid.
			throw new ProgramConfigValueError(key, `${value} is not an integer`);
		}
		const range = RANGES[key];
		if (range && (value < range.min || value > range.max)) {
			throw new ProgramConfigValueError(
				key,
				`${value} is outside the permitted range ${range.min}–${range.max}`,
			);
		}
		return String(value);
	}
	return value;
}
 
export class ProgramConfigDal {
	constructor(private db: DrizzleD1Database<typeof schema>) {}
 
	/** The live row for a key, or null if it has never been written. */
	async getCurrent(
		key: ProgramConfigKey,
	): Promise<ProgramConfigVersion | null> {
		const rows = await this.db
			.select()
			.from(CONFIG)
			.where(eq(CONFIG.key, key))
			.orderBy(desc(CONFIG.version))
			.limit(1);
		const row = rows[0];
		return row ? ({ ...row, key } as ProgramConfigVersion) : null;
	}
 
	/**
	 * The live number for a key.
	 *
	 * Falls back to the default ONLY when the key has never been written. A row
	 * that exists but does not parse **throws** — deliberately unlike
	 * `RrmConfigDal.getNumber`, which returns its fallback.
	 *
	 * That reader can do so safely because its defaults are all in the
	 * conservative direction: a corrupt send cap reads as 30 and sends less.
	 * There is no conservative direction here. Falling back to ₹100 when the
	 * row says something unparseable would quietly pay the wrong amount and
	 * write it into an immutable ledger. Failing the request is recoverable;
	 * a wrong payment is not.
	 */
	async getNumber(key: ProgramConfigKey): Promise<number> {
		const row = await this.getCurrent(key);
		if (row === null) {
			const fallback = PROGRAM_CONFIG_DEFAULTS[key];
			Iif (typeof fallback !== "number") {
				throw new ProgramConfigValueError(key, "is not a numeric key");
			}
			return fallback;
		}
		const trimmed = row.value.trim();
		const parsed = trimmed === "" ? Number.NaN : Number(trimmed);
		if (!Number.isFinite(parsed)) {
			throw new ProgramConfigValueError(
				key,
				`stored value ${JSON.stringify(row.value)} is not a number`,
			);
		}
		return parsed;
	}
 
	async getBoolean(key: ProgramConfigKey): Promise<boolean> {
		const row = await this.getCurrent(key);
		if (row === null) {
			const fallback = PROGRAM_CONFIG_DEFAULTS[key];
			Iif (typeof fallback !== "boolean") {
				throw new ProgramConfigValueError(key, "is not a boolean key");
			}
			return fallback;
		}
		const value = row.value.trim().toLowerCase();
		if (TRUE_VALUES.has(value)) return true;
		if (FALSE_VALUES.has(value)) return false;
		throw new ProgramConfigValueError(
			key,
			`stored value ${JSON.stringify(row.value)} is not a boolean`,
		);
	}
 
	/** Full history for a key, newest first. This is the FR-A-8 audit view. */
	async history(key: ProgramConfigKey): Promise<ProgramConfigVersion[]> {
		const rows = await this.db
			.select()
			.from(CONFIG)
			.where(eq(CONFIG.key, key))
			.orderBy(desc(CONFIG.version));
		return rows as ProgramConfigVersion[];
	}
 
	/** Every key's live value, for the admin config screen. */
	async getAllCurrent(): Promise<Record<string, string>> {
		const keys = Object.values(PROGRAM_CONFIG_KEYS);
		const rows = await Promise.all(keys.map((key) => this.getCurrent(key)));
		const out: Record<string, string> = {};
		keys.forEach((key, i) => {
			const row = rows[i];
			out[key] = row ? row.value : String(PROGRAM_CONFIG_DEFAULTS[key]);
		});
		return out;
	}
 
	/**
	 * Append a new version of a key.
	 *
	 * `reason` and `actorId` are mandatory for EVERY key, not just a dangerous
	 * subset as in `rrm_config`. There is no key here whose change is routine:
	 * each one either prices a payment or bounds how many a partner can make.
	 *
	 * @throws {ProgramConfigAttributionError} if reason or actorId is blank
	 * @throws {ProgramConfigValueError} if the value is malformed or out of range
	 */
	async set(
		key: ProgramConfigKey,
		value: ProgramConfigValue,
		options: { reason?: string; actorId?: string } = {},
	): Promise<ProgramConfigVersion> {
		const reason = options.reason?.trim() || null;
		const actorId = options.actorId?.trim() || null;
		if (!reason || !actorId) throw new ProgramConfigAttributionError(key);
 
		const stored = serialise(key, value);
		const current = await this.getCurrent(key);
		const version = (current?.version ?? 0) + 1;
		const dateCreated = new Date();
 
		// A concurrent writer racing us takes the same version number and one of
		// the two INSERTs violates the composite primary key. That is the correct
		// outcome — the loser is told, rather than silently overwriting a rate
		// change it never saw. The caller re-reads and decides.
		await this.db
			.insert(CONFIG)
			.values({ key, version, value: stored, reason, actorId, dateCreated });
 
		return { key, version, value: stored, reason, actorId, dateCreated };
	}
 
	/**
	 * The rate in force at a given time.
	 *
	 * This is the question the ledger asks when someone disputes an amount:
	 * not "what is verify_100 worth" but "what was it worth on 3 March, when
	 * this row was written". Without it, a rate change makes every historic
	 * accrual look wrong.
	 */
	async valueAt(
		key: ProgramConfigKey,
		at: Date,
	): Promise<ProgramConfigVersion | null> {
		const rows = await this.db
			.select()
			.from(CONFIG)
			.where(and(eq(CONFIG.key, key), lte(CONFIG.dateCreated, at)))
			.orderBy(desc(CONFIG.version))
			.limit(1);
		return (rows[0] as ProgramConfigVersion | undefined) ?? null;
	}
}