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:
parent
3d40fdeb09
commit
1ac4e5539e
3 changed files with 110 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue