All files / routes/partner me.routes.ts

100% Statements 50/50
100% Branches 20/20
100% Functions 13/13
100% Lines 45/45

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                                                                                            5x     42x                             8x                                                 9x                                         13x                               5x                         5x         15x     23x                                                   5x 16x 16x 16x       16x                 16x                                                 2x             16x         15x   15x 15x   15x                                                                         4x                                       5x 22x 22x   22x 22x 22x   9x 9x   8x               22x   8x                             5x 6x 6x 6x   6x       6x   5x                     4x                                 5x 5x           5x 5x        
/**
 * The partner's own dashboard payload (F-21, RRM partner surface).
 *
 * ONE endpoint, ONE partner: every row in this response is selected by the
 * `prospectId` the session cookie resolved to, and there is no path parameter,
 * query filter or body field that can move it. That is the whole security
 * model of this file — a partner asking "what have I earned" must not be able
 * to spell another partner's id and get an answer.
 *
 * The guard itself (`requirePartnerSession`) is applied by the sub-router in
 * `./index.ts`, not here. Keeping that import out of this module is deliberate:
 * the handler's only contract with auth is "read `partnerId`", which is
 * also what lets it be tested against a real database without a cookie.
 *
 * Responses are the bare contract shape, NOT the `success()`/`data` envelope
 * the admin API uses. The partner page is a separate client with its own fixed
 * contract; wrapping it here would break it.
 */
 
import { Hono } from "hono";
import { z } from "zod";
import type { Dal } from "../../dal";
import { PROGRAM_CONFIG_KEYS } from "../../dal/partners/config.dal";
import type { Partner } from "../../dal/partners/partners.dal";
import type { Referral } from "../../dal/partners/referrals.dal";
import {
	RRM_CONFIG_DEFAULTS,
	RRM_CONFIG_KEYS,
	RrmEarningsDal,
} from "../../dal/rrm";
import { PARTNER_LANGUAGES } from "../../db/schema/enums";
import type { RrmEarning, RrmPayout } from "../../db/schema/rrm";
import { csvDocument } from "../../lib/csv";
import { tdsGate } from "../../lib/partners/tds";
import { summarise } from "../../lib/rrm/earnings";
import { fyLabel, fyStart } from "../../lib/rrm/time";
 
export type PartnerEnv = {
	Bindings: CloudflareBindings;
	Variables: {
		dal: Dal;
		/** Set by `requirePartnerSession`. The ONLY source of partner identity. */
		partnerId: string;
	};
};
 
const me = new Hono<PartnerEnv>();
 
function iso(value: Date | null): string | null {
	return value ? value.toISOString() : null;
}
 
/**
 * What the partner sees of their own referral.
 *
 * A deliberately narrow projection. `firstReferrerReferralId` is the one
 * omission that matters: it names ANOTHER partner's referral, and this
 * endpoint is scoped to the caller — so a duplicate is reported as
 * `isDuplicate: true` and nothing more. Every field published here is a field
 * a future bug can leak.
 *
 * `contactPhone` is the contract's name for the stored `contactPhoneNorm`.
 */
export function serializeReferral(row: Referral) {
	return {
		id: row.id,
		code: row.code,
		status: row.status,
		contactName: row.contactName,
		contactPhone: row.contactPhoneNorm,
		society: row.society,
		config: row.config,
		possessionBand: row.possessionBand,
		mode: row.mode,
		nudgeCount: row.nudgeCount,
		lastNudgeAt: iso(row.lastNudgeAt),
		forwardedAt: iso(row.forwardedAt),
		dateCreated: row.dateCreated.toISOString(),
		// Shown, not hidden. "First referrer wins" is a promise made on the
		// partner page, so a referral that lost a duplicate check has to be able
		// to say so rather than quietly never earning. The id of the WINNER is
		// deliberately not published — it belongs to another partner, and this
		// endpoint is scoped to the caller.
		isDuplicate: row.status === "duplicate",
		rejectedReason: row.rejectedReason,
	};
}
 
function serializeEarning(row: RrmEarning) {
	return {
		id: row.id,
		kind: row.kind,
		amountPaise: row.amountPaise,
		state: row.state,
		holdUntil: row.holdUntil.toISOString(),
		releasedAt: iso(row.releasedAt),
		dateCreated: row.dateCreated.toISOString(),
		referralId: row.referralId,
		/**
		 * FR-M-6: "the partner sees the reversal and its reason". The ENUM, not
		 * the note — the partner is owed why their money went, and the client
		 * turns each value into a sentence written for them. `reversalNotes` is
		 * operator free-text and stays on the admin side.
		 */
		reversalReason: row.reversalReason,
	};
}
 
