Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion server/api/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
* directly rather than read off the context.
*/
import { createRoute } from '@hono/zod-openapi';
import { count, eq } from 'drizzle-orm';
import { createApiRouter } from '../lib/openapi-router';
import { getDrizzle } from '../lib/route-helpers';
import { Errors } from '../lib/errors';
import { withMcpMetadata } from '../lib/route-metadata-standards';
import { MeteringService } from '../services/metering.service';
import { inspections as inspectionsTable } from '../lib/db/schema';
import { getSeatUsage } from '../features/seat-quota';
import { readTenantTier } from '../features/plan-quota/guard';
import { FREE_TIER_CAPS } from '../features/plan-quota/policy';
Expand Down Expand Up @@ -68,13 +71,29 @@ const usageRoutes = createApiRouter()

const isFreeTierQuota = tier === 'free' && c.var.profile.hasUsageQuota;

// The number shown against a cap must be the number the cap is enforced
// against. `PlanQuotaGuard.consumeInspection` gates on the inspection
// ROWS a tenant has, and `usage_counters.value` is only a self-healing
// cache of that — it reads stale-high between a delete and the next
// create, which would tell a tenant "5 of 5 used" while creating still
// works. So capped tenants get the live count.
//
// Everyone else keeps the cumulative lifetime counter: with `caps: null`
// it is measured against nothing, and quietly redefining it from "how
// many they have ever created" to "how many they have now" would change
// an analytics figure nobody asked to change.
const inspectionsUsed = isFreeTierQuota
? (await getDrizzle(c).select({ n: count() }).from(inspectionsTable)
.where(eq(inspectionsTable.tenantId, tenantId)).get())?.n ?? 0
: inspections;

