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 | 1x 1x 1x 25x 25x 25x 25x 25x 14x 14x 14x 14x 14x 13x 13x 13x 1x 12x 12x 8x 4x 4x 4x 4x | /**
* "Pay me what I've earned" (F-21, RRM partner surface).
*
* The partner supplies a UPI id and nothing else. The AMOUNT is not theirs to
* name — `RrmEarningsDal.requestPayout` derives it from their released,
* unattached earnings — and neither is the identity: the `partnerId` comes
* from the session variable, never from the body, so a request naming someone
* else's id still requests against the caller's own ledger.
*
* No money moves here. This records a request an operator settles by hand over
* UPI; see the DAL for why that is deliberate in this version.
*/
import { Hono } from "hono";
import { PROGRAM_CONFIG_KEYS } from "../../dal/partners/config.dal";
import {
BelowThresholdError,
PayoutAlreadyPendingError,
RRM_CONFIG_DEFAULTS,
RRM_CONFIG_KEYS,
RrmEarningsDal,
} from "../../dal/rrm";
import { tdsGate } from "../../lib/partners/tds";
import { summarise } from "../../lib/rrm/earnings";
import { fyLabel, fyStart } from "../../lib/rrm/time";
import { type PartnerEnv, serializePayout } from "./me.routes";
const payouts = new Hono<PartnerEnv>();
/**
* A UPI virtual payment address: `identifier@handle`.
*
* Validated because this string is the only instruction an operator gets about
* where to send real money, and a typo is discovered by the money arriving
* somewhere else. The handle is alphanumeric-after-a-letter rather than the
* strict NPCI letters-only, because PSP handles do change and the cost of the
* two failures is not symmetric: a slightly-loose regex sends an operator a
* VPA that bounces, a slightly-tight one silently locks a real partner out of
* their own earnings.
*
* The upper bounds are not decoration — this value is written to the database
* and read back by a human.
*/
const UPI_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{1,63}@[a-zA-Z][a-zA-Z0-9]{1,31}$/;
// POST /api/partner/payouts/request
payouts.post("/request", async (c) => {
const dal = c.get("dal");
const partnerId = c.get("partnerId");
// A malformed body is a malformed UPI id as far as this endpoint is
// concerned — there is only one field, and `c.req.json()` throws on junk.
//
// What cannot arrive here at all is a body that was never declared JSON:
// `requirePartnerSession` (lib/rrm/partner-session.ts) refuses a write whose
// Origin is not on the allowlist, and one whose content type is a form
// encoding — which is what keeps an injected `enctype="text/plain"` form,
// posted with this partner's cookie, off the endpoint that names where their
// money goes.
const body = await c.req.json().catch(() => null);
const upiId =
typeof (body as { upiId?: unknown } | null)?.upiId === "string"
? (body as { upiId: string }).upiId.trim()
: "";
if (!UPI_ID.test(upiId)) return c.json({ error: "invalid_upi" }, 400);
const earnings = new RrmEarningsDal(dal.db);
const now = new Date();
const [
thresholdPaise,
tdsThresholdPaise,
partner,
earningRows,
fyPaidPaise,
pending,
] = await Promise.all([
dal.rrmConfig.getNumber(
RRM_CONFIG_KEYS.payoutThresholdPaise,
RRM_CONFIG_DEFAULTS[RRM_CONFIG_KEYS.payoutThresholdPaise],
),
// One arg: this reader owns its own default and THROWS on a malformed
// row rather than falling back, because there is no conservative
// direction for a threshold that gates a payment.
dal.programConfig.getNumber(PROGRAM_CONFIG_KEYS.tdsThresholdPaise),
dal.partners.findById(partnerId),
earnings.listForPartner(partnerId),
earnings.paidSince(partnerId, fyStart(now)),
earnings.pendingPayout(partnerId),
]);
Iif (!partner) return c.json({ error: "not_found" }, 404);
// Checked before the TDS gate, and before `requestPayout` would throw it,
// so the answer is the one the partner can act on. A partner already over
// the threshold would otherwise be told "we need your PAN" about a request
// they cannot make anyway, while the request they DID make sits unexplained.
if (pending) return c.json({ error: "payout_pending" }, 409);
// FR-M-7. Refused HERE rather than only at settle, so a partner learns why
// before the money is sitting in a request nobody can pay. The operator's
// settle carries the same check — this one is the explanation, that one is
// the control.
//
// Only for a request that could otherwise succeed. A partner who is short
// AND over the threshold for the year is reachable (₹500 available against
// ₹14,900 already paid), and the shortfall is the one they can act on
// today — so `below_threshold` below answers first. The client's
// `payoutCta` orders the two the same way.
//
// Assessed on `availablePaise`, which is what `requestPayout` would derive:
// both read the same released, unattached rows, and the one case where they
// differ (a payout already pending) was refused above.
const balance = summarise(earningRows, thresholdPaise);
const gate = tdsGate({
fyPaidPaise,
requestPaise: balance.availablePaise,
thresholdPaise: tdsThresholdPaise,
hasPan: Boolean(partner.panLast4),
});
if (balance.meetsThreshold && gate.blocked) {
return c.json(
{
error: "pan_required",
fyLabel: fyLabel(now),
fyPaidPaise: gate.fyPaidPaise,
wouldTotalPaise: gate.wouldTotalPaise,
tdsThresholdPaise: gate.thresholdPaise,
},
422,
);
}
try {
const payout = await earnings.requestPayout({
// From the session. Deliberately not `body.partnerId` — there is no
// such field, and this is the line that makes sure there never is.
partnerId,
upiId,
thresholdPaise,
});
return c.json(serializePayout(payout), 201);
} catch (err) {
Iif (err instanceof PayoutAlreadyPendingError) {
return c.json({ error: "payout_pending" }, 409);
}
Eif (err instanceof BelowThresholdError) {
// The error carries its numbers in the message only, so the shortfall
// is recomputed from the same rows `/me` reports — the partner must
// not be told "₹300 to go" here and "₹250 to go" on the dashboard.
//
// The two agree: `summarise` counts every `released` earning while
// `requestPayout` counts only the unattached ones, and they can
// differ ONLY while a payout is pending — which is the branch above,
// checked first and returned before this one is reachable.
const { shortfallPaise } = balance;
return c.json({ error: "below_threshold", shortfallPaise }, 422);
}
throw err;
}
});
export default payouts;
|