/** Exported for `payouts.routes.ts` — one payout shape, one definition. */
export function serializePayout(row: RrmPayout) {
	return {
		id: row.id,
		amountPaise: row.amountPaise,
		state: row.state,
		upiId: row.upiId,
		reference: row.reference,
		dateCreated: row.dateCreated.toISOString(),
		settledAt: iso(row.settledAt),
	};
}
 
/**
 * A full Indian PAN: five letters, four digits, one letter. Shared with the
 * operator's fallback route in `admin/rrm/partners.routes.ts` so the two never
 * disagree about what a PAN looks like. The client copies MUST match this.
 */
export const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
 
/**
 * `language`: the enum, not a string. SQLite does not enforce a text enum, so
 * an unconstrained value here writes a language nothing can render — and the
 * column ends up in a URL segment and an `hreflang`.
 *
 * `pan`: validated in full, stored as its last four (FR-M-7). Uppercased before
 * the regex so a partner typing on a phone keyboard is not refused for case.
 *
 * At least one key, or there is nothing to do and `{}` should be a 400 rather
 * than a silent 200.
 */
const patchSchema = z
	.object({
		language: z.enum(PARTNER_LANGUAGES).optional(),
		pan: z.string().trim().toUpperCase().regex(PAN_PATTERN).optional(),
	})
	.refine((body) => body.language !== undefined || body.pan !== undefined);
 
function serializePartner(row: Partner) {
	return {
		name: row.name,
		firmName: row.firmName,
		language: row.language,
		closingsBand: row.closingsBand,
		// So the client can show the UPI section only when money is payable
		// (FR-P-7.1) without a second call, and never ask for it at join.
		hasUpi: Boolean(row.upiId),
		// Same rule as `hasUpi`: whether it is on file, never the value. The
		// last four digits of a PAN are still a tax identifier, and the client
		// only ever needs to know whether to ask for it.
		hasPan: Boolean(row.panLast4),
		/**
		 * FR-P-8: "where did you get my number?" must be answerable, and the
		 * honest answer differs. A self-registered partner filled in a form —
		 * we can name the date. A recruited one did not, and saying "you signed
		 * up" to them would be a lie the help page tells on our behalf.
		 */
		source: row.source,
		joinedAt: row.dateCreated.toISOString(),
		suspended: Boolean(row.suspendedAt),
		suspendedReason: row.suspendedReason,
	};
}
 
// GET /api/partner/me
me.get("/", async (c) => {
	const dal = c.get("dal");
	const partnerId = c.get("partnerId");
	const earnings = new RrmEarningsDal(dal.db);
 
	// Five independent reads, all filtered on the same session-derived id.
	// Sequential would be five round trips for a screen that is one screen.
	const now = new Date();
	const [
		partner,
		thresholdPaise,
		earningRows,
		referralRows,
		payoutRows,
		tdsThresholdPaise,
		fyPaidPaise,
	] = await Promise.all([
		dal.partners.findById(partnerId),
		dal.rrmConfig.getNumber(
			RRM_CONFIG_KEYS.payoutThresholdPaise,
			RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.payoutThresholdPaise],
		),
		earnings.listForPartner(partnerId),
		dal.referrals.listForPartner(partnerId),
		earnings.listPayouts(partnerId),
		// DEGRADES, unlike everywhere else this key is read.
		//
		// `ProgramConfigDal.getNumber` throws rather than falling back, which is
		// right where the value GATES a payment — there is no conservative
		// direction for a threshold that decides whether money may move. But
		// this endpoint only needs it to decide whether to show a notice, and
		// letting that read fail takes the entire dashboard down: balance,
		// referrals, everything, replaced by "Could not load your account".
		//
		// That is not hypothetical. A local database missing `program_config`
		// did exactly this, and the endpoint had been fine until the TDS work
		// gave it a reason to read the table at all. The gate on
		// `POST /payouts/request` and on the operator's settle both still fail
		// closed; this one shows one less line.
		dal.programConfig
			.getNumber(PROGRAM_CONFIG_KEYS.tdsThresholdPaise)
			.catch(() => null),
		earnings.paidSince(partnerId, fyStart(now)),
	]);
 
	// A live session whose partner has been erased under DPDP. Not an auth
	// failure — the cookie is valid — so it is a 404 rather than a 401, and the
	// client should log them out rather than retry.
	if (!partner) return c.json({ error: "not_found" }, 404);
 
	// A balance is not a page a CDN or a back button should serve from a copy —
	// it is scoped to a cookie, so a shared cache holding it would hand one
	// partner another partner's money.
	c.header("Cache-Control", "no-store");
 
	const payouts = payoutRows.map(serializePayout);
	const balance = summarise(earningRows, thresholdPaise);
 
	return c.json({
		partner: serializePartner(partner),
		balance: {
			// `summarise` owns the money rules; `thresholdPaise` is echoed back
			// beside them so the client can render "₹X to go" without having to
			// know the configured value independently.
			...balance,
			thresholdPaise,
		},
		// FR-M-7, answered on the dashboard rather than only at the point of
		// refusal. A partner who has earned enough should be asked for their
		// PAN while they are looking at the screen, not told "no" after tapping
		// the button that was offered to them.
		//
		// `null` when the threshold could not be read. The client treats a
		// missing `tds` as "nothing to say", which is the honest answer: we do
		// not know whether they are over it. They can still be refused at the
		// request, which is the check that actually protects anyone.
		tds:
			tdsThresholdPaise === null
				? null
				: {
						...tdsGate({
							fyPaidPaise,
							requestPaise: balance.availablePaise,
							thresholdPaise: tdsThresholdPaise,
							hasPan: Boolean(partner.panLast4),
						}),
						fyLabel: fyLabel(now),
						fyPaidPaise,
						thresholdPaise: tdsThresholdPaise,
					},
		referrals: referralRows.map(serializeReferral),
		earnings: earningRows.map(serializeEarning),
		payouts,
		// Derived from the list already fetched rather than a sixth query —
		// `listPayouts` is newest-first and only one payout can be pending.
		pendingPayout: payouts.find((p) => p.state === "pending") ?? null,
	});
});
 
