fix: charge /v1/timestamp only after successful stamp

Defer meter recording for single stamps so upstream failures (402/5xx)
do not bill customers. commitMeteredUsage on 2xx/mock; abandonMeteredUsage
zeros X-Verae-Credits-Spend on failure. Batch path unchanged.
This commit is contained in:
George Lambert 2026-10-05 14:08:34 -04:00
commit 3d40fdeb09
4 changed files with 212 additions and 17 deletions

View file

@ -83,6 +83,10 @@ The 50/month limit belongs to the middleware tenant, not to api.veraetime.net cu
The monthly counter resets automatically when the period rolls over (`usage.period`, e.g. `2026-10` → `2026-11`).
## Known follow-up
## Charge only after a successful stamp
`meter()` records usage **before** the upstream stamp. A stamp that fails upstream (for example the 402 above) is still billed. Customers can then go negative for stamps they never received, and the overdue job would ask them to pay for those. Fixing this needs deferred/compensating usage recording on non-2xx. It isn't part of this change.
`POST /v1/timestamp` uses deferred metering (`meter(..., { deferRecord: true })`):
* Usage (and the NATS `verae.billing.usage.recorded` event) is written only via `commitMeteredUsage` after a 2xx upstream stamp (or a local mock when `ALLOW_MOCK_CERTS=true`).
* Upstream failures (402, 5xx, unconfigured) call `abandonMeteredUsage`, which records nothing and sets `X-Verae-Credits-Spend: 0`.
* Batch `POST /v1/timestamps` already recorded per successful item only; unchanged.

View file