return c.json({
success: true as const,
data: {
tier,
caps: isFreeTierQuota ? FREE_TIER_CAPS : null,
usage: {
inspections, sms, email,
inspections: inspectionsUsed, sms, email,
smsByo, emailByo,
aiTranslate, aiTranslateByo,
aiAssist, aiAssistByo,
Expand Down
67 changes: 53 additions & 14 deletions server/features/plan-quota/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ import { FREE_TIER_CAPS, type AiCappedMetric, type AiTierCaps } from './policy';
* The actual meter increment stays at the existing send-site call (see
* MeteringService.record in the sms/email pipelines) so a provider failure
* never consumes quota it didn't actually spend.
*
* IMPORTANT — `usage_counters.value` for the `inspections` metric is a CACHE,
* not the gate. The cap counts the inspection rows a tenant has; the counter is
* written alongside it and heals itself on the next create. A value that looks
* wrong (higher than the row count, e.g. after a delete) is therefore not a
* defect and must NOT be "corrected" by hand — that is what the 2026-08-05
* production hand-fix did, and it is exactly what this design makes
* unnecessary. Deleting the row is worse than leaving it: see the INSERT-branch
* gating below. `sms`/`email` are the opposite — consumed events with nothing
* to count, so their counters ARE the source of truth.
*/
/**
* One-line tenant-tier lookup, defaulting to 'free' when the row is missing
Expand Down Expand Up @@ -43,30 +53,59 @@ export class PlanQuotaGuard {
},
) {}

/** Atomic consume for inspection creation. Free+enforced: increment-if-below-cap
* (throws QuotaExhausted at the cap). Other tiers / standalone: plain increment
* (lifetime analytics). Counter is monotonic — deletes never refund. */
async consumeInspection(tenantId: string): Promise<void> {
/** Atomic consume for inspection creation. Free+enforced: allow-if-the-rows-a-
* tenant-has-plus-`count`-fit-under-the-cap (throws QuotaExhausted otherwise).
* Other tiers / standalone: plain increment (lifetime analytics). The cap
* counts the inspections a tenant HAS, so deleting one returns the allowance.
*
* `count` is the number of inspections the caller is about to create in one
* go. It must be passed for a batch rather than looping: because the gate
* counts rows and the caller inserts them only after this returns, N looped
* calls all read the same count and all pass — a 3-sub request would take a
* tenant from 3 inspections to 6. One call with `count: 3` is the same single
* statement and admits the batch only if the whole batch fits. */
async consumeInspection(tenantId: string, count = 1): Promise<void> {
const tier = await readTenantTier(this.db, tenantId);

if (!this.opts.enforced || tier !== 'free') {
await new MeteringService(this.db).record(tenantId, 'inspections', STOCK_PERIOD);
await new MeteringService(this.db).record(tenantId, 'inspections', STOCK_PERIOD, count);
return;
}

const cap = FREE_TIER_CAPS.inspections;
// Single-statement conditional increment: the guarded UPDATE only fires
// while value < cap, so D1's `meta.changes === 0` is the authoritative
// "already at cap" signal even under concurrent callers — SQLite/D1
// serialize writes to a given row, so there is no read-then-write window
// for two callers to both observe "below cap" and both increment past it.
// THE GATE COUNTS ROWS. `usage_counters.value` is a self-healing display
// cache for this metric, never the thing enforced — a stale value can no
// longer refuse a tenant who is genuinely under the cap, and a delete
// returns the allowance without anything having to write the counter back.
// (Do not "fix" a stale value by hand; the next create heals it. See #105.)
//
// Still a single statement, and still atomic: D1/SQLite serialize writes to
// a given row, so `meta.changes === 0` remains the authoritative "at cap"
// answer with no read-then-write window inside the guard. The INSERT branch
// is gated too — hence `INSERT ... SELECT ... WHERE` rather than VALUES,
// because a conflict-free INSERT never reaches DO UPDATE and would wave
// through a tenant who has rows but no counter row yet.
//
// What counting rows DOES give up, unavoidably: the caller inserts the
// inspection row AFTER this returns, so two creates that overlap before
// either row lands both see the same count and both pass. The cap is
// therefore a steady-state invariant, not a serialized claim — an overshoot
// is bounded by in-flight concurrency and self-corrects, because the next
// create counts the rows that actually exist and refuses. That trade is
// deliberate: the alternative (a counter that can only climb) is what cost
// real tenants their allowance permanently. The one case that would NOT
// have been a rare race — a caller creating N rows in a loop — is why
// `count` exists rather than being left to the caller to iterate.
const res = await this.db.prepare(
`INSERT INTO usage_counters (tenant_id, metric, period_key, value, updated_at)
VALUES (?1, 'inspections', 'lifetime', 1, ?2)
SELECT ?1, 'inspections', 'lifetime', cnt.n + ?4, ?2
FROM (SELECT COUNT(*) AS n FROM inspections WHERE tenant_id = ?1) AS cnt
WHERE cnt.n + ?4 <= ?3
ON CONFLICT(tenant_id, metric, period_key)
DO UPDATE SET value = value + 1, updated_at = ?2
WHERE usage_counters.value < ?3`,
).bind(tenantId, Date.now(), cap).run();
DO UPDATE SET value = (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + ?4,
updated_at = excluded.updated_at
WHERE (SELECT COUNT(*) FROM inspections WHERE tenant_id = excluded.tenant_id) + ?4 <= ?3`,
).bind(tenantId, Date.now(), cap, count).run();

if (res.meta.changes === 0) {
throw Errors.QuotaExhausted({ metric: 'inspections', used: cap, cap, billingPortalUrl: this.opts.billingPortalUrl });
Expand Down
10 changes: 5 additions & 5 deletions server/services/inspection-request.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,14 @@ export class InspectionRequestService {

const totalAmount = subs.reduce((sum, s) => sum + (s.price ?? 0), 0);

// Quota is consumed once per sub-inspection, after every precondition
// Quota is consumed for the whole batch at once, after every precondition
// check above (bounds, template ownership) and BEFORE the parent
// request row is inserted — a request row must never be orphaned
// (created with zero children) because the tenant hit the cap
// partway through.
for (let i = 0; i < subs.length; i++) {
await this.planQuota?.consumeInspection(tenantId);
}
// partway through. One call, not a loop: the guard counts existing
// inspection rows and these are inserted below, so N looped calls would
// all read the same count and all pass. See consumeInspection's `count`.
await this.planQuota?.consumeInspection(tenantId, subs.length);

await db.insert(inspectionRequests).values({
id: requestId,
Expand Down
20 changes: 16 additions & 4 deletions tests/unit/inspections/inspection-quota.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,26 @@ describe('Inspection creation consumes the free-tier quota (Task 3)', () => {
expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(3);
});

it('a delete does not refund quota', async () => {
it('a delete DOES return the allowance, and the counter heals on the next create', async () => {
const guard = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null });
const svc = makeService(guard);

const a = await svc.createInspection(TENANT, minimalCreateData());
await deleteInspectionCascade(testDb as unknown as DrizzleD1Database, makeR2(), TENANT, a.id);
// Fill the cap, then delete two — the tenant is under it again.
const created = [];
for (let i = 0; i < 5; i++) created.push(await svc.createInspection(TENANT, minimalCreateData()));
await expect(svc.createInspection(TENANT, minimalCreateData())).rejects.toMatchObject({
status: 402, code: 'QUOTA_EXHAUSTED',
});
for (const insp of created.slice(0, 2)) {
await deleteInspectionCascade(testDb as unknown as DrizzleD1Database, makeR2(), TENANT, insp.id);
}

expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(1);
// The counter is momentarily stale-high (nothing writes it on delete)...
expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(5);
// ...but it is a display cache, not the gate: the create is admitted,
// and the cache heals to the truth as it lands.
await expect(svc.createInspection(TENANT, minimalCreateData())).resolves.toBeDefined();
expect(await new MeteringService(testD1).lifetimeTotal(TENANT, 'inspections')).toBe(4);
});

it('cloneInspection of a nonexistent id rejects and does not consume quota', async () => {
Expand Down
Loading
Loading