fix(overdue): payment-request job targets ONLY negative effective balances

- listOverdueCustomers requires effective < 0; zero/positive never listed
- --threshold-cents / OVERDUE_THRESHOLD_CENTS clamped to <= 0 (can only narrow)
- sendPaymentRequests skips any non-negative row (defense in depth)
- tests + docs/NEGATIVE-BALANCE.md updated; dry run remains default
This commit is contained in:
George Lambert 2026-10-05 14:15:38 -04:00
commit 1ac4e5539e
3 changed files with 110 additions and 13 deletions

View file

@ -15,7 +15,7 @@ It never invents a `mock-cert-*` receipt (fail-closed, commit 1107272).
* Customers can keep timestamping when their prepaid / effective balance is zero or negative.
* Invoice issue (`POST /admin/api/invoices/:id/issue`) still draws down prepaid only when it fully covers the invoice. Otherwise the invoice stays `issued` (unpaid) and shows up in the overdue report.
* The daily overdue job (below) finds the customers who need a payment request.
* The daily overdue job (below) finds the customers who need a payment request: **only customers whose effective balance is negative** (strictly below $0.00). Zero and positive balances are never listed or emailed.
### Effective balance
@ -34,17 +34,18 @@ effective = balance_cents (prepaid)
cd /opt/verae/zappier-edge
sudo -u verae-zapier node dist/jobs/overdue-balances.js # dry run table
sudo -u verae-zapier node dist/jobs/overdue-balances.js --json # machine-readable
sudo -u verae-zapier node dist/jobs/overdue-balances.js --threshold-cents 500 # below $5
sudo -u verae-zapier node dist/jobs/overdue-balances.js --threshold-cents -500 # only owing more than $5
# Sends one email per overdue customer through the existing mailer (src/mailer.ts):
sudo systemd-run --uid=verae-zapier --gid=verae-zapier -p WorkingDirectory=/opt/verae/zappier-edge \
-p EnvironmentFile=/SSD2/secrets/zap-verae/zappier-edge.env -p EnvironmentFile=/etc/verae/zappier-edge-smtp.env \
--wait --pipe /usr/bin/node dist/jobs/overdue-balances.js --send --to-override you@example.com # test to yourself
```
* **Negative balances only.** A customer is targeted only when `effective < 0` (and `effective < threshold`). `--threshold-cents` / `OVERDUE_THRESHOLD_CENTS` can only narrow the list: a positive value is clamped to `0` with a warning on stderr, so zero/positive balances can never be included. `sendPaymentRequests` also skips any row that isn't negative (`skipped: balance not negative`). Locked in by `tests/negative-balance.test.ts`.
* From: `SMTP_FROM` / `SMTP_FROM_NAME` in `/etc/verae/zappier-edge-smtp.env` (`veraetimestamp@gmail.com`).
* Skips Peergos sub-accounts (they bill to their parent) and `cust-platform`.
* Customers with no email are listed with `skipped: no email on file`.
* Env: `BILLING_DB`, `OVERDUE_THRESHOLD_CENTS` (default 0), `PORTAL_URL` (default `https://zap.verae.ai/portal/`).
* Env: `BILLING_DB`, `OVERDUE_THRESHOLD_CENTS` (default 0, must be ≤ 0), `PORTAL_URL` (default `https://zap.verae.ai/portal/`).
* Nothing is emailed without `--send`.
### Cron / systemd timer

View file