@ -10,7 +10,7 @@ import {
CustomerRepo,
InMemoryCustomerRepo,
} from './auth';
import { billingCustomerId, meter } from './meter';
import { abandonMeteredUsage, billingCustomerId, commitMeteredUsage, meter } from './meter';
import { resolveBillingChannel } from './billing-channel-resolve';
import { lookupSkuCredits } from './billing-channels';
import { setBillingHeaders } from './billing-headers';
@ -412,7 +412,7 @@ export function buildApp(deps: AppDeps = {}): {
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
});
app.post('/v1/timestamp', meter('timestamp', usage, pricing, onUsage, customers, deps.channelRates), async (req, res) => {
app.post('/v1/timestamp', meter('timestamp', usage, pricing, onUsage, customers, deps.channelRates, { deferRecord: true }), async (req, res) => {
const formats = parseResultFormats(req);
const data = req.body?.data != null ? String(req.body.data) : '';
const sha256 =
@ -421,6 +421,7 @@ export function buildApp(deps: AppDeps = {}): {
(data && /^[a-f0-9]{64}$/i.test(data) ? data.toLowerCase() : '') ||
(data ? createHash('sha256').update(data, 'utf8').digest('hex') : '');
if (!sha256) {
abandonMeteredUsage(res);
res.status(400).json({ error: 'data or sha256 is required' });
return;
}
@ -449,6 +450,7 @@ export function buildApp(deps: AppDeps = {}): {
}
if (upstream && upstream.status >= 200 && upstream.status < 300 && upstream.body && typeof upstream.body === 'object') {
await commitMeteredUsage(res, usage, onUsage);
const body = upstream.body as Record<string, unknown>;
const enriched = await attachRegistrationExports(body, formats, documentName);
if (formats.has('pdf') && req.query.format === 'pdf') {
@ -471,6 +473,7 @@ export function buildApp(deps: AppDeps = {}): {
// Fail closed when upstream is configured (or mock not explicitly allowed).
if (!allowMockCerts()) {
abandonMeteredUsage(res);
rejectUpstreamTimestamp(res, upstream);
return;
}
@ -478,6 +481,7 @@ export function buildApp(deps: AppDeps = {}): {
// Dev-only local mock when ZAPPIER_UPSTREAM unset and ALLOW_MOCK_CERTS=true
const existing = hashIndex.get(sha256);
if (existing) {
await commitMeteredUsage(res, usage, onUsage);
const mockBody = {
jobId: existing.jobId,
sha256,
@ -497,6 +501,7 @@ export function buildApp(deps: AppDeps = {}): {
const timestamp = new Date().toISOString();
hashIndex.set(sha256, { jobId, sha256, data, timestamp });
jobIndex.set(jobId, { jobId, sha256, data, timestamp });
await commitMeteredUsage(res, usage, onUsage);
const mockBody = {
jobId,
sha256,

View file

@ -1,4 +1,4 @@
import { RequestHandler } from 'express';
import { RequestHandler, Response } from 'express';
import { Customer, CustomerRepo, StorageMode } from './auth';
import {
PricingContext,
@ -8,7 +8,7 @@ import {
quoteStorageMonth,
storagePinFromEnv,
} from './pricing';
import { UsageRepo } from './usage';
import { UsageEntry, UsageRepo } from './usage';
import { resolveBillingChannel } from './billing-channel-resolve';
import {
BillingChannel,
@ -65,19 +65,82 @@ type TimestampQuoteCredits = NonNullable<
import('./pricing').TimestampQuoteInput['channelSkuCredits']
>;
export function meter(
endpointId: string,
repo: UsageRepo,
pricing: PricingContext,
onRecord?: (entry: {
export type MeterOnRecord = (entry: {
customerId: string;
endpointId: string;
cents: number;
actorId?: string;
channel?: BillingChannel;
}) => void;
export type MeterOptions = {
/**
* Quote and attach billing headers, but do not write usage until
* `commitMeteredUsage`. Used by `/v1/timestamp` so upstream failures
* (e.g. 402) are not billed.
*/
deferRecord?: boolean;
};
export type PendingMeteredUsage = {
entry: UsageEntry;
onRecordPayload: {
customerId: string;
endpointId: string;
cents: number;
actorId?: string;
channel?: BillingChannel;
}) => void,
};
balanceCredits: number;
};
/**
* Persist deferred meter usage after a successful upstream stamp (or mock).
* No-op when nothing was deferred.
*/
export async function commitMeteredUsage(
res: Response,
repo: UsageRepo,
onRecord?: MeterOnRecord,
): Promise<void> {
const pending = res.locals.pendingUsage as PendingMeteredUsage | undefined;
if (!pending) return;
await repo.record(pending.entry);
onRecord?.(pending.onRecordPayload);
delete res.locals.pendingUsage;
const period = await repo.summaryFor(pending.entry.customerId);
setBillingHeaders(res, {
spendCredits: pending.entry.cents,
balanceCredits: pending.balanceCredits,
periodSpendCredits: period.totalCents,
channel: pending.onRecordPayload.channel,
});
}
/**
* Drop deferred meter usage when the stamp fails (or early 4xx).
* Zeros the spend header so clients do not treat a failed stamp as billed.
*/
export function abandonMeteredUsage(res: Response): void {
const pending = res.locals.pendingUsage as PendingMeteredUsage | undefined;
delete res.locals.pendingUsage;
if (!pending) return;
setBillingHeaders(res, {
spendCredits: 0,
balanceCredits: pending.balanceCredits,
periodSpendCredits: undefined,
channel: pending.onRecordPayload.channel,
});
}
export function meter(
endpointId: string,
repo: UsageRepo,
pricing: PricingContext,
onRecord?: MeterOnRecord,
customers?: CustomerRepo,
channelRates?: { load: () => ChannelRateTable },
options?: MeterOptions,
): RequestHandler {
return (req, res, next) => {
void (async () => {
@ -162,7 +225,7 @@ export function meter(
}
const billedTo = billingCustomerId(customer);
const entry = {
const entry: UsageEntry = {
customerId: billedTo,
endpointId: recordedEndpoint,
cents: quote.totalCents,
@ -170,20 +233,40 @@ export function meter(
attachmentBytes,
timestamp: new Date(),
};
await repo.record(entry);
onRecord?.({
const onRecordPayload = {
customerId: billedTo,
endpointId: recordedEndpoint,
cents: quote.totalCents,
actorId: customer.id !== billedTo ? customer.id : undefined,
channel: res.locals.billingChannel as BillingChannel | undefined,
});
};
const balanceCredits = billParent.balanceCents ?? customer.balanceCents ?? 0;
res.locals.quote = quote;
res.locals.channel = res.locals.billingChannel;
if (options?.deferRecord) {
res.locals.pendingUsage = {
entry,
onRecordPayload,
balanceCredits,
} satisfies PendingMeteredUsage;
// Quote-only headers until commit (spend shown as prospective) or abandon (0).
setBillingHeaders(res, {
spendCredits: quote.totalCents,
balanceCredits,
channel: res.locals.billingChannel as string | undefined,
});
next();
return;
}
await repo.record(entry);
onRecord?.(onRecordPayload);
const period = await repo.summaryFor(billedTo);
setBillingHeaders(res, {
spendCredits: quote.totalCents,
balanceCredits: billParent.balanceCents ?? customer.balanceCents ?? 0,
balanceCredits,
periodSpendCredits: period.totalCents,
channel: res.locals.billingChannel as string | undefined,
});

View file

@ -0,0 +1,103 @@
import request from 'supertest';
import { buildApp } from '../src/app';
import { BILLING_HEADER_SPEND } from '../src/billing-headers';
const KEY = 'key-ada';
describe('charge only after successful stamp', () => {
const prevAllow = process.env.ALLOW_MOCK_CERTS;
const prevUpstream = process.env.ZAPPIER_UPSTREAM;
const orig = globalThis.fetch;
afterEach(() => {
globalThis.fetch = orig;
if (prevAllow === undefined) delete process.env.ALLOW_MOCK_CERTS;
else process.env.ALLOW_MOCK_CERTS = prevAllow;
if (prevUpstream === undefined) delete process.env.ZAPPIER_UPSTREAM;
else process.env.ZAPPIER_UPSTREAM = prevUpstream;
});
it('does not record usage when upstream returns 402', async () => {
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
delete process.env.ALLOW_MOCK_CERTS;
globalThis.fetch = jest.fn(async () =>
new Response(
JSON.stringify({
error: { code: 'QUOTA_EXCEEDED', message: 'Monthly timestamp quota exceeded' },
}),
{ status: 402, headers: { 'content-type': 'application/json' } },
),
) as unknown as typeof fetch;
const { app, usage } = buildApp();
const before = await usage.summaryFor('cust_1');
const res = await request(app)
.post('/v1/timestamp')
.set('x-api-key', KEY)
.send({ sha256: 'c'.repeat(64) });
expect(res.status).toBe(402);
expect(res.body.error).toBe('upstream_timestamp_failed');
expect(res.headers[BILLING_HEADER_SPEND.toLowerCase()]).toBe('0');
const after = await usage.summaryFor('cust_1');
expect(after.calls).toBe(before.calls);
expect(after.totalCents).toBe(before.totalCents);
});
it('does not record usage when upstream returns 503', async () => {
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
delete process.env.ALLOW_MOCK_CERTS;
globalThis.fetch = jest.fn(async () =>
new Response(JSON.stringify({ error: 'verae_unavailable' }), {
status: 503,
headers: { 'content-type': 'application/json' },
}),
) as unknown as typeof fetch;
const { app, usage } = buildApp();
const res = await request(app)
.post('/v1/timestamp')
.set('x-api-key', KEY)
.send({ sha256: 'd'.repeat(64) });
expect(res.status).toBe(503);
expect((await usage.listFor('cust_1')).filter((e) => e.endpointId === 'timestamp')).toHaveLength(0);
expect(res.headers[BILLING_HEADER_SPEND.toLowerCase()]).toBe('0');
});
it('records usage after a successful real upstream stamp', async () => {
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
delete process.env.ALLOW_MOCK_CERTS;
globalThis.fetch = jest.fn(async () =>
new Response(
JSON.stringify({
receipt_id: 'rcpt-live-ok',
certificate: 'cert-live-ok',
status: 'completed',
sha256: 'e'.repeat(64),
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
) as unknown as typeof fetch;
const { app, usage } = buildApp();
const res = await request(app)
.post('/v1/timestamp')
.set('x-api-key', KEY)
.send({ sha256: 'e'.repeat(64) });
expect(res.status).toBe(200);
expect(res.body.receipt_id).toBe('rcpt-live-ok');
const entries = (await usage.listFor('cust_1')).filter((e) => e.endpointId === 'timestamp');
expect(entries).toHaveLength(1);
expect(entries[0].cents).toBeGreaterThan(0);
expect(Number(res.headers[BILLING_HEADER_SPEND.toLowerCase()])).toBe(entries[0].cents);
});
it('does not record usage on missing sha256/data (400)', async () => {
process.env.ZAPPIER_UPSTREAM = 'http://upstream.test';
globalThis.fetch = jest.fn() as unknown as typeof fetch;
const { app, usage } = buildApp();
const res = await request(app).post('/v1/timestamp').set('x-api-key', KEY).send({});
expect(res.status).toBe(400);
expect(await usage.listFor('cust_1')).toHaveLength(0);
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});