All files / routes/admin/rrm partners.routes.ts

100% Statements 132/132
96.66% Branches 58/60
100% Functions 17/17
100% Lines 119/119

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 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474                                                                2x 2x 2x 2x     54x 6x   48x     2x               2x 29x 29x       29x 29x 5x 5x         5x   24x 24x 24x 24x   24x   29x                   24x                           29x                   29x                   29x   29x         24x 35x                                               2x 6x 6x   6x 6x   5x                               5x       6x     2x                     2x 12x 12x 12x 12x   11x 2x   1x 1x     2x 3x 3x 3x   3x 2x   1x 1x                               2x 3x 3x 3x   3x 2x   1x 1x     2x                   2x 14x 14x 14x 14x   13x 6x   5x             5x     2x                           2x 12x 12x 12x 12x   11x 5x   4x   4x                     2x     2x     2x                                                                         2x 10x 10x 10x   10x 9x 1x     8x 8x     7x         7x 1x         6x         6x 1x     5x   5x             5x 5x 4x         4x 4x 4x             5x                     5x               5x                               5x                                   2x 42x 27x 27x   27x   15x        
import { and, desc, eq, like, or, sql } from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import type { Dal } from "../../../dal";
import { RrmSubmissionsDal } from "../../../dal/rrm";
import * as schema from "../../../db/schema";
import {
	ForbiddenError,
	NotFoundError,
	ValidationError,
} from "../../../lib/errors";
import { velocityFlag } from "../../../lib/partners/velocity";
import { handleError, success } from "../../../lib/response";
import { isSuperAdminFromRoles } from "../../../lib/role-cache";
import { istDayStart } from "../../../lib/rrm/time";
import { PAN_PATTERN } from "../../partner/me.routes";
 
/**
 * The partners table (FR-A-3) — who is in the programme and what they are
 * allowed to do.
 *
 * Suspension lives here rather than on the referrals screen because it is a
 * decision about a PERSON, not about one introduction: it kills their session,
 * stops their share link attributing, and freezes what they are owed. FR-F-7
 * requires the partner to be told with a reason, which is why `reason` is
 * mandatory and travels all the way to their login screen.
 */
type Env = {
	Bindings: CloudflareBindings;
	Variables: { dal: Dal; user: { id: string } | null };
};
 
const partners = new Hono<Env>();
const PARTNERS = schema.partners;
const REFERRALS = schema.referrals;
const PAYOUTS = schema.rrmPayouts;
 
function requireActorId(user: { id: string } | null): string {
	if (!user?.id) {
		throw new ValidationError("An authenticated operator is required");
	}
	return user.id;
}
 
const listQuery = z.object({
	search: z.string().trim().max(120).optional(),
	suspended: z.enum(["true", "false"]).optional(),
	limit: z.coerce.number().int().min(1).max(200).default(50),
	offset: z.coerce.number().int().min(0).default(0),
});
 
