SMS Overage Billing Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Use superpowers:test-driven-development for every task. Use the
error-handlingskill (no native try/catch β use@power-rent/try-catch/nextjs). For the UI tasks use thereact-relayskill.
Goal: Let internal staff see and collect what each rental company owes for SMS sent above its plan quota, computed forward-only from real Twilio cost + 50%.
Architecture: A new Neon Postgres table sms_overage (data access SmsOverageRepository extends BaseService, business logic SmsOverageService). Overage is computed live on staff demand from billable_events via the root/sudo client (cross-tenant); persisted only when staff Bill/Waive/Void a closed month. Twilio cost gaps are reconciled at Bill time. A root-admin-gated GraphQL surface backs a staff dashboard.
Tech Stack: TypeScript, Prisma 7 + Neon Postgres, Vitest, GraphQL (SDL + graphql-codegen), Relay, Luxon, @power-rent/try-catch/nextjs.
Branch: feat/sms-overage-billing-design (already checked out). Spec: docs/superpowers/specs/2026-06-17-sms-overage-billing-design.md.
Conventions used throughout:
- Run one test file:
pnpm exec vitest run <path>(add-t "<name>"for a single test). - Typecheck:
pnpm typecheck. Lint a file:pnpm exec eslint <path>. - Tests are co-located
*.test.tsnext to the unit. - Money math uses
Prisma.Decimal. NeverNumber()on costs. - Errors: wrap fallible async in
new Try(...)from@power-rent/try-catch/nextjs; log withconsole.error+ Sentry; never swallow. sms_overageis cross-tenant βBaseService, neverTenantBaseService.
Task 1: sms_overage Neon table + billable_events index
Files:
- Modify:
packages/prisma-client/prisma/schema.prisma - Create (generated):
packages/prisma-client/prisma/migrations/<timestamp>_add_sms_overage/migration.sql
Step 1: Add the model + enum to schema.prisma
Add near the other root models:
enum SmsOverageStatus { unpaid paid waived voided}
model sms_overage { id String @id @default(dbgenerated("snowflake_id()")) period String companyId String tenantId String smsSent Int quota Int overageCount Int realCostSum Decimal @db.Decimal(10, 4) fallbackCount Int markup Decimal @db.Decimal(10, 4) fallbackFlat Decimal @db.Decimal(10, 4) amountDue Decimal @db.Decimal(10, 4) currency String @default("USD") windowFrom DateTime @db.Timestamptz(6) windowTo DateTime @db.Timestamptz(6) messageBasis Json status SmsOverageStatus raisedBy String raisedAt DateTime @db.Timestamptz(6) paidBy String? paidAt DateTime? @db.Timestamptz(6) paymentRef String? waivedBy String? waivedAt DateTime? @db.Timestamptz(6) waiveReason String? voidedBy String? voidedAt DateTime? @db.Timestamptz(6) voidReason String? createdAt DateTime @default(now()) @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6)
@@unique([companyId, period]) @@index([tenantId])}Add the cross-tenant scan index to the existing billable_events model:
@@index([event, date, companyId])Step 2: Validate the schema
Run: pnpm --filter @power-rent/prisma-client run prisma:validate
Expected: βThe schema at prisma/schema.prisma is valid πβ
Step 3: Create + apply the migration
Run: pnpm --filter @power-rent/prisma-client exec prisma migrate dev --name add_sms_overage
Expected: a new timestamped folder under prisma/migrations/ and βYour database is now in syncβ.
If
migrate deverrors on a shadow database (Neon), fall back toprisma migrate dev --create-only --name add_sms_overagethenpnpm --filter @power-rent/prisma-client exec prisma migrate deploy. See memoryproject_prisma_migrations. Identifiers are camelCase β confirm the generated SQL quotes them (memoryfeedback_postgres_quoting).
Step 4: Regenerate the Prisma client
Run: pnpm --filter @power-rent/prisma-client run prisma:generate
Then: pnpm --filter @power-rent/prisma-client run prisma:build
Expected: no errors; sms_overage type now exported from @power-rent/prisma-client.
Step 5: Commit
git add packages/prisma-client/prisma/schema.prisma packages/prisma-client/prisma/migrationsgit commit -m "feat(sms-overage): add sms_overage Neon table + billable_events index"Task 2: Fix quota-count column to date (M7)
Quota count and overage selection must read the same column. Both getSmsSentByTenantId methods currently filter createdAt; switch to date.
Files:
- Modify:
services/global/RootPlanService.ts(methodgetSmsSentByTenantId) - Modify:
services/global/PlanService.ts(methodgetSmsSentByTenantId) - Test:
services/global/RootPlanService.test.ts(create)
Step 1: Write the failing test
import { describe, it, expect, vi, beforeEach } from 'vitest';import { RootPlanService } from './RootPlanService';
describe('RootPlanService.getSmsSentByTenantId', () => { it('counts billable sms events by `date`, not `createdAt`', async () => { const count = vi.fn().mockResolvedValue(7); const svc = new RootPlanService(); // @ts-expect-error test override of the prisma delegate svc.prisma = { billable_events: { count } };
const from = new Date('2026-07-01T00:00:00Z'); const to = new Date('2026-07-31T23:59:59Z'); const result = await svc.getSmsSentByTenantId({ tenantId: 'c1', startOfMonth: from, endOfMonth: to });
expect(result).toBe(7); expect(count).toHaveBeenCalledWith({ where: { tenantId: 'c1', event: 'sms', date: { gte: from, lte: to } }, }); });});Step 2: Run test to verify it fails
Run: pnpm exec vitest run services/global/RootPlanService.test.ts
Expected: FAIL β count called with createdAt, not date.
Step 3: Implement β rename createdAt β date in both files
In RootPlanService.ts and PlanService.ts, inside getSmsSentByTenantId, change the where key createdAt to date (keep the gte/lte).
Step 4: Run test to verify it passes
Run: pnpm exec vitest run services/global/RootPlanService.test.ts
Expected: PASS.
Step 5: Commit
git add services/global/RootPlanService.ts services/global/PlanService.ts services/global/RootPlanService.test.tsgit commit -m "fix(sms-overage): count monthly SMS by date column, not createdAt (M7)"Task 3: Constants
Files:
- Create:
constants/smsOverage.ts - Modify:
constants/index.*(re-export β match the existing barrel; checkconstants/index.ts/.js)
Step 1: Create the constants
export const SMS_MARKUP = 1.5; // +50% on actual Twilio costexport const FALLBACK_FLAT_USD = 0.5; // flat charge for an overage SMS with no recoverable costexport const SMS_OVERAGE_GO_LIVE = '2026-07'; // YYYY-MM, Europe/Rome; SMS before this are never billedexport const SMS_OVERAGE_TZ = 'Europe/Rome';export const SMS_RECONCILE_MAX_CALLS = 200; // cap Twilio price lookups per Billexport const SMS_RECONCILE_CONCURRENCY = 5;Step 2: Re-export from the barrel
Open constants/index.*, add export * from './smsOverage'; following the fileβs existing style.
Step 3: Typecheck
Run: pnpm typecheck
Expected: no new errors.
Step 4: Commit
git add constants/smsOverage.ts constants/index.*git commit -m "feat(sms-overage): add markup, fallback, go-live and reconcile constants"Task 4: Period bounds helper (Europe/Rome) β M6
A pure function turning 'YYYY-MM' into UTC [from, to) bounds in Europe/Rome, plus validation and a βclosed monthβ check.
Files:
- Create:
services/global/smsOverage/period.ts - Test:
services/global/smsOverage/period.test.ts
Step 1: Write the failing test
import { describe, it, expect } from 'vitest';import { parsePeriod, periodBounds, isValidPeriod, isClosedPeriod } from './period';
describe('smsOverage/period', () => { it('rejects malformed periods', () => { expect(isValidPeriod('2026-13')).toBe(false); expect(isValidPeriod('26-07')).toBe(false); expect(isValidPeriod('2026-07')).toBe(true); });
it('computes Europe/Rome month bounds as UTC', () => { const { from, to } = periodBounds('2026-07'); // July 2026 in Rome is +02:00 β 1 Jul 00:00 local == 30 Jun 22:00 UTC expect(from.toISOString()).toBe('2026-06-30T22:00:00.000Z'); expect(to.toISOString()).toBe('2026-07-31T22:00:00.000Z'); });
it('treats a Rome-midnight message as belonging to the new month', () => { const { from } = periodBounds('2026-07'); const romeMidnight = new Date('2026-07-01T00:30:00+02:00'); // 2026-06-30T22:30Z expect(romeMidnight >= from).toBe(true); // counted in July, not June });
it('detects closed vs open months relative to a reference now', () => { const now = new Date('2026-08-10T12:00:00Z'); expect(isClosedPeriod('2026-07', now)).toBe(true); expect(isClosedPeriod('2026-08', now)).toBe(false); expect(isClosedPeriod('2026-09', now)).toBe(false); });});Step 2: Run test to verify it fails
Run: pnpm exec vitest run services/global/smsOverage/period.test.ts
Expected: FAIL β module not found.
Step 3: Implement
import { DateTime } from 'luxon';import { SMS_OVERAGE_TZ } from '@power-rent/constants';
const PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
export function isValidPeriod(period: string): boolean { return PERIOD_RE.test(period);}
export function parsePeriod(period: string): { year: number; month: number } { if (!isValidPeriod(period)) throw new Error(`Invalid period: ${period}`); const [year, month] = period.split('-').map(Number); return { year, month };}
export function periodBounds(period: string): { from: Date; to: Date } { const { year, month } = parsePeriod(period); const start = DateTime.fromObject({ year, month, day: 1 }, { zone: SMS_OVERAGE_TZ }).startOf('month'); const end = start.plus({ months: 1 }); return { from: start.toUTC().toJSDate(), to: end.toUTC().toJSDate() };}
// A period is closed once the current Rome month is strictly after it.export function isClosedPeriod(period: string, now: Date = new Date()): boolean { const { year, month } = parsePeriod(period); const periodStart = DateTime.fromObject({ year, month, day: 1 }, { zone: SMS_OVERAGE_TZ }).startOf('month'); const currentStart = DateTime.fromJSDate(now).setZone(SMS_OVERAGE_TZ).startOf('month'); return periodStart < currentStart;}Step 4: Run test to verify it passes
Run: pnpm exec vitest run services/global/smsOverage/period.test.ts
Expected: PASS.
Step 5: Commit
git add services/global/smsOverage/period.ts services/global/smsOverage/period.test.tsgit commit -m "feat(sms-overage): Europe/Rome period bounds + closed-month helper (M6)"Task 5: quotaFor + billing-start resolution (M1, billing window)
Decides whether a company is billable for a period and the included quota.
Files:
- Create:
services/global/smsOverage/quota.ts - Test:
services/global/smsOverage/quota.test.ts
Verify the plan shape first: read
services/global/shared/getPlanFeaturesusage inserver/graphql/resolvers/shared/plan.jsandconstants/features.jsso the SMS-feature check matches. The function below takes an already-loaded plan object so it stays pure and testable.
Step 1: Write the failing test
import { describe, it, expect } from 'vitest';import { quotaFor } from './quota';
const base = { id: 'p1', startMonth: '2026-07', features: { sms: true }, limits: { sms: 500 } };
describe('quotaFor', () => { it('returns the plan included quota for a paid SMS plan', () => { expect(quotaFor(base as any)).toEqual({ billable: true, quota: 500 }); }); it('treats quota 0 as all-overage (billable)', () => { expect(quotaFor({ ...base, limits: { sms: 0 } } as any)).toEqual({ billable: true, quota: 0 }); }); it('excludes no/default plan', () => { expect(quotaFor(null as any).billable).toBe(false); expect(quotaFor({ ...base, id: 'default' } as any).billable).toBe(false); }); it('excludes when SMS feature disabled', () => { expect(quotaFor({ ...base, features: { sms: false } } as any).billable).toBe(false); }); it('excludes + flags when SMS enabled but quota missing', () => { const res = quotaFor({ ...base, limits: {} } as any); expect(res).toEqual({ billable: false, reason: 'missing-quota' }); });});Step 2: Run β verify it fails
Run: pnpm exec vitest run services/global/smsOverage/quota.test.ts
Expected: FAIL β module not found.
Step 3: Implement
import * as Sentry from '@sentry/nextjs';
type PlanLike = { id: string; features?: { sms?: boolean } | null; limits?: { sms?: number | null } | null;} | null;
export type QuotaResult = | { billable: true; quota: number } | { billable: false; reason?: 'missing-quota' };
export function quotaFor(plan: PlanLike): QuotaResult { if (!plan || plan.id === 'default') return { billable: false }; if (!plan.features?.sms) return { billable: false };
const sms = plan.limits?.sms; if (sms == null) { Sentry.captureException(new Error(`SMS-enabled plan ${plan.id} has no limits.sms`)); return { billable: false, reason: 'missing-quota' }; } return { billable: true, quota: sms };}Step 4: Run β verify it passes
Run: pnpm exec vitest run services/global/smsOverage/quota.test.ts
Expected: PASS.
Step 5: Commit
git add services/global/smsOverage/quota.ts services/global/smsOverage/quota.test.tsgit commit -m "feat(sms-overage): quotaFor billable/quota resolution (M1)"Task 6: Overage charge math (per-message floor) β M9, S10
Pure function: given ordered overage messages, compute the charge. Heaviest money tests live here.
Files:
- Create:
services/global/smsOverage/charge.ts - Test:
services/global/smsOverage/charge.test.ts
Step 1: Write the failing test
import { describe, it, expect } from 'vitest';import { Prisma } from '@power-rent/prisma-client';import { computeCharge } from './charge';
const D = (n: string) => new Prisma.Decimal(n);
describe('computeCharge', () => { it('floors cost*1.5 per real message; flat 0.50 for no-cost', () => { const res = computeCharge([ { messageId: 'a', cost: D('0.35') }, { messageId: 'b', cost: D('0.35') }, { messageId: 'c', cost: D('0.35') }, { messageId: 'd', cost: null }, ]); expect(res.amountDue.toString()).toBe('2.06'); // 3*0.52 + 0.50 expect(res.fallbackCount).toBe(1); expect(res.realCostSum.toString()).toBe('1.05'); expect(res.overageCount).toBe(4); expect(res.messageBasis).toEqual([ { messageId: 'a', cost: '0.35', charge: '0.52', usedFallback: false }, { messageId: 'b', cost: '0.35', charge: '0.52', usedFallback: false }, { messageId: 'c', cost: '0.35', charge: '0.52', usedFallback: false }, { messageId: 'd', cost: '0', charge: '0.50', usedFallback: true }, ]); });
it('a single 35c overage SMS = 0.52 (floored from 0.525)', () => { expect(computeCharge([{ messageId: 'x', cost: D('0.35') }]).amountDue.toString()).toBe('0.52'); });
it('treats cost 0 as fallback', () => { expect(computeCharge([{ messageId: 'x', cost: D('0') }]).amountDue.toString()).toBe('0.50'); });
it('empty overage = 0', () => { expect(computeCharge([]).amountDue.toString()).toBe('0'); });});Step 2: Run β verify it fails
Run: pnpm exec vitest run services/global/smsOverage/charge.test.ts
Expected: FAIL β module not found.
Step 3: Implement
import { Prisma } from '@power-rent/prisma-client';import { SMS_MARKUP, FALLBACK_FLAT_USD } from '@power-rent/constants';
export type OverageMessage = { messageId: string; cost: Prisma.Decimal | null };
export type MessageBasis = { messageId: string; cost: string; charge: string; usedFallback: boolean };
export type ChargeResult = { overageCount: number; realCostSum: Prisma.Decimal; fallbackCount: number; amountDue: Prisma.Decimal; messageBasis: MessageBasis[];};
const MARKUP = new Prisma.Decimal(SMS_MARKUP);const FLAT = new Prisma.Decimal(FALLBACK_FLAT_USD);
function isReal(cost: Prisma.Decimal | null): cost is Prisma.Decimal { return cost != null && cost.greaterThan(0);}
export function computeCharge(messages: OverageMessage[]): ChargeResult { let realCostSum = new Prisma.Decimal(0); let fallbackCount = 0; let amountDue = new Prisma.Decimal(0); const messageBasis: MessageBasis[] = [];
for (const m of messages) { if (isReal(m.cost)) { const charge = m.cost.times(MARKUP).toDecimalPlaces(2, Prisma.Decimal.ROUND_DOWN); realCostSum = realCostSum.plus(m.cost); amountDue = amountDue.plus(charge); messageBasis.push({ messageId: m.messageId, cost: m.cost.toString(), charge: charge.toString(), usedFallback: false }); } else { fallbackCount += 1; amountDue = amountDue.plus(FLAT); messageBasis.push({ messageId: m.messageId, cost: '0', charge: FLAT.toFixed(2), usedFallback: true }); } }
return { overageCount: messages.length, realCostSum, fallbackCount, amountDue, messageBasis };}Step 4: Run β verify it passes
Run: pnpm exec vitest run services/global/smsOverage/charge.test.ts
Expected: PASS (incl. the $2.06 and $0.52 assertions).
Step 5: Commit
git add services/global/smsOverage/charge.ts services/global/smsOverage/charge.test.tsgit commit -m "feat(sms-overage): per-message floored charge math, x1.5 + 0.50 flat (M9)"Task 7: SmsOverageRepository
Data access for the new table β extends BaseService<'sms_overage'> (root/cross-tenant, like TenantsRepository).
Files:
- Create:
repositories/sms-overage/SmsOverageRepository.ts - Test:
repositories/sms-overage/SmsOverageRepository.test.ts
Step 1: Write the failing test
import { describe, it, expect, vi } from 'vitest';import { SmsOverageRepository } from './SmsOverageRepository';
describe('SmsOverageRepository', () => { it('finds a row by company + period', async () => { const findUnique = vi.fn().mockResolvedValue({ id: '1' }); const repo = new SmsOverageRepository(); // @ts-expect-error inject delegate repo.model = { findUnique }; const row = await repo.findByCompanyPeriod('c1', '2026-07'); expect(row).toEqual({ id: '1' }); expect(findUnique).toHaveBeenCalledWith({ where: { companyId_period: { companyId: 'c1', period: '2026-07' } } }); });});Step 2: Run β verify it fails
Run: pnpm exec vitest run repositories/sms-overage/SmsOverageRepository.test.ts
Expected: FAIL β module not found.
Step 3: Implement
import prisma, { sms_overage } from '@power-rent/prisma-client';import { BaseService } from '@power-rent/services/base/BaseService';
/** Data access for the root `sms_overage` table (cross-tenant; BaseService, not TenantBaseService). */export class SmsOverageRepository extends BaseService<'sms_overage'> { constructor() { super(prisma, 'sms_overage'); }
async findByCompanyPeriod(companyId: string, period: string): Promise<sms_overage | null> { return this.model.findUnique({ where: { companyId_period: { companyId, period } } }); }}Step 4: Run β verify it passes
Run: pnpm exec vitest run repositories/sms-overage/SmsOverageRepository.test.ts
Expected: PASS.
If the generated unique accessor is not
companyId_period, copy the exact name Prisma generated for@@unique([companyId, period])from the generated client and update the test + code together.
Step 5: Commit
git add repositories/sms-overage/SmsOverageRepository.ts repositories/sms-overage/SmsOverageRepository.test.tsgit commit -m "feat(sms-overage): SmsOverageRepository (BaseService root table)"Task 8: SmsOverageService β live worklist (expected payments)
Cross-tenant aggregate β per over-quota company β preview charge (fallback $0.50 for cost 0/null, no Twilio call on read).
Files:
- Create:
services/global/SmsOverageService.ts - Test:
services/global/SmsOverageService.test.ts - Modify:
services/index.ts(registersmsOverageService)
Step 1: Write the failing test β worklist excludes under-quota and already-handled periods, applies billing-start floor, uses fallback for cost 0/null without calling Twilio.
import { describe, it, expect, vi } from 'vitest';import { Prisma } from '@power-rent/prisma-client';import { SmsOverageService } from './SmsOverageService';
const D = (n: string) => new Prisma.Decimal(n);
function makeService(overrides: Partial<any> = {}) { const deps = { billableEvents: { groupBy: vi.fn(), findMany: vi.fn() }, plans: { planByCompanyId: vi.fn() }, repo: { findManyByPeriod: vi.fn().mockResolvedValue([]) }, twilio: { getSmsPrice: vi.fn() }, ...overrides, }; return { svc: new SmsOverageService(deps as any), deps };}
describe('SmsOverageService.worklist', () => { it('returns expected payment for an over-quota company, fallback for cost 0, no Twilio call', async () => { const { svc, deps } = makeService(); deps.billableEvents.groupBy.mockResolvedValue([{ companyId: 'c1', _count: { _all: 3 } }]); deps.plans.planByCompanyId.mockResolvedValue({ id: 'p1', startMonth: '2026-07', features: { sms: true }, limits: { sms: 1 } }); deps.billableEvents.findMany.mockResolvedValue([ { id: '1', messageId: 'a', cost: D('0.35'), date: new Date('2026-07-02T00:00:00Z') }, { id: '2', messageId: 'b', cost: D('0.35'), date: new Date('2026-07-03T00:00:00Z') }, { id: '3', messageId: 'c', cost: null, date: new Date('2026-07-04T00:00:00Z') }, ]);
const rows = await svc.worklist('2026-07');
expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ companyId: 'c1', overageCount: 2, amountDue: '1.02' }); // 0.52 + 0.50 expect(deps.twilio.getSmsPrice).not.toHaveBeenCalled(); });});Step 2: Run β verify it fails
Run: pnpm exec vitest run services/global/SmsOverageService.test.ts
Expected: FAIL β module not found.
Step 3: Implement the worklist (constructor takes injected deps for testability; the real wiring is in services/index.ts).
import prisma, { Prisma } from '@power-rent/prisma-client';import { SMS_OVERAGE_GO_LIVE } from '@power-rent/constants';import { rootPlanService } from '@power-rent/services';import { Twilio } from '@power-rent/server/graphql/helpers';import { SmsOverageRepository } from '@power-rent/repositories/sms-overage/SmsOverageRepository';import { periodBounds, isValidPeriod } from './smsOverage/period';import { quotaFor } from './smsOverage/quota';import { computeCharge, type OverageMessage } from './smsOverage/charge';
type Deps = { prisma?: typeof prisma; plans?: { planByCompanyId: (id: string) => Promise<any> }; repo?: SmsOverageRepository; twilio?: { getSmsPrice: (sid: string) => Promise<number> };};
export type WorklistRow = { companyId: string; period: string; smsSent: number; quota: number; overageCount: number; amountDue: string; // serialized Decimal currency: 'USD';};
export class SmsOverageService { private prisma: typeof prisma; private plans: NonNullable<Deps['plans']>; private repo: SmsOverageRepository; private twilio: NonNullable<Deps['twilio']>;
constructor(deps: Deps = {}) { this.prisma = deps.prisma ?? prisma; this.plans = deps.plans ?? rootPlanService; this.repo = deps.repo ?? new SmsOverageRepository(); this.twilio = deps.twilio ?? new Twilio(); }
async worklist(period: string): Promise<WorklistRow[]> { if (!isValidPeriod(period)) throw new Error(`Invalid period: ${period}`); const { from, to } = periodBounds(period);
const grouped = await this.prisma.billable_events.groupBy({ by: ['companyId'], where: { event: 'sms', date: { gte: from, lt: to } }, _count: { _all: true }, });
const handled = new Set( (await this.repo.model.findMany({ where: { period, status: { in: ['unpaid', 'paid', 'waived'] } }, select: { companyId: true }, })).map((r) => r.companyId), );
const rows: WorklistRow[] = []; for (const g of grouped) { if (handled.has(g.companyId)) continue; const plan = await this.plans.planByCompanyId(g.companyId); const q = quotaFor(plan); if (!q.billable) continue; const smsSent = g._count._all; if (smsSent <= q.quota) continue;
const messages = await this.overageMessages(g.companyId, plan, from, to, q.quota); const charge = computeCharge(messages.map((m) => ({ messageId: m.messageId, cost: m.cost }))); rows.push({ companyId: g.companyId, period, smsSent, quota: q.quota, overageCount: charge.overageCount, amountDue: charge.amountDue.toString(), currency: 'USD', }); } return rows; }
// overage = period SMS on/after billing start, ordered date asc, id asc, after the first `quota` private async overageMessages(companyId: string, plan: any, from: Date, to: Date, quota: number) { const billingFloor = this.billingStart(plan, from); const all = await this.prisma.billable_events.findMany({ where: { companyId, event: 'sms', date: { gte: billingFloor, lt: to } }, orderBy: [{ date: 'asc' }, { id: 'asc' }], select: { id: true, messageId: true, cost: true, date: true }, }); return all.slice(quota); }
private billingStart(plan: any, periodFrom: Date): Date { // Forward-only floor: never bill before go-live, the period start, or the company's plan start. const candidates = [periodFrom, periodBounds(SMS_OVERAGE_GO_LIVE).from]; if (plan?.startMonth && isValidPeriod(plan.startMonth)) { candidates.push(periodBounds(plan.startMonth).from); } return new Date(Math.max(...candidates.map((d) => d.getTime()))); }}Note:
billingStartreturns the later of the period start, theSMS_OVERAGE_GO_LIVEfloor, and the companyβsplan.startMonth. A company whose SMS plan starts after the requested period therefore contributes no overage messages (the floor lands at/afterto, so the slice is empty). Whenplan.startMonthfalls inside the period, only on/after-start SMS are billable β but the groupedsmsSentcount still spans the whole month, so add a worklist test asserting a mid-period plan start excludes pre-start SMS from the charge (and, ifplan.startMonthis later than the period entirely, the company is skipped). Confirm the real plan exposesstartMonthas'YYYY-MM'; if the plan shape differs, derive the start month from the planβs effective date in one place.
Step 4: Run β verify it passes
Run: pnpm exec vitest run services/global/SmsOverageService.test.ts
Expected: PASS.
Step 5: Register the singleton in services/index.ts (follow the existing billableEventService export pattern):
import { SmsOverageService } from './global/SmsOverageService';export const smsOverageService = new SmsOverageService();Step 6: Typecheck + commit
Run: pnpm typecheck
git add services/global/SmsOverageService.ts services/global/SmsOverageService.test.ts services/index.tsgit commit -m "feat(sms-overage): SmsOverageService live worklist (expected payments)"Task 9: Bill-time cost reconciliation (S4, S7, L4)
Before computing the final charge at Bill, fill cost for overage messages with cost 0/null via getSmsPrice β capped, per-message isolated, never overwrite positive with 0.
Files:
- Modify:
services/global/SmsOverageService.ts(addreconcileCosts) - Test:
services/global/SmsOverageService.reconcile.test.ts
Step 1: Write the failing test
import { describe, it, expect, vi } from 'vitest';import { Prisma } from '@power-rent/prisma-client';import { SmsOverageService } from './SmsOverageService';
const D = (n: string) => new Prisma.Decimal(n);
describe('SmsOverageService.reconcileCosts', () => { it('fetches price only for cost 0/null, writes positive results, never overwrites positive', async () => { const update = vi.fn().mockResolvedValue({}); const getSmsPrice = vi.fn().mockResolvedValue(0.4); const { svc } = (() => { const s = new SmsOverageService({ prisma: { billable_events: { update } } as any, twilio: { getSmsPrice }, }); return { svc: s }; })();
const messages = [ { id: '1', messageId: 'a', cost: D('0.35') }, // real, untouched { id: '2', messageId: 'b', cost: null }, // reconciled to 0.40 ]; const out = await (svc as any).reconcileCosts(messages);
expect(getSmsPrice).toHaveBeenCalledTimes(1); expect(getSmsPrice).toHaveBeenCalledWith('b'); expect(update).toHaveBeenCalledWith({ where: { id: '2' }, data: { cost: 0.4 } }); expect(out[1].cost?.toString()).toBe('0.4'); });
it('leaves cost null and does not write when price still 0', async () => { const update = vi.fn(); const getSmsPrice = vi.fn().mockResolvedValue(0); const svc = new SmsOverageService({ prisma: { billable_events: { update } } as any, twilio: { getSmsPrice } }); const out = await (svc as any).reconcileCosts([{ id: '2', messageId: 'b', cost: null }]); expect(update).not.toHaveBeenCalled(); expect(out[0].cost).toBeNull(); });});Step 2: Run β verify it fails
Run: pnpm exec vitest run services/global/SmsOverageService.reconcile.test.ts
Expected: FAIL β reconcileCosts not a function.
Step 3: Implement (use @power-rent/try-catch/nextjs per message; cap calls; bounded concurrency). Add to the class:
import { Try } from '@power-rent/try-catch/nextjs';import { SMS_RECONCILE_MAX_CALLS } from '@power-rent/constants';
// inside SmsOverageService:private async reconcileCosts( messages: { id: string; messageId: string; cost: Prisma.Decimal | null }[],) { let calls = 0; for (const m of messages) { if (m.cost != null && m.cost.greaterThan(0)) continue; if (calls >= SMS_RECONCILE_MAX_CALLS) break; calls += 1;
const price = await new Try(() => this.twilio.getSmsPrice(m.messageId)) .catch((err) => { console.error('reconcileCosts getSmsPrice failed', m.messageId, err); }) .default(0) .value();
if (price && price > 0) { m.cost = new Prisma.Decimal(price); await new Try(() => this.prisma.billable_events.update({ where: { id: m.id }, data: { cost: price } })) .catch((err) => { console.error('reconcileCosts update failed', m.id, err); }) .value(); } } return messages;}Confirm the exact
@power-rent/try-catch/nextjsAPI against theerror-handlingskill (method names.catch/.default/.valueandTryvsnew Try). Adjust the calls to match; the contract required here is: run the async, log on error, fall back to 0/skip, never throw.
Step 4: Run β verify it passes
Run: pnpm exec vitest run services/global/SmsOverageService.reconcile.test.ts
Expected: PASS.
Step 5: Commit
git add services/global/SmsOverageService.ts services/global/SmsOverageService.reconcile.test.tsgit commit -m "feat(sms-overage): Bill-time Twilio cost reconciliation (capped, isolated)"Task 10: Bill / markPaid / waive / void (lifecycle, idempotency, closed-month) β M5, S8, S12
Files:
- Modify:
services/global/SmsOverageService.ts - Test:
services/global/SmsOverageService.bill.test.ts
Step 1: Write failing tests covering: closed-month-only; recompute+reconcile then insert; unique-violation β friendly idempotent rejection (not 500); ServiceError checked via error != null; voidβre-raise updates the voided row; markPaid/waive set actor fields.
// services/global/SmsOverageService.bill.test.ts (abridged β write all four)import { describe, it, expect, vi } from 'vitest';import { Prisma } from '@power-rent/prisma-client';import { SmsOverageService } from './SmsOverageService';
const D = (n: string) => new Prisma.Decimal(n);const NOW = new Date('2026-08-10T00:00:00Z');
describe('SmsOverageService.bill', () => { it('rejects billing an open (current) month', async () => { const svc = new SmsOverageService({}); await expect(svc.bill({ companyId: 'c1', period: '2026-08', actor: 'u1', now: NOW })) .rejects.toThrow(/closed/i); });
it('inserts an unpaid row with frozen numbers for a closed month', async () => { const create = vi.fn().mockResolvedValue({ data: { id: 'o1' }, error: null }); const repo = { findByCompanyPeriod: vi.fn().mockResolvedValue(null), model: { create } }; // stub overageMessages + reconcile via prisma/plans/twilio const svc = new SmsOverageService({ prisma: { billable_events: { groupBy: vi.fn(), findMany: vi.fn().mockResolvedValue([ { id: '1', messageId: 'a', cost: D('0.35'), date: new Date('2026-07-02T00:00:00Z') }, { id: '2', messageId: 'b', cost: D('0.35'), date: new Date('2026-07-03T00:00:00Z') }, ]), update: vi.fn(), } } as any, plans: { planByCompanyId: vi.fn().mockResolvedValue({ id: 'p1', startMonth: '2026-07', features: { sms: true }, limits: { sms: 1 } }) }, repo: repo as any, twilio: { getSmsPrice: vi.fn() }, });
const row = await svc.bill({ companyId: 'c1', period: '2026-07', actor: 'u1', now: NOW }); expect(create).toHaveBeenCalled(); const data = create.mock.calls[0][0].data; expect(data).toMatchObject({ companyId: 'c1', period: '2026-07', status: 'unpaid', raisedBy: 'u1', amountDue: '0.52', overageCount: 1 }); expect(row.id).toBe('o1'); });
it('returns a friendly idempotent rejection on unique violation', async () => { const create = vi.fn().mockResolvedValue({ data: null, error: { code: 'P2002' } }); const repo = { findByCompanyPeriod: vi.fn().mockResolvedValue(null), model: { create } }; const svc = makeBillableSvc(repo); // helper mirroring the test above await expect(svc.bill({ companyId: 'c1', period: '2026-07', actor: 'u1', now: NOW })) .rejects.toThrow(/already/i); });});Step 2: Run β verify it fails
Run: pnpm exec vitest run services/global/SmsOverageService.bill.test.ts
Expected: FAIL.
Step 3: Implement the four methods. Key rules:
bill: assertisClosedPeriod(period, now)else throw'period not closed'. Load plan βquotaFor(throw if not billable). Build overage messages (Task 8 helper) βreconcileCostsβcomputeCharge. If an existing row isvoided, update it back tounpaidwith fresh numbers; otherwisecreate(statusunpaid). Checkresult.error != null(itβs a truthyServiceError, memoryproject_service_error_truthy_guard); onP2002throw a friendly βalready billed by {raisedBy}β. Freeze:smsSent, quota, overageCount, realCostSum, fallbackCount, markup(SMS_MARKUP), fallbackFlat(FALLBACK_FLAT_USD), amountDue, currency:'USD', windowFrom, windowTo, messageBasis, raisedBy, raisedAt:now.markPaid({companyId, period, actor, paymentRef}): update row βstatus:'paid', paidBy:actor, paidAt:now, paymentRef.waive({companyId, period, actor, reason}): update βstatus:'waived', waivedBy:actor, waivedAt:now, waiveReason:reason.void({companyId, period, actor, reason}): update βstatus:'voided', voidedBy:actor, voidedAt:now, voidReason:reason.
Use Prisma error introspection for P2002. Wrap DB writes per the error-handling skill. All methods take now defaulting to new Date() (injected in tests).
Step 4: Run β verify it passes
Run: pnpm exec vitest run services/global/SmsOverageService.bill.test.ts
Expected: PASS (all four).
Step 5: Add the non-tautological frozen test (S19)
Add a test: bill a period; then mutate the underlying billable_events mock + change SMS_MARKUP (re-import not trivial β instead assert the persisted create payload captured the frozen markup/amountDue independent of later reads). Confirm markPaid does NOT recompute amountDue.
Step 6: Commit
git add services/global/SmsOverageService.ts services/global/SmsOverageService.bill.test.tsgit commit -m "feat(sms-overage): bill/markPaid/waive/void lifecycle (closed-month, idempotent)"Task 11: requireRootAdmin guard (M3)
Files:
- Create:
server/graphql/helpers/auth/requireRootAdmin.js - Test:
server/graphql/helpers/auth/requireRootAdmin.test.js
Step 1: Write the failing test β passes for isAdmin, throws userIsNotAdmin otherwise (mirror banCompanyβs dual fauna/postgres path).
import { describe, it, expect, vi } from 'vitest';import requireRootAdmin from './requireRootAdmin';
const fqlCtx = (rootUser) => ({ flags: {}, rootClientX: { query: vi.fn().mockResolvedValue({ data: rootUser }) }, services: {},});
describe('requireRootAdmin', () => { it('passes for an admin', async () => { await expect(requireRootAdmin(fqlCtx({ isAdmin: true, email: 'a@b.c' }))).resolves.toMatchObject({ isAdmin: true }); }); it('throws for a non-admin', async () => { await expect(requireRootAdmin(fqlCtx({ isAdmin: false, email: 'x@y.z' }))).rejects.toThrow(/access denied/i); }); it('throws when no identity', async () => { await expect(requireRootAdmin(fqlCtx(null))).rejects.toThrow(/access denied/i); });});Step 2: Run β verify it fails
Run: pnpm exec vitest run server/graphql/helpers/auth/requireRootAdmin.test.js
Expected: FAIL β module not found.
Step 3: Implement (extract the banCompany pattern):
import * as Sentry from '@sentry/nextjs';import { fql } from 'fauna';import { ApiError, codes } from '@power-rent/lib/errorCodes';
export default async function requireRootAdmin(context) { const { flags, rootClientX, services } = context;
let rootUser = null; if (flags?.faunaMigrationUserTenants) { rootUser = await services.usersService.getMeRoot(); } else { ({ data: rootUser } = await rootClientX.query(fql` Query.identity() { email, isAdmin } `)); }
if (!rootUser || !rootUser.isAdmin) { const error = new ApiError(codes.userIsNotAdmin, 'Access denied'); if (rootUser) Sentry.addBreadcrumb({ message: 'non-admin email', data: { email: rootUser.email } }); Sentry.captureException(error); throw error; } return rootUser;}Step 4: Run β verify it passes
Run: pnpm exec vitest run server/graphql/helpers/auth/requireRootAdmin.test.js
Expected: PASS.
Step 5: Commit
git add server/graphql/helpers/auth/requireRootAdmin.js server/graphql/helpers/auth/requireRootAdmin.test.jsgit commit -m "feat(sms-overage): requireRootAdmin guard (reuse Billion-admin isAdmin check)"Task 12: GraphQL surface (queries + mutations)
Files:
- Create:
server/graphql/schema/sms-overage.graphql - Create:
server/graphql/resolvers/query/smsOverageWorklist.resolver.js,.../smsOverages.resolver.js,.../smsOveragePeriodDetail.resolver.js - Create:
server/graphql/resolvers/mutation/SmsOverage/billSmsOverage.resolver.js,markSmsOveragePaid...,waiveSmsOverage...,voidSmsOverage... - Modify:
server/graphql/resolvers/index.js(register underQuery/Mutation)
Step 1: Define SDL (model on server/graphql/schema/root-company.graphql, which already hosts cross-company expenses):
type SmsOverageExpected { companyId: String! period: String! smsSent: Int! quota: Int! overageCount: Int! amountDue: String! currency: String!}
type SmsOverageRecord { id: ID! companyId: String! period: String! overageCount: Int! amountDue: String! currency: String! status: String! raisedBy: String! raisedAt: String!}
extend type Query { smsOverageWorklist(period: String!): [SmsOverageExpected!]! smsOverages(period: String, status: String): [SmsOverageRecord!]!}
extend type Mutation { billSmsOverage(companyId: String!, period: String!): SmsOverageRecord! markSmsOveragePaid(companyId: String!, period: String!, paymentRef: String): SmsOverageRecord! waiveSmsOverage(companyId: String!, period: String!, reason: String!): SmsOverageRecord! voidSmsOverage(companyId: String!, period: String!, reason: String!): SmsOverageRecord!}(Confirm extend type Query/Mutation matches how other schema files extend the root types; copy the existing idiom.)
Step 2: Run server codegen
Run: pnpm codegen
Expected: @power-rent/__graphql__/graphql now exports the new arg/return types.
Step 3: Write a resolver test (worklist resolver enforces root-admin then delegates):
import { describe, it, expect, vi } from 'vitest';vi.mock('@power-rent/server/graphql/helpers/auth/requireRootAdmin', () => ({ default: vi.fn() }));import requireRootAdmin from '@power-rent/server/graphql/helpers/auth/requireRootAdmin';import resolver from './smsOverageWorklist.resolver';
it('requires root admin then returns the worklist', async () => { const ctx = { services: { smsOverageService: { worklist: vi.fn().mockResolvedValue([{ companyId: 'c1' }]) } } }; const rows = await resolver(undefined, { period: '2026-07' }, ctx); expect(requireRootAdmin).toHaveBeenCalledWith(ctx); expect(rows).toEqual([{ companyId: 'c1' }]);});Step 4: Run β verify it fails, implement, verify it passes
Run: pnpm exec vitest run server/graphql/resolvers/query/smsOverageWorklist.resolver.test.js
Resolver shape (mutations derive actor from the verified root user, never from args):
import requireRootAdmin from '@power-rent/server/graphql/helpers/auth/requireRootAdmin';
export default async (_, { period }, context) => { await requireRootAdmin(context); return context.services.smsOverageService.worklist(period);};import requireRootAdmin from '@power-rent/server/graphql/helpers/auth/requireRootAdmin';
export default async (_, { companyId, period }, context) => { const admin = await requireRootAdmin(context); return context.services.smsOverageService.bill({ companyId, period, actor: admin.email });};(Implement the other three mutations + smsOverages/smsOveragePeriodDetail queries analogously. smsOverageService must be exposed on context.services β if context.services is only the tenant bundle, reference the singleton directly: import { smsOverageService } from '@power-rent/services' and call it. Check how expenses.resolver.js accesses global services and match it.)
Step 5: Register resolvers in server/graphql/resolvers/index.js β import each and add to the Query: {} (line ~1030) and Mutation: {} (line ~785) maps.
Step 6: Codegen client types + typecheck
Run: pnpm relay then pnpm typecheck
Expected: clean.
Step 7: Commit
git add server/graphql/schema/sms-overage.graphql server/graphql/schema/root-company.graphql server/graphql/resolversgit commit -m "feat(sms-overage): root-admin GraphQL queries + mutations"Task 13: Staff dashboard UI (Relay)
Use the
react-relayskill. Model the page on the existing Billion/root-admin pages. Keep one React component per file (memoryfeedback-one-file-one-component). SWR not involved here β this is Relay.
Files:
- Create: a page under the root-admin area (find the Billion admin page dir, e.g.
pages/billion/*or equivalent) βpages/<admin>/sms-overage.tsx - Create:
components/sms-overage/SmsOverageBillingPage.tsx(query:smsOverageWorklist) - Create:
components/sms-overage/SmsOverageRow.tsx(actions) - Create:
components/sms-overage/BillConfirmationModal.tsx
Step 1: Period selector + worklist query β useLazyLoadQuery/usePreloadedQuery for smsOverageWorklist(period), default to last closed month. Render a table: company, smsSent/quota, overageCount, amountDue (USD), status. Empty state when none.
Step 2: Bill flow β a confirmation modal (S5) showing the recomputed breakdown before committing; on confirm call billSmsOverage. Disable the Bill button while the mutation runs (covers reconciliation latency, S7).
Step 3: Row actions β Mark paid (optional paymentRef), Waive (required reason), Void (required reason). Each calls its mutation and refetches the worklist/history.
Step 4: History view β a second tab/table backed by smsOverages(period, status).
Step 5: PII β never render recipient in the worklist; the per-period drill-down (smsOveragePeriodDetail) masks recipients (S15).
Step 6: Verify in the app β use the run skill / verify skill to load the page as a root-admin user and confirm the worklist, Bill modal, and a waive round-trip. Capture a screenshot.
Step 7: Commit
git add pages componentsgit commit -m "feat(sms-overage): staff billing dashboard (Relay)"Final verification
Step 1: Full test suite
Run: pnpm test
Expected: all pass; specifically the money tests in Tasks 6, 8, 10.
Step 2: Typecheck + lint
Run: pnpm typecheck && pnpm exec eslint services repositories server constants
Expected: clean (no new errors).
Step 3: Verification-before-completion
Use superpowers:verification-before-completion. Confirm against the specβs Testing section that each intent test exists: quota edges, selection ordering, math ($2.06 / $0.52), timezone (June not July), canonical date column, concurrency double-bill rejection, reconciliation scope, frozen non-tautological, authZ 403, isolation, validation, lifecycle exclusion, production shapes.
Step 4: Open the PR (only when the user asks).
Coverage map (spec β task)
- M1 quotaFor β Task 5. M6 timezone β Task 4. M7 column β Task 2. M9 math β Task 6.
- M3/M4 authZ + sudo read β Tasks 11, 12 (+ Task 8 root client). M5 idempotent bill β Task 10.
- M8 (no Fauna) β Task 8 reads Postgres only (no faunaStopped gate, per latest decision).
- S2/S3 messageBasis β Task 6 + persisted in Task 10. S4 no-overwrite reconcile β Task 9.
- S5 confirm modal / S7 latency β Task 13 / Task 9 cap. S8 waivedBy / S12 statuses β Tasks 1, 10.
- S10 Decimal/floor β Task 6. S13/S16 metrics (fallbackCount surfaced) β Tasks 1, 6, 13. S14 validation β Tasks 4, 12.
- S15 PII β Tasks 12, 13. S21 index order β Task 1. L1 tiebreak β Tasks 6, 8.
Out of scope (do NOT build)
Email overage; in-app invoice/fattura/SDI; Stripe push; FX conversion (USD only); customer notifications (sales handle offline); crons; historical billing before billing start. A PDF statement is a possible fast-follow, not v1.