/**
 * PATCH /api/partner/me — the two things a partner may change about themselves.
 *
 * LANGUAGE and PAN, deliberately nothing else. Their name and firm came from
 * signup and their number IS their identity; a partner who needs either changed
 * talks to us, and that conversation is the check. UPI has its own path on the
 * payout request, because it travels with the money it is for.
 *
 * The PAN is the partner's own answer to the TDS gate (FR-M-7): past the
 * threshold nothing could be paid, and until this route nothing anywhere wrote
 * `partners.pan_last4`. THE FULL PAN IS NEVER PERSISTED OR LOGGED — it is
 * validated, cut to its last four, and the rest is dropped on the floor here.
 * The full number lives in the finance register, not in this database.
 *
 * Only the keys given are written: `{ pan }` leaves the language alone.
 */
me.patch("/", async (c) => {
	const dal = c.get("dal");
	const partnerId = c.get("partnerId");
 
	const body = await c.req.json().catch(() => null);
	const parsed = patchSchema.safeParse(body);
	if (!parsed.success) return c.json({ error: "bad_request" }, 400);
 
	const partner = await dal.partners.findById(partnerId);
	if (!partner) return c.json({ error: "not_found" }, 404);
 
	const patch = {
		...(parsed.data.language !== undefined
			? { language: parsed.data.language }
			: {}),
		...(parsed.data.pan !== undefined
			? { panLast4: parsed.data.pan.slice(-4) }
			: {}),
	};
	await dal.partners.update(partnerId, patch);
 
	return c.json({ partner: serializePartner({ ...partner, ...patch }) });
});
 
/**
 * GET /api/partner/me/ledger.csv — FR-P-7.5.
 *
 * Every line of their own money, as a file they can open, keep, or hand to
 * whoever does their books. A partner arguing about a payment should not have
 * to screenshot a phone.
 *
 * Written through `lib/csv.ts` rather than string concatenation: a partner's
 * own `firm_name` is user input that lands in a spreadsheet, and `=` at the
 * front of it is a live formula in Excel. The reversal REASON is included and
 * the operator's private note is not, exactly as on the dashboard.
 */
me.get("/ledger.csv", async (c) => {
	const dal = c.get("dal");
	const partnerId = c.get("partnerId");
	const earningsDal = new RrmEarningsDal(dal.db);
 
	const [partner, rows] = await Promise.all([
		dal.partners.findById(partnerId),
		earningsDal.listForPartner(partnerId),
	]);
	if (!partner) return c.json({ error: "not_found" }, 404);
 
	const body = csvDocument(
		[
			"Date",
			"What for",
			"Amount (INR)",
			"Status",
			"Clears on",
			"Paid on",
			"Reason if withdrawn",
			"Referral",
		],
		rows.map((row) => [
			row.dateCreated.toISOString(),
			row.kind,
			// Rupees, not paise — this file is read by a person, and by their
			// accountant. Two decimal places always, so a column of them adds up
			// in a spreadsheet without anyone reformatting it.
			(row.amountPaise / 100).toFixed(2),
			row.state,
			row.holdUntil.toISOString(),
			iso(row.releasedAt),
			row.reversalReason,
			row.referralId,
		]),
	);
 
	// `attachment` with a name, so the phone saves a file rather than rendering
	// CSV as text in a browser tab.
	c.header("Content-Type", "text/csv; charset=utf-8");
	c.header(
		"Content-Disposition",
		'attachment; filename="interioring-earnings.csv"',
	);
	// Same reason `/me` is no-store: this is one partner's money, keyed on a
	// cookie, and a shared cache holding it would hand it to the next caller.
	c.header("Cache-Control", "no-store");
	return c.body(body);
});
 
export default me;