// GET /api/admin/rrm/partners
partners.get("/", async (c) => {
	const dal = c.get("dal");
	const q = listQuery.parse(
		Object.fromEntries(new URL(c.req.url).searchParams),
	);
 
	const filters = [];
	if (q.search) {
		const term = `%${q.search}%`;
		const match = or(
			like(PARTNERS.name, term),
			like(PARTNERS.phoneNorm, term),
			like(PARTNERS.firmName, term),
		);
		Eif (match) filters.push(match);
	}
	if (q.suspended === "true")
		filters.push(sql`${PARTNERS.suspendedAt} IS NOT NULL`);
	if (q.suspended === "false")
		filters.push(sql`${PARTNERS.suspendedAt} IS NULL`);
 
	const where = filters.length ? and(...filters) : undefined;
 
	const rows = await dal.db
		.select()
		.from(PARTNERS)
		.where(where)
		.orderBy(desc(PARTNERS.dateCreated))
		.limit(q.limit)
		.offset(q.offset);
 
	// Referral counts per partner, in ONE grouped query rather than one per
	// row. D1 charges per statement and a 50-row table would otherwise be 51.
	const counts = rows.length
		? await dal.db
				.select({
					partnerId: REFERRALS.partnerId,
					total: sql<number>`count(*)`,
					verified: sql<number>`sum(case when ${REFERRALS.status} in ('verified','contacted','quoted','converted') then 1 else 0 end)`,
					// FR-F-5's numerator. An IST day, because the partner is in
					// Hyderabad and a "day" that rolled at 05:30 local would make
					// the flag indefensible to the person it lands on.
					today: sql<number>`sum(case when ${REFERRALS.dateCreated} >= ${Math.floor(istDayStart(new Date()).getTime() / 1000)} then 1 else 0 end)`,
				})
				.from(REFERRALS)
				.groupBy(REFERRALS.partnerId)
		: [];
	const byPartner = new Map(counts.map((r) => [r.partnerId, r]));
 
	// What each partner has ACTUALLY been paid, summed over settled payouts.
	//
	// This column used to be `partners.cumulative_paid_paise`, which nothing
	// ever wrote — so the operator's list showed "₹0 paid" beside every partner,
	// including ones who had been paid, on a screen about money. The ledger is
	// the source of truth, as the schema comment beside it always said. Netted
	// of clawbacks for free: a clawback settled into a payout reduced that
	// payout's amount when it was created.
	const paid = rows.length
		? await dal.db
				.select({
					partnerId: PAYOUTS.partnerId,
					total: sql<number>`coalesce(sum(${PAYOUTS.amountPaise}), 0)`,
				})
				.from(PAYOUTS)
				.where(eq(PAYOUTS.state, "sent"))
				.groupBy(PAYOUTS.partnerId)
		: [];
	const paidByPartner = new Map(paid.map((r) => [r.partnerId, r.total]));
 
	const [{ total }] = await dal.db
		.select({ total: sql<number>`count(*)` })
		.from(PARTNERS)
		.where(where);
 
	return success(c, {
		partners: rows.map((p) => ({
			...p,
			referralCount: Number(byPartner.get(p.id)?.total ?? 0),
			verifiedCount: Number(byPartner.get(p.id)?.verified ?? 0),
			// Same field name the portal already renders — the value now comes
			// from the ledger instead of a column nobody wrote.
			cumulativePaidPaise: Number(paidByPartner.get(p.id) ?? 0),
			// FR-F-5. A FLAG, never a block — the requirement says so twice, and
			// nothing on this response refuses anything. `no_band` is reported
			// distinctly from `within_band`: most partners never stated one, and
			// "not flagged" for a partner we could not have flagged is a lie.
			velocity: velocityFlag({
				band: p.closingsBand ?? null,
				referralsToday: Number(byPartner.get(p.id)?.today ?? 0),
			}),
		})),
		total: Number(total ?? 0),
		limit: q.limit,
		offset: q.offset,
	});
});
 
// GET /api/admin/rrm/partners/:id — one screen answers "what happened with
// this person" (FR-A-4).
partners.get("/:id", async (c) => {
	const dal = c.get("dal");
	const id = c.req.param("id");
 
	const partner = await dal.partners.findById(id);
	if (!partner) throw new NotFoundError("Partner", id);
 
	const [referrals, earnings, payouts] = await Promise.all([
		dal.referrals.listForPartner(id, 200),
		dal.db
			.select()
			.from(schema.rrmEarnings)
			.where(eq(schema.rrmEarnings.partnerId, id))
			.orderBy(desc(schema.rrmEarnings.dateCreated)),
		dal.db
			.select()
			.from(schema.rrmPayouts)
			.where(eq(schema.rrmPayouts.partnerId, id))
			.orderBy(desc(schema.rrmPayouts.dateCreated)),
	]);
 
	// The RRM prospect they came from, when they were recruited rather than
	// self-registered — the single column joining the two domains (DM-9).
	const prospect = partner.prospectId
		? await dal.rrmProspects.findById(partner.prospectId)
		: null;
 
	return success(c, { partner, referrals, earnings, payouts, prospect });
});
 