@ -3,8 +3,9 @@
*
* Stamping is never blocked by a customer's prepaid balance (see
* docs/NEGATIVE-BALANCE.md). Instead this job finds customers whose
* *effective* balance is below a threshold so staff can send a payment request
* from the SMTP_FROM mailbox (veraetimestamp@gmail.com on zap.verae.ai).
* *effective* balance is NEGATIVE (strictly < $0.00) so staff can send a
* payment request from the SMTP_FROM mailbox (veraetimestamp@gmail.com on
* zap.verae.ai). Zero and positive balances are never listed or emailed.
*
* effective = prepaid balance_cents
* − billable of issued-but-unpaid invoices
@ -14,13 +15,14 @@
* Usage (from /opt/verae/zappier-edge, after `npm run build`):
* node dist/jobs/overdue-balances.js # dry run, table
* node dist/jobs/overdue-balances.js --json # dry run, JSON
* node dist/jobs/overdue-balances.js --threshold-cents 500
* node dist/jobs/overdue-balances.js --threshold-cents -500 # only owing > $5
* node dist/jobs/overdue-balances.js --send # email each customer
* node dist/jobs/overdue-balances.js --send --to-override ops@example.com
*
* Env: BILLING_DB, SMTP_* (see src/mailer.ts), PORTAL_URL
* (default https://zap.verae.ai/portal/), OVERDUE_THRESHOLD_CENTS (default 0).
* Nothing is emailed unless --send is passed.
* (default https://zap.verae.ai/portal/), OVERDUE_THRESHOLD_CENTS (default 0;
* may only be 0 or negative, a positive value is clamped to 0 so zero/positive
* balances can never be targeted). Nothing is emailed unless --send is passed.
*/
import { DEFAULT_TIERS, TierConfig } from '../pricing';
import type { Customer, CustomerRepo } from '../auth';
@ -100,16 +102,37 @@ export async function accountPosition(
};
}
/** Billing parents (not Peergos sub-accounts / platform org) below threshold. */
/**
* Clamp a requested threshold so the job can only ever target negative
* balances: anything > 0 (or non-finite) becomes 0. A negative threshold
* narrows the list further (e.g. -500 = only customers owing more than $5).
*/
export function negativeOnlyThreshold(thresholdCents: number | undefined): number {
const t = Number(thresholdCents ?? 0);
if (!Number.isFinite(t) || t > 0) return 0;
return Math.trunc(t);
}
/** True only for a strictly negative effective balance (zero/positive → false). */
export function isNegativeBalance(pos: Pick<AccountPosition, 'effectiveBalanceCents'>): boolean {
return Number.isFinite(pos.effectiveBalanceCents) && pos.effectiveBalanceCents < 0;
}
/**
* Billing parents (not Peergos sub-accounts / platform org) with a NEGATIVE
* effective balance below `thresholdCents` (clamped to <= 0). Customers with a
* zero or positive effective balance are never returned.
*/
export async function listOverdueCustomers(
deps: OverdueDeps,
thresholdCents = 0,
): Promise<AccountPosition[]> {
const threshold = negativeOnlyThreshold(thresholdCents);
const out: AccountPosition[] = [];
for (const c of await deps.customers.list()) {
if (c.parentId || PLATFORM_IDS.has(c.id)) continue;
const pos = await accountPosition(c, deps);
if (pos.effectiveBalanceCents < thresholdCents) out.push(pos);
if (isNegativeBalance(pos) && pos.effectiveBalanceCents < threshold) out.push(pos);
}
return out.sort((a, b) => a.effectiveBalanceCents - b.effectiveBalanceCents);
}
@ -154,6 +177,11 @@ export async function sendPaymentRequests(
const skipped: { customerId: string; reason: string }[] = [];
if (!mailer.available()) throw new Error('SMTP not configured (SMTP_USER/SMTP_PASS)');
for (const pos of rows) {
// Defense in depth: never send a payment request for a zero/positive balance.
if (!isNegativeBalance(pos)) {
skipped.push({ customerId: pos.customerId, reason: 'balance not negative' });
continue;
}
const to = opts.toOverride || pos.email;
if (!to) {
skipped.push({ customerId: pos.customerId, reason: 'no email on file' });
@ -189,9 +217,15 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
const { SqlitePricingStore } = await import('../db/pricing-store');
const { smtpMailerFromEnv } = await import('../mailer');
const threshold = Number(
const requested = Number(
argValue(argv, '--threshold-cents') ?? process.env.OVERDUE_THRESHOLD_CENTS ?? 0,
);
const threshold = negativeOnlyThreshold(requested);
if (threshold !== requested) {
console.error(
`overdue-balances: threshold ${requested} clamped to ${threshold} (only negative balances are targeted)`,
);
}
const db = openSqlite(billingDbPath());
const deps: OverdueDeps = {
customers: new SqliteCustomerRepo(db),
@ -205,12 +239,15 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
}
})(),
};
const rows = await listOverdueCustomers(deps, Number.isFinite(threshold) ? threshold : 0);
const rows = await listOverdueCustomers(deps, threshold);
if (argv.includes('--json')) {
console.log(JSON.stringify({ thresholdCents: threshold, count: rows.length, customers: rows }, null, 2));
} else {
console.log(`overdue-balances: ${rows.length} customer(s) below ${usd(threshold)} effective balance`);
console.log(
`overdue-balances: ${rows.length} customer(s) with a negative effective balance` +
(threshold < 0 ? ` below ${usd(threshold)}` : ''),
);
for (const r of rows) {
console.log(
[

View file

@ -5,9 +5,12 @@ import { InMemoryInvoiceRepo } from '../src/invoicing';
import { DEFAULT_TIERS } from '../src/pricing';
import { InMemoryUsageRepo } from '../src/usage';
import { MemoryMailer } from '../src/mailer';
import type { Customer } from '../src/auth';
import {
accountPosition,
isNegativeBalance,
listOverdueCustomers,
negativeOnlyThreshold,
paymentRequestEmail,
sendPaymentRequests,
} from '../src/jobs/overdue-balances';
@ -99,3 +102,59 @@ describe('overdue-balances job', () => {
expect(mailer.sent[0].text).toMatch(/keep working/);
});
});
describe('overdue-balances targets ONLY negative balances', () => {
const now = new Date('2026-10-05T12:00:00Z');
const mk = (id: string, balanceCents: number, extra: Partial<Customer> = {}): Customer =>
({ id, name: id, apiKey: `k-${id}`, tierId: 'free', email: `${id}@example.com`, balanceCents, ...extra }) as Customer;
const all: Customer[] = [
mk('neg', -250),
mk('zero', 0),
mk('pos', 1000),
mk('owes-usage', 0), // effective negative via unbilled usage
mk('cust-platform', -9999),
mk('child', -5000, { parentId: 'neg' }),
];
function deps() {
const usage = new InMemoryUsageRepo();
// free tier credit 100c → 300c usage → 200c unbilled
for (let i = 0; i < 3; i++) {
usage.record({ customerId: 'owes-usage', endpointId: 'timestamp', cents: 100, metadataBytes: 0, attachmentBytes: 0, timestamp: new Date('2026-10-02T00:00:00Z') });
}
return { customers: { list: async () => all }, usage, invoices: new InMemoryInvoiceRepo(), tiers: DEFAULT_TIERS, now };
}
it('excludes zero and positive effective balances', async () => {
const rows = await listOverdueCustomers(deps(), 0);
expect(rows.map((r) => r.customerId).sort()).toEqual(['neg', 'owes-usage']);
expect(rows.every((r) => r.effectiveBalanceCents < 0)).toBe(true);
});
it('a positive threshold is clamped to 0 (never includes zero/positive)', async () => {
expect(negativeOnlyThreshold(500)).toBe(0);
expect(negativeOnlyThreshold(Number.NaN)).toBe(0);
expect(negativeOnlyThreshold(undefined)).toBe(0);
expect(negativeOnlyThreshold(-300)).toBe(-300);
const rows = await listOverdueCustomers(deps(), 100000);
expect(rows.map((r) => r.customerId).sort()).toEqual(['neg', 'owes-usage']);
});
it('a negative threshold narrows the list further', async () => {
const rows = await listOverdueCustomers(deps(), -225);
expect(rows.map((r) => r.customerId)).toEqual(['neg']);
});
it('sendPaymentRequests refuses zero/positive rows even if passed in', async () => {
const d = deps();
const zero = await accountPosition(all[1], d);
const pos = await accountPosition(all[2], d);
const neg = await accountPosition(all[0], d);
expect(isNegativeBalance(zero)).toBe(false);
expect(isNegativeBalance(pos)).toBe(false);
const mailer = new MemoryMailer();
const out = await sendPaymentRequests([neg, zero, pos], mailer, { log: () => {} });
expect(out.sent).toEqual(['neg']);
expect(out.skipped.map((s) => s.customerId).sort()).toEqual(['pos', 'zero']);
expect(mailer.sent).toHaveLength(1);
});
});