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 | 67x 3x 64x 7x 49x 49x 49x 49x 49x 49x 49x 58x 58x 10x 10x 38x 35x 38x 5x 5x 5x 5x 49x 49x 49x 6x 6x 6x | /**
* Partner earnings — the money rules, as pure functions.
*
* Everything here is arithmetic over rows the caller supplies. No database, no
* clock of its own, no I/O. That is deliberate: these are the rules that decide
* what a partner is owed, and they need to be testable exhaustively without a
* fixture stack. `services/rrm/earnings.service.ts` does the persistence.
*
* PAISE, ALWAYS. Every amount in this module is an integer number of paise.
* ₹100 is 10000. There is no float anywhere in the path, because D1 has no
* decimal type and rupee arithmetic in floating point is how you end up owing
* someone ₹99.99999999999999.
*/
import type { RRM_EARNING_STATES } from "../../db/schema/enums";
export type EarningState = (typeof RRM_EARNING_STATES)[number];
/** The subset of an earning row these rules need. */
export type EarningLike = {
state: EarningState;
amountPaise: number;
holdUntil: Date;
/**
* Set once the earning is locked to a payout request. A `released` earning
* with a payout id is spoken for: it is not withdrawable again, even though
* its state has not changed yet.
*/
payoutId?: string | null;
};
export type Balance = {
/** Earned, still inside its hold window. Visible to the partner, not yet payable. */
heldPaise: number;
/** Hold passed, not yet in a payout. This is what counts towards the threshold. */
availablePaise: number;
/** Released but locked to a payout request the operator has not settled. */
requestedPaise: number;
/** Already settled. */
paidPaise: number;
/** Withdrawn. Shown so a partner can see it rather than watch a number shrink. */
reversedPaise: number;
/**
* Clawed back after payment (FR-M-6), reported POSITIVE. Its own line for
* the same reason `reversedPaise` has one — more so, because this offsets
* money the partner has already been paid, and a total that shrinks with
* no line explaining why is how a support call opens as an accusation.
*/
clawedBackPaise: number;
/** held + available + requested — earned and not yet paid. Never negative. */
outstandingPaise: number;
/** Whether `availablePaise` has reached the payout threshold. */
meetsThreshold: boolean;
/** Paise still needed to reach the threshold, or 0 once met. */
shortfallPaise: number;
};
/** When an earning created now becomes releasable. */
export function holdUntilFrom(now: Date, holdDays: number): Date {
if (!Number.isInteger(holdDays) || holdDays < 0) {
throw new RangeError(
`holdDays must be a non-negative integer, got ${holdDays}`,
);
}
return new Date(now.getTime() + holdDays * 24 * 60 * 60 * 1000);
}
/**
* Is this row ready to move `accrued` -> `released`?
*
* Only `accrued` rows are candidates. A `reversed` row whose hold has expired
* must never become releasable — that would resurrect money someone already
* decided to withdraw.
*/
export function isReleasable(earning: EarningLike, now: Date): boolean {
return (
earning.state === "accrued" && earning.holdUntil.getTime() <= now.getTime()
);
}
/**
* Roll a partner's rows into the balance the portal shows.
*
* `reversed` contributes to its own bucket and to nothing else. It is reported
* rather than hidden: a partner who was told "you earned ₹100" and later sees
* the total drop is owed a line explaining it, and silence there is how a
* support conversation becomes an accusation.
*/
export function summarise(
earnings: readonly EarningLike[],
thresholdPaise: number,
): Balance {
let heldPaise = 0;
let availablePaise = 0;
let requestedPaise = 0;
let paidPaise = 0;
let reversedPaise = 0;
let clawedBackPaise = 0;
for (const e of earnings) {
// Every negative row gets its own reported line...
if (e.amountPaise < 0) clawedBackPaise -= e.amountPaise;
// ...but is still bucketed BY STATE like any other row. That is what
// keeps `availablePaise` identical to the sum `requestPayout` takes
// over released, unattached earnings: an offset already applied to a
// settled payout is `paid` and must stop reducing what is requestable,
// or it would be charged to the partner twice.
switch (e.state) {
case "accrued":
heldPaise += e.amountPaise;
break;
case "released":
// Locked to a pending request -> reported separately, not as
// available. Otherwise the dashboard shows "Ready to withdraw
// ₹1,000" directly above "₹1,000 withdrawal requested", which
// reads as ₹2,000 to the person owed it.
if (e.payoutId) requestedPaise += e.amountPaise;
else availablePaise += e.amountPaise;
break;
case "paid":
paidPaise += e.amountPaise;
break;
case "reversed":
reversedPaise += e.amountPaise;
break;
}
}
// Raw, and so possibly negative: a clawback larger than what has since been
// earned leaves the partner owing the difference against future referrals.
const netAvailablePaise = availablePaise;
const meetsThreshold = netAvailablePaise >= thresholdPaise;
return {
heldPaise,
// Clamped for DISPLAY only. "Available: -₹100" is not a thing a partner
// can act on, and the debt is not lost — it lives in the rows, so the
// next referral nets against it on the next call.
availablePaise: Math.max(0, netAvailablePaise),
requestedPaise,
paidPaise,
reversedPaise,
clawedBackPaise,
outstandingPaise: Math.max(
0,
heldPaise + netAvailablePaise + requestedPaise,
),
meetsThreshold,
// Off the RAW net, not the clamped figure. With ₹100 clawed back and a
// ₹500 threshold the honest answer is "earn ₹600", and the clamped one
// would say ₹500 and then refuse the request.
shortfallPaise: meetsThreshold ? 0 : thresholdPaise - netAvailablePaise,
};
}
/**
* Paise -> the string a partner reads. `10000` -> `"₹100"`, `12550` -> `"₹125.50"`.
*
* Whole rupees drop the decimals because every amount in the programme today is
* a round number and "₹100.00" reads like a system talking to itself. Grouping
* is Indian (1,00,000 not 100,000) via en-IN, which is the whole reason this
* uses Intl rather than a hand-rolled `toFixed`.
*/
export function formatPaise(paise: number): string {
const rupees = paise / 100;
const hasPaise = paise % 100 !== 0;
return `₹${rupees.toLocaleString("en-IN", {
minimumFractionDigits: hasPaise ? 2 : 0,
maximumFractionDigits: 2,
})}`;
}
|