const suspendSchema = z.object({
	reason: z.string().trim().min(3).max(300),
});
 
/**
 * Suspend. FR-F-7: never silent.
 *
 * The reason is mandatory and is shown to the partner — `requirePartnerSession`
 * answers 403 with it, and `GET /auth/session` returns it so their login screen
 * can explain rather than looping them through a code they can never spend.
 */
partners.post("/:id/suspend", async (c) => {
	const dal = c.get("dal");
	requireActorId(c.get("user"));
	const id = c.req.param("id");
	const body = suspendSchema.parse(await c.req.json().catch(() => ({})));
 
	const partner = await dal.partners.findById(id);
	if (!partner) throw new NotFoundError("Partner", id);
 
	await dal.partners.suspend(id, body.reason);
	return success(c, { partner: await dal.partners.findById(id) });
});
 
partners.post("/:id/unsuspend", async (c) => {
	const dal = c.get("dal");
	requireActorId(c.get("user"));
	const id = c.req.param("id");
 
	const partner = await dal.partners.findById(id);
	if (!partner) throw new NotFoundError("Partner", id);
 
	await dal.partners.unsuspend(id);
	return success(c, { partner: await dal.partners.findById(id) });
});
 
/**
 * A STOP that arrived where the webhook cannot see it — on the operator's own
 * handset, which is where every hand-sent invitation is answered.
 *
 * Turns off exactly what the partner's own STOP turns off (`notify_whatsapp`),
 * and nothing else. Deliberately NOT `do_not_contact`: `verify-otp` refuses a
 * login code to a do-not-contact number, so marking a partner's number would
 * lock them out of their own money for asking us to stop texting them. Nor
 * suspension, which kills the account for a request about messages.
 *
 * One way only, like the webhook's. Replying START is what turns updates back
 * on — a channel the partner closed is reopened by the partner.
 */
partners.post("/:id/mute", async (c) => {
	const dal = c.get("dal");
	requireActorId(c.get("user"));
	const id = c.req.param("id");
 
	const partner = await dal.partners.findById(id);
	if (!partner) throw new NotFoundError("Partner", id);
 
	await dal.partners.update(id, { notifyWhatsapp: false });
	return success(c, { partner: await dal.partners.findById(id) });
});
 
const capsSchema = z.object({
	// Null clears the override and returns them to the programme default.
	capPerDay: z.number().int().min(1).max(1000).nullable().optional(),
	capPerMonth: z.number().int().min(1).max(10000).nullable().optional(),
});
 
/**
 * Raise (or clear) a partner's caps. FR-F-3 makes these PER-PARTNER columns
 * precisely so one proven partner can be lifted without moving everyone.
 */
partners.post("/:id/caps", async (c) => {
	const dal = c.get("dal");
	requireActorId(c.get("user"));
	const id = c.req.param("id");
	const body = capsSchema.parse(await c.req.json().catch(() => ({})));
 
	const partner = await dal.partners.findById(id);
	if (!partner) throw new NotFoundError("Partner", id);
 
	await dal.partners.update(id, {
		...(body.capPerDay !== undefined ? { capPerDay: body.capPerDay } : {}),
		...(body.capPerMonth !== undefined
			? { capPerMonth: body.capPerMonth }
			: {}),
	});
 
	return success(c, { partner: await dal.partners.findById(id) });
});
 
const panSchema = z.object({
	pan: z.string().trim().toUpperCase().regex(PAN_PATTERN),
});
 
/**
 * Record a partner's PAN on their behalf — the fallback for one that arrives
 * on the WhatsApp thread rather than through the app (FR-M-7). The partner
 * entering it themselves on their payout screen is the primary path; this
 * exists so a PAN sent to a person does not leave that partner dead-ended at
 * settle.
 *
 * Same rule as the partner's own route: the full PAN is validated and only the
 * last four are written. The full number stays in the finance register.
 */
partners.post("/:id/pan", async (c) => {
	const dal = c.get("dal");
	requireActorId(c.get("user"));
	const id = c.req.param("id");
	const body = panSchema.parse(await c.req.json().catch(() => ({})));
 
	const partner = await dal.partners.findById(id);
	if (!partner) throw new NotFoundError("Partner", id);
 
	await dal.partners.update(id, { panLast4: body.pan.slice(-4) });
 
	return success(c, { partner: await dal.partners.findById(id) });
});
 
/**
 * A phone number that cannot be dialled and cannot collide.
 *
 * `phone_norm` is NOT NULL and UNIQUE, so an erasure cannot simply drop it.
 * The partner id makes it unique for free, and the non-numeric prefix keeps it
 * out of the way of every real `91…` number — including the one this row used
 * to hold, which is now on the suppression list and must stay findable there.
 */
const ERASED_PHONE_PREFIX = "erased-";
 
/** `partners.name` and `referrals.contact_name` are both NOT NULL. */
const ERASED_NAME = "Erased at their request";
 
const ERASED_SUSPENSION_REASON =
	"Erased at their own request under DPDP. Only the payment record is kept.";
 
/**
 * DPDP erasure for a PARTNER (the partner-app help screen sends the request
 * over WhatsApp, so it arrives at a person, not at a form).
 *
 * ANONYMISE, never delete. The row keeps its id and everything money hangs off
 * it — `rrm_earnings`, `rrm_payouts` and `referral_events` are financial and
 * audit records, and a payment that was made is not erasable. What goes is
 * everything that says WHO they were: name, firm, number, UPI, PAN.
 *
 * Including the UPI id on payouts already made. A payout's `upi_id` is a
 * separate snapshot from the partner's, and Indian UPI handles are routinely
 * the person's phone number (`9876543210@ybl`) — leaving it would keep, on a
 * screen an operator reads, the exact number this endpoint just anonymised and
 * suppressed. The payment record survives it: amount, state, `settled_at` and
 * the operator's `reference` are what prove a payment was made.
 *
 * Money still owed is the one thing that must move BEFORE this runs, which is
 * why a pending payout is refused rather than quietly broken: after erasure
 * there is no UPI id to send it to, no admin route that creates a payout, and
 * no login left for the partner to request one. Released earnings under the
 * threshold are NOT refused — they could never have been requested, so
 * blocking on them would make erasure impossible for anyone holding ₹50.
 *
 * Super-admin only. The parent admin router's `requirePlatformAdmin` admits
 * `admin` as well, which is the right bar for the reversible levers in this
 * file; it is not the right bar for a write that cannot be undone. Same check,
 * and the same reasoning, as the prospect hard-delete in
 * `prospects.routes.ts`.
 *
 * D1 has no transaction across these statements, so the order is the safety
 * net: suppression first (a half-finished erasure must never leave the number
 * contactable), and the partner row — the thing an operator looks at — last,
 * so a failure part-way reads as "not done yet" and is retried rather than
 * looking finished.
 */
partners.post("/:id/erase", async (c) => {
	const dal = c.get("dal");
	const actorId = requireActorId(c.get("user"));
	const id = c.req.param("id");
 
	const roles = await dal.userTenantRoles.findByUserId(actorId);
	if (!isSuperAdminFromRoles(roles)) {
		throw new ForbiddenError("Super admin access required");
	}
 
	const partner = await dal.partners.findById(id);
	if (!partner) throw new NotFoundError("Partner", id);
 
	// Before the first write, so a refusal leaves nothing half-done.
	const pending = await dal.db
		.select({ id: PAYOUTS.id })
		.from(PAYOUTS)
		.where(and(eq(PAYOUTS.partnerId, id), eq(PAYOUTS.state, "pending")))
		.limit(1);
	if (pending.length > 0) {
		throw new ValidationError(
			"Settle or fail this partner's pending withdrawal first — erasing removes the UPI id, and after it there is no way to pay them.",
		);
	}
 
	const phone = partner.phoneNorm;
	// Already erased. Re-running is harmless and must stay that way — but not
	// by repeating the work: `erased-…` is not a number, and putting it on the
	// suppression list would be a junk row on the one list that has to stay
	// readable at 2am.
	if (phone.startsWith(ERASED_PHONE_PREFIX)) {
		return success(c, { erased: true });
	}
 
	const erasedPhone = `${ERASED_PHONE_PREFIX}${partner.id}`;
 
	await dal.rrmSuppression.suppress(phone, "deletion_request");
 
	// The recruitment side, through the path that already exists rather than a
	// second one that would drift from it. Found by PHONE, not by
	// `partner.prospect_id`: a partner who signed up on their own still has a
	// prospect row if we had scraped that number first, and that row holds the
	// same name and firm we are being asked to remove.
	const prospect = await dal.rrmProspects.findByPhone(phone);
	if (prospect) {
		const actor = {
			actorType: "operator" as const,
			actorId,
			reason: "deletion_request",
		};
		await dal.rrmProspects.erase(prospect.id, actor);
		await new RrmSubmissionsDal(dal.db).scrubForProspect(prospect.id);
		await dal.rrmProspects.scrubTaskNotes(prospect.id);
	}
 
	// Where this person is the CONTACT on someone else's referral, the name and
	// number on that row are still theirs. The referral itself is kept — the
	// partner who made it is owed for it, and deleting it would take their
	// money with it.
	await dal.db
		.update(REFERRALS)
		.set({
			contactName: ERASED_NAME,
			contactPhoneNorm: erasedPhone,
			dateUpdated: new Date(),
		})
		.where(eq(REFERRALS.contactPhoneNorm, phone));
 
	// The destination handle on every payout, for the reason in the header. The
	// amount, the state and the reference stay: that is the payment record.
	await dal.db
		.update(PAYOUTS)
		.set({ upiId: null })
		.where(eq(PAYOUTS.partnerId, id));
 
	// Written directly rather than through `PartnersDal.update()`, whose patch
	// type deliberately excludes `phone_norm` and the suspension pair: nothing
	// else in the programme may move a partner's number.
	await dal.db
		.update(PARTNERS)
		.set({
			phoneNorm: erasedPhone,
			name: ERASED_NAME,
			firmName: null,
			upiId: null,
			upiName: null,
			panLast4: null,
			notifyWhatsapp: false,
			suspendedAt: new Date(),
			suspendedReason: ERASED_SUSPENSION_REASON,
			dateUpdated: new Date(),
		})
		.where(eq(PARTNERS.id, id));
 
	return success(c, { erased: true });
});
 
/**
 * The one place a throw in this file is given HTTP meaning.
 *
 * There is no app-level `onError` anywhere in the API, so without this Hono's
 * default handler answers every throw in here with a bare 500: a partner id
 * that does not exist reads as "the server broke" instead of 404, and a
 * two-character suspension reason reads the same way instead of 400. The
 * sibling routers buy this with a try/catch per handler; one handler on the
 * router is the same guarantee in one place, and `route()` carries it through
 * to the parent when this is mounted.
 *
 * `handleError` has no ZodError branch, so schema failures are named here
 * before being handed over — an operator has to be told WHICH field was
 * refused, not just that something was.
 */
partners.onError((err, c) => {
	if (err instanceof z.ZodError) {
		const detail = err.issues
			.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`)
			.join("; ");
		return handleError(c, new ValidationError(detail));
	}
	return handleError(c, err);
});
 
export default partners;