# Metered API + Tiered Pricing + Admin UI + Zapier Distribution — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the Zappier HTTP API driven by an OpenAPI spec, with per-call usage metering, runtime-editable pricing (rate card × tier multiplier + per-customer overrides + monthly credits) managed through an admin web UI, SQLite persistence, Stripe metered billing, and a published Zapier app. **Architecture:** `openapi.yaml` is the API source of truth — `express-openapi-validator` validates requests against it and `swagger-ui-express` serves docs at `/docs`; each spec `operationId` is a rate-card key. A pure pricing module quotes every call: endpoints carry **list prices** on a rate card, customer types are **tier configs** (multiplier + monthly credit + optional default rule), customers may carry a **multiplier override**; billed price = `round(list × multiplier)`. Pricing lives in a `PricingStore` (SQLite in the server, in-memory in tests) and is editable at runtime through an admin API + vanilla-JS admin UI at `/admin` — changes take effect on the next request. Every call is recorded in a usage repository; a monthly job subtracts the tier credit and reports the billable remainder to a Stripe Billing Meter. A separate `zapier-app/` package (Zapier Platform CLI, plain JS) exposes API-key auth, a polling trigger, and a create action against the same API. **Tech Stack:** Node 20, TypeScript (strict), Express 4, express-openapi-validator, swagger-ui-express, better-sqlite3, Jest + ts-jest + supertest, Stripe Node SDK (Billing Meters), Zapier Platform CLI. ## Global Constraints - Project root: `/Users/marchon/zappier`. All paths below are relative to it; the server must be started from the project root (it loads `openapi.yaml` and `admin/` via `process.cwd()`). - `openapi.yaml` is the API source of truth. Rate-card endpoint ids must match spec `operationId`s exactly. - All money values are integer cents. List prices are computed first (per-KB / per-MB sizes rounded up), then the multiplier is applied once with `Math.round`. - Free rules price at 0 cents on **every** tier, regardless of multiplier or overrides. - Pricing is runtime-editable through the admin API/UI; changes take effect on the next request, no restart. - Admin endpoints require the `x-admin-key` header (env `ADMIN_KEY`, dev default `admin-dev-key`). Never commit a real key. - Customer-facing endpoints require the `x-api-key` header. - Tests never touch real Stripe or Zapier accounts — unit tests use fakes; account steps are manual. - Jest tests use in-memory repositories/stores. The server (`src/index.ts`) and the Stripe job use SQLite (`zappier.db`, override with the `ZAPPIER_DB` env var). --- ### Task 1: Project scaffold + pricing engine + pricing stores **Files:** - Create: `package.json` - Create: `tsconfig.json` - Create: `jest.config.js` - Create: `src/pricing.ts` - Test: `tests/pricing.test.ts` - Modify: `README.md` (replace pricing section) **Interfaces:** - Consumes: nothing. - Produces: - `export type PriceRule = { kind: 'free' } | { kind: 'fixed'; fixedCents: number } | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number }` - `export interface TierConfig { id: string; name: string; multiplier: number; monthlyCreditCents: number; defaultRule?: PriceRule }` - `export interface RateCard { endpoints: Record }` - `export interface TierCatalog { find(id: string): TierConfig | undefined; list(): TierConfig[] }` and `export class ConfigTierCatalog implements TierCatalog` (constructor takes `TierConfig[]`) - `export interface CallUsage { metadataBytes: number; attachmentBytes: number }` - `export interface Quote { endpointId: string; listCents: number; totalCents: number; breakdown: { baseCents: number; metadataCents: number; attachmentCents: number } }` - `export interface PricingContext { rateCard: RateCard; tiers: TierCatalog }` - `export interface PricingStore { getRateCard(): RateCard; getTiers(): TierConfig[]; upsertEndpoint(endpointId: string, rule: PriceRule): void; deleteEndpoint(endpointId: string): void; upsertTier(tier: TierConfig): void; deleteTier(tierId: string): void }` - `export class InMemoryPricingStore implements PricingStore` — constructor `(rateCard?: RateCard, tiers?: TierConfig[])`, defaults to the seeds below. - `export const DEFAULT_RATE_CARD`, `DEFAULT_TIERS`, `DEFAULT_PRICING` (seed data; also used to seed SQLite in Task 7) - `export function quoteCall(pricing: PricingContext, tierId: string, endpointId: string, usage: CallUsage, multiplierOverride?: number): Quote` — throws `Error("Unknown tier: ")` or `Error("No price rule for /")`. `multiplierOverride` (per-customer) beats the tier multiplier. - [ ] **Step 1: Scaffold the project** Create `package.json`: ```json { "name": "zappier", "version": "0.1.0", "private": true, "scripts": { "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "dev": "ts-node src/index.ts", "test": "jest" }, "dependencies": { "express": "^4.19.2", "express-openapi-validator": "^5.3.0", "stripe": "^16.0.0", "swagger-ui-express": "^5.0.0", "yamljs": "^0.3.0" }, "devDependencies": { "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/multer": "^1.4.11", "@types/node": "^20.14.0", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", "@types/yamljs": "^0.2.34", "jest": "^29.7.0", "supertest": "^7.0.0", "ts-jest": "^29.1.4", "ts-node": "^10.9.2", "typescript": "^5.5.0" } } ``` Create `tsconfig.json`: ```json { "compilerOptions": { "target": "ES2022", "module": "commonjs", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node", "jest", "multer"] }, "include": ["src"] } ``` Create `jest.config.js`: ```js module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['/tests'], }; ``` Run: `cd /Users/marchon/zappier && npm install` Expected: installs cleanly, `node_modules/` exists. - [ ] **Step 2: Write the failing pricing tests** Create `tests/pricing.test.ts`: ```ts import { ConfigTierCatalog, DEFAULT_PRICING, DEFAULT_TIERS, InMemoryPricingStore, quoteCall, } from '../src/pricing'; const noUsage = { metadataBytes: 0, attachmentBytes: 0 }; describe('quoteCall', () => { it('prices free endpoints at 0 on every tier, ignoring the multiplier', () => { for (const tier of DEFAULT_TIERS) { expect(quoteCall(DEFAULT_PRICING, tier.id, 'status', noUsage).totalCents).toBe(0); expect(quoteCall(DEFAULT_PRICING, tier.id, 'storage-list', noUsage).totalCents).toBe(0); } }); it('applies the tier multiplier to fixed list prices', () => { // transform list price: 4 cents expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage).totalCents).toBe(4); expect(quoteCall(DEFAULT_PRICING, 'pro', 'transform', noUsage).totalCents).toBe(2); expect(quoteCall(DEFAULT_PRICING, 'business', 'transform', noUsage).totalCents).toBe(1); }); it('prices storage as base + per-KB metadata + per-MB attachments at list rates', () => { const usage = { metadataBytes: 2048, attachmentBytes: 2 * 1024 * 1024 }; // list: 10 + 2 * 1 + 2 * 50 = 112 expect(quoteCall(DEFAULT_PRICING, 'free', 'storage', usage).totalCents).toBe(112); expect(quoteCall(DEFAULT_PRICING, 'pro', 'storage', usage).totalCents).toBe(56); expect(quoteCall(DEFAULT_PRICING, 'business', 'storage', usage).totalCents).toBe(28); }); it('rounds partial KB and MB up before applying the multiplier', () => { const q = quoteCall(DEFAULT_PRICING, 'free', 'storage', { metadataBytes: 1, attachmentBytes: 1, }); // list: 10 + 1 KB * 1 + 1 MB * 50 = 61 expect(q.listCents).toBe(61); expect(q.totalCents).toBe(61); }); it('exposes the list-price breakdown and the multiplied total', () => { const q = quoteCall(DEFAULT_PRICING, 'pro', 'storage', { metadataBytes: 1024, attachmentBytes: 0, }); expect(q.breakdown).toEqual({ baseCents: 10, metadataCents: 1, attachmentCents: 0 }); expect(q.listCents).toBe(11); expect(q.totalCents).toBe(6); // Math.round(11 * 0.5) }); it('lets a per-customer multiplier override beat the tier multiplier', () => { expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage, 0.5).totalCents).toBe(2); expect(quoteCall(DEFAULT_PRICING, 'free', 'status', noUsage, 0.5).totalCents).toBe(0); }); it('falls back to the tier default rule for endpoints not on the rate card', () => { // pro default rule: fixed 8 list -> round(8 * 0.5) = 4 expect(quoteCall(DEFAULT_PRICING, 'pro', 'experimental', noUsage).totalCents).toBe(4); }); it('throws for an endpoint with no rate-card entry and no tier default', () => { expect(() => quoteCall(DEFAULT_PRICING, 'free', 'experimental', noUsage)).toThrow( 'No price rule', ); }); it('throws for an unknown tier', () => { expect(() => quoteCall(DEFAULT_PRICING, 'platinum', 'status', noUsage)).toThrow( 'Unknown tier', ); }); }); describe('ConfigTierCatalog', () => { it('finds tiers by id and lists them', () => { const catalog = new ConfigTierCatalog(DEFAULT_TIERS); expect(catalog.find('pro')?.multiplier).toBe(0.5); expect(catalog.find('nope')).toBeUndefined(); expect(catalog.list().map((t) => t.id)).toEqual(['free', 'pro', 'business']); }); it('supports adding a customer type as pure config', () => { const catalog = new ConfigTierCatalog([ ...DEFAULT_TIERS, { id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 }, ]); expect(catalog.find('edu')?.name).toBe('Education'); }); }); describe('InMemoryPricingStore', () => { it('seeds from the default rate card and tiers', () => { const store = new InMemoryPricingStore(); expect(store.getRateCard().endpoints.status).toEqual({ kind: 'free' }); expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']); }); it('upserts and deletes endpoints', () => { const store = new InMemoryPricingStore(); store.upsertEndpoint('experimental', { kind: 'fixed', fixedCents: 9 }); expect(store.getRateCard().endpoints.experimental).toEqual({ kind: 'fixed', fixedCents: 9, }); store.deleteEndpoint('experimental'); expect(store.getRateCard().endpoints.experimental).toBeUndefined(); }); it('upserts and deletes tiers', () => { const store = new InMemoryPricingStore(); store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 }); expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.4); store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 }); expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1); store.deleteTier('edu'); expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined(); }); }); ``` - [ ] **Step 3: Run tests to verify they fail** Run: `npm test` Expected: FAIL — `Cannot find module '../src/pricing'`. - [ ] **Step 4: Implement the pricing engine and stores** Create `src/pricing.ts`: ```ts export type PriceRule = | { kind: 'free' } | { kind: 'fixed'; fixedCents: number } | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number }; export interface TierConfig { id: string; name: string; multiplier: number; monthlyCreditCents: number; defaultRule?: PriceRule; } export interface RateCard { endpoints: Record; } export interface TierCatalog { find(id: string): TierConfig | undefined; list(): TierConfig[]; } export class ConfigTierCatalog implements TierCatalog { constructor(private tiers: TierConfig[]) {} find(id: string): TierConfig | undefined { return this.tiers.find((t) => t.id === id); } list(): TierConfig[] { return [...this.tiers]; } } export interface CallUsage { metadataBytes: number; attachmentBytes: number; } export interface Quote { endpointId: string; listCents: number; totalCents: number; breakdown: { baseCents: number; metadataCents: number; attachmentCents: number }; } export interface PricingContext { rateCard: RateCard; tiers: TierCatalog; } export interface PricingStore { getRateCard(): RateCard; getTiers(): TierConfig[]; upsertEndpoint(endpointId: string, rule: PriceRule): void; deleteEndpoint(endpointId: string): void; upsertTier(tier: TierConfig): void; deleteTier(tierId: string): void; } export const DEFAULT_RATE_CARD: RateCard = { endpoints: { status: { kind: 'free' }, 'storage-list': { kind: 'free' }, transform: { kind: 'fixed', fixedCents: 4 }, storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 }, }, }; export const DEFAULT_TIERS: TierConfig[] = [ { id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 }, { id: 'pro', name: 'Pro', multiplier: 0.5, monthlyCreditCents: 1000, defaultRule: { kind: 'fixed', fixedCents: 8 }, }, { id: 'business', name: 'Business', multiplier: 0.25, monthlyCreditCents: 10000, defaultRule: { kind: 'fixed', fixedCents: 8 }, }, ]; export const DEFAULT_PRICING: PricingContext = { rateCard: DEFAULT_RATE_CARD, tiers: new ConfigTierCatalog(DEFAULT_TIERS), }; export class InMemoryPricingStore implements PricingStore { private endpoints: Record; private tiers: TierConfig[]; constructor(rateCard: RateCard = DEFAULT_RATE_CARD, tiers: TierConfig[] = DEFAULT_TIERS) { this.endpoints = { ...rateCard.endpoints }; this.tiers = [...tiers]; } getRateCard(): RateCard { return { endpoints: { ...this.endpoints } }; } getTiers(): TierConfig[] { return [...this.tiers]; } upsertEndpoint(endpointId: string, rule: PriceRule): void { this.endpoints[endpointId] = rule; } deleteEndpoint(endpointId: string): void { delete this.endpoints[endpointId]; } upsertTier(tier: TierConfig): void { const i = this.tiers.findIndex((t) => t.id === tier.id); if (i >= 0) this.tiers[i] = tier; else this.tiers.push(tier); } deleteTier(tierId: string): void { this.tiers = this.tiers.filter((t) => t.id !== tierId); } } export function quoteCall( pricing: PricingContext, tierId: string, endpointId: string, usage: CallUsage, multiplierOverride?: number, ): Quote { const tier = pricing.tiers.find(tierId); if (!tier) throw new Error(`Unknown tier: ${tierId}`); const rule = pricing.rateCard.endpoints[endpointId] ?? tier.defaultRule; if (!rule) throw new Error(`No price rule for ${tierId}/${endpointId}`); if (rule.kind === 'free') { return { endpointId, listCents: 0, totalCents: 0, breakdown: { baseCents: 0, metadataCents: 0, attachmentCents: 0 }, }; } let breakdown: Quote['breakdown']; if (rule.kind === 'fixed') { breakdown = { baseCents: rule.fixedCents, metadataCents: 0, attachmentCents: 0 }; } else { breakdown = { baseCents: rule.baseCents, metadataCents: Math.ceil(usage.metadataBytes / 1024) * rule.perKbCents, attachmentCents: Math.ceil(usage.attachmentBytes / (1024 * 1024)) * rule.perMbCents, }; } const listCents = breakdown.baseCents + breakdown.metadataCents + breakdown.attachmentCents; const multiplier = multiplierOverride ?? tier.multiplier; return { endpointId, listCents, totalCents: Math.round(listCents * multiplier), breakdown, }; } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npm test` Expected: PASS — 14 tests in `tests/pricing.test.ts`. - [ ] **Step 6: Document the pricing model in the README** Replace the body of `README.md` below the title with: ```markdown ## Pricing model `openapi.yaml` defines the API surface; each `operationId` is a rate-card key. Endpoints carry **list prices** (seed: `src/pricing.ts` → `DEFAULT_RATE_CARD`). Customer types are **tier configs** (`DEFAULT_TIERS`) with a `multiplier`, a `monthlyCreditCents` quota, and an optional `defaultRule` for endpoints not on the card. Individual customers can carry a `multiplierOverride`. Billed price = `round(list price × multiplier)`; usage up to the monthly credit is free. Pricing is editable at runtime via the admin UI at `/admin` (see Task 8). ### Seed rate card (list prices, cents per call) | Endpoint | Model | List price | | -------------- | -------- | -------------------------------------------- | | `status` | free | 0 | | `storage-list` | free | 0 | | `transform` | fixed | 4 | | `storage` | variable | 10 + 1 per KB metadata + 50 per MB attached | ### Seed customer types | Tier | Multiplier | Monthly credit | Default rule (unlisted endpoints) | | ---------- | ---------- | -------------- | --------------------------------- | | `free` | 1.0 | 100 cents | none — call rejected with 403 | | `pro` | 0.5 | 1000 cents | fixed 8 list → 4 billed | | `business` | 0.25 | 10000 cents | fixed 8 list → 2 billed | Adding a new API call = add it to `openapi.yaml`, then price it in the admin UI. Adding a customer type = create it in the admin UI. Variable pricing = base per call + metadata size (rounded up to KB) + attachment size (rounded up to MB), then the multiplier. ``` - [ ] **Step 7: Commit** ```bash cd /Users/marchon/zappier && git init -q 2>/dev/null; git add -A git commit -m "feat: scaffold project with rate-card pricing engine and pricing stores" ``` --- ### Task 2: Usage log **Files:** - Create: `src/usage.ts` - Test: `tests/usage.test.ts` **Interfaces:** - Consumes: nothing from Task 1 (independent module). - Produces: - `export interface UsageEntry { customerId: string; endpointId: string; cents: number; metadataBytes: number; attachmentBytes: number; timestamp: Date }` - `export interface UsageSummary { customerId: string; totalCents: number; calls: number; byEndpoint: Record }` - `export function summarize(customerId: string, list: UsageEntry[]): UsageSummary` — shared aggregation helper (used again by the SQLite repo in Task 7). - `export interface UsageRepo { record(entry: UsageEntry): void; listFor(customerId: string, since?: Date): UsageEntry[]; summaryFor(customerId: string, since?: Date): UsageSummary }` - `export class InMemoryUsageRepo implements UsageRepo` - [ ] **Step 1: Write the failing tests** Create `tests/usage.test.ts`: ```ts import { InMemoryUsageRepo, UsageEntry } from '../src/usage'; const entry = (over: Partial = {}): UsageEntry => ({ customerId: 'cust_1', endpointId: 'transform', cents: 4, metadataBytes: 0, attachmentBytes: 0, timestamp: new Date('2026-07-27T10:00:00Z'), ...over, }); describe('InMemoryUsageRepo', () => { it('records and lists entries per customer', () => { const repo = new InMemoryUsageRepo(); repo.record(entry()); repo.record(entry({ customerId: 'cust_2' })); expect(repo.listFor('cust_1')).toHaveLength(1); expect(repo.listFor('cust_2')).toHaveLength(1); }); it('filters entries by since date', () => { const repo = new InMemoryUsageRepo(); repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') })); repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') })); expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1); }); it('summarizes totals by endpoint', () => { const repo = new InMemoryUsageRepo(); repo.record(entry()); repo.record(entry({ endpointId: 'storage', cents: 112 })); repo.record(entry()); const s = repo.summaryFor('cust_1'); expect(s.customerId).toBe('cust_1'); expect(s.calls).toBe(3); expect(s.totalCents).toBe(120); expect(s.byEndpoint.transform).toEqual({ calls: 2, cents: 8 }); expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 }); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- tests/usage.test.ts` Expected: FAIL — `Cannot find module '../src/usage'`. - [ ] **Step 3: Implement the usage repo** Create `src/usage.ts`: ```ts export interface UsageEntry { customerId: string; endpointId: string; cents: number; metadataBytes: number; attachmentBytes: number; timestamp: Date; } export interface UsageSummary { customerId: string; totalCents: number; calls: number; byEndpoint: Record; } export function summarize(customerId: string, list: UsageEntry[]): UsageSummary { const byEndpoint: UsageSummary['byEndpoint'] = {}; for (const e of list) { const bucket = (byEndpoint[e.endpointId] ??= { calls: 0, cents: 0 }); bucket.calls += 1; bucket.cents += e.cents; } return { customerId, calls: list.length, totalCents: list.reduce((sum, e) => sum + e.cents, 0), byEndpoint, }; } export interface UsageRepo { record(entry: UsageEntry): void; listFor(customerId: string, since?: Date): UsageEntry[]; summaryFor(customerId: string, since?: Date): UsageSummary; } export class InMemoryUsageRepo implements UsageRepo { private entries: UsageEntry[] = []; record(entry: UsageEntry): void { this.entries.push(entry); } listFor(customerId: string, since?: Date): UsageEntry[] { return this.entries.filter( (e) => e.customerId === customerId && (!since || e.timestamp >= since), ); } summaryFor(customerId: string, since?: Date): UsageSummary { return summarize(customerId, this.listFor(customerId, since)); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test -- tests/usage.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Commit** ```bash git add src/usage.ts tests/usage.test.ts git commit -m "feat: add usage log with shared summarizer and per-customer summaries" ``` --- ### Task 3: API-key auth + customer model **Files:** - Create: `src/auth.ts` - Test: `tests/auth.test.ts` **Interfaces:** - Consumes: nothing (a customer's tier is a plain `tierId` string resolved by `PricingContext.tiers` at quote time — auth never imports pricing). - Produces: - `export interface Customer { id: string; name: string; tierId: string; apiKey: string; stripeCustomerId?: string; multiplierOverride?: number }` - `export interface CustomerRepo { findByApiKey(apiKey: string): Customer | undefined; list(): Customer[]; save(customer: Customer): void }` — `save` upserts by `id` (used by the admin API in Task 8). - `export class InMemoryCustomerRepo implements CustomerRepo` — constructor takes `Customer[]`. - `export function apiKeyAuth(repo: CustomerRepo): RequestHandler` — reads `x-api-key` header, sets `req.customer`, else 401 JSON `{ error: 'invalid or missing API key' }`. - Global augmentation: `Express.Request.customer?: Customer`. - [ ] **Step 1: Write the failing tests** Create `tests/auth.test.ts`: ```ts import express from 'express'; import request from 'supertest'; import { apiKeyAuth, InMemoryCustomerRepo } from '../src/auth'; const repo = new InMemoryCustomerRepo([ { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' }, { id: 'cust_2', name: 'Grace', tierId: 'business', apiKey: 'key-grace', stripeCustomerId: 'cus_123' }, ]); const app = express(); app.use(apiKeyAuth(repo)); app.get('/ping', (req, res) => res.json({ customerId: req.customer!.id, tierId: req.customer!.tierId }), ); describe('apiKeyAuth', () => { it('rejects a missing key with 401', async () => { const res = await request(app).get('/ping'); expect(res.status).toBe(401); expect(res.body.error).toMatch(/API key/); }); it('rejects an unknown key with 401', async () => { const res = await request(app).get('/ping').set('x-api-key', 'wrong'); expect(res.status).toBe(401); }); it('attaches the customer for a valid key', async () => { const res = await request(app).get('/ping').set('x-api-key', 'key-ada'); expect(res.status).toBe(200); expect(res.body).toEqual({ customerId: 'cust_1', tierId: 'pro' }); }); }); describe('InMemoryCustomerRepo', () => { it('lists all customers', () => { expect(repo.list().map((c) => c.id)).toEqual(['cust_1', 'cust_2']); }); it('save() upserts by id', () => { const local = new InMemoryCustomerRepo([ { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' }, ]); local.save({ id: 'cust_1', name: 'Ada', tierId: 'business', apiKey: 'key-ada', multiplierOverride: 0.4 }); local.save({ id: 'cust_9', name: 'New', tierId: 'free', apiKey: 'key-new' }); expect(local.list()).toHaveLength(2); const updated = local.findByApiKey('key-ada'); expect(updated?.tierId).toBe('business'); expect(updated?.multiplierOverride).toBe(0.4); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- tests/auth.test.ts` Expected: FAIL — `Cannot find module '../src/auth'`. - [ ] **Step 3: Implement auth** Create `src/auth.ts`: ```ts import { RequestHandler } from 'express'; export interface Customer { id: string; name: string; tierId: string; apiKey: string; stripeCustomerId?: string; multiplierOverride?: number; } export interface CustomerRepo { findByApiKey(apiKey: string): Customer | undefined; list(): Customer[]; save(customer: Customer): void; } export class InMemoryCustomerRepo implements CustomerRepo { constructor(private customers: Customer[] = []) {} findByApiKey(apiKey: string): Customer | undefined { return this.customers.find((c) => c.apiKey === apiKey); } list(): Customer[] { return [...this.customers]; } save(customer: Customer): void { const i = this.customers.findIndex((c) => c.id === customer.id); if (i >= 0) this.customers[i] = customer; else this.customers.push(customer); } } declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { customer?: Customer; } } } export function apiKeyAuth(repo: CustomerRepo): RequestHandler { return (req, res, next) => { const key = req.header('x-api-key'); const customer = key ? repo.findByApiKey(key) : undefined; if (!customer) { res.status(401).json({ error: 'invalid or missing API key' }); return; } req.customer = customer; next(); }; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test -- tests/auth.test.ts` Expected: PASS — 5 tests. - [ ] **Step 5: Commit** ```bash git add src/auth.ts tests/auth.test.ts git commit -m "feat: add API-key auth and upsertable customer model with tier overrides" ``` --- ### Task 4: Metering middleware **Files:** - Create: `src/meter.ts` - Test: `tests/meter.test.ts` **Interfaces:** - Consumes: `quoteCall`, `PricingContext`, `Quote`, `DEFAULT_PRICING` from `src/pricing.ts`; `UsageRepo` from `src/usage.ts`; `req.customer` (with `tierId` and `multiplierOverride`) from `src/auth.ts`. - Produces: `export function meter(endpointId: string, repo: UsageRepo, pricing: PricingContext): RequestHandler` — computes `metadataBytes` from `req.body.metadata` (byte length of its JSON form, `{}` when absent) and `attachmentBytes` from `req.files` (Multer-shaped array provided by express-openapi-validator, 0 when absent); quotes via `quoteCall(pricing, req.customer.tierId, endpointId, usage, req.customer.multiplierOverride)`; on a pricing error responds **403** with the error message and records nothing; otherwise records a `UsageEntry` with `cents = quote.totalCents` and sets `res.locals.quote`. Must run **after** `apiKeyAuth` and after the OpenAPI validator (which parses multipart bodies). - [ ] **Step 1: Write the failing tests** Create `tests/meter.test.ts`: ```ts import express from 'express'; import request from 'supertest'; import { apiKeyAuth, Customer, InMemoryCustomerRepo } from '../src/auth'; import { meter } from '../src/meter'; import { DEFAULT_PRICING } from '../src/pricing'; import { InMemoryUsageRepo } from '../src/usage'; const FREE_ADA: Customer = { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' }; function buildApp(seed: Customer[] = [FREE_ADA]) { const customers = new InMemoryCustomerRepo(seed); const usage = new InMemoryUsageRepo(); const app = express(); app.use(express.json()); app.use(apiKeyAuth(customers)); app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) => res.json({ quote: res.locals.quote }), ); app.post('/storage', meter('storage', usage, DEFAULT_PRICING), (req, res) => res.json({ quote: res.locals.quote }), ); app.post('/experimental', meter('experimental', usage, DEFAULT_PRICING), (req, res) => res.json({ quote: res.locals.quote }), ); return { app, usage }; } describe('meter middleware', () => { it('quotes a fixed endpoint at the multiplied tier price and records usage', async () => { const { app, usage } = buildApp(); const res = await request(app) .post('/transform') .set('x-api-key', 'key-ada') .send({ text: 'hi' }); expect(res.status).toBe(200); expect(res.body.quote.totalCents).toBe(4); // free tier, multiplier 1 const entries = usage.listFor('cust_1'); expect(entries).toHaveLength(1); expect(entries[0].endpointId).toBe('transform'); expect(entries[0].cents).toBe(4); }); it('charges per KB of metadata on variable endpoints', async () => { const { app } = buildApp(); const res = await request(app) .post('/storage') .set('x-api-key', 'key-ada') .send({ metadata: { note: 'x'.repeat(2048) } }); // JSON of metadata is 2059 bytes -> 3 KB -> list 10 + 3 * 1 = 13 expect(res.body.quote.totalCents).toBe(13); }); it('applies a per-customer multiplier override', async () => { const { app } = buildApp([ { id: 'cust_vip', name: 'Vip', tierId: 'free', apiKey: 'key-vip', multiplierOverride: 0.5 }, ]); const res = await request(app) .post('/transform') .set('x-api-key', 'key-vip') .send({ text: 'hi' }); expect(res.body.quote.totalCents).toBe(2); // list 4 x override 0.5 }); it('returns 401 when no customer is attached', async () => { const usage = new InMemoryUsageRepo(); const app = express(); app.use(express.json()); app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) => res.json({}), ); const res = await request(app).post('/transform').send({ text: 'hi' }); expect(res.status).toBe(401); expect(usage.listFor('cust_1')).toHaveLength(0); }); it('returns 403 for an endpoint with no price rule on the caller tier', async () => { const { app, usage } = buildApp(); const res = await request(app) .post('/experimental') .set('x-api-key', 'key-ada') .send({}); expect(res.status).toBe(403); expect(res.body.error).toMatch(/No price rule/); expect(usage.listFor('cust_1')).toHaveLength(0); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- tests/meter.test.ts` Expected: FAIL — `Cannot find module '../src/meter'`. - [ ] **Step 3: Implement the middleware** Create `src/meter.ts`: ```ts import { RequestHandler } from 'express'; import { PricingContext, Quote, quoteCall } from './pricing'; import { UsageRepo } from './usage'; export function meter( endpointId: string, repo: UsageRepo, pricing: PricingContext, ): RequestHandler { return (req, res, next) => { const customer = req.customer; if (!customer) { res.status(401).json({ error: 'unauthenticated' }); return; } const metadataBytes = Buffer.byteLength( JSON.stringify(req.body?.metadata ?? {}), 'utf8', ); const files = (req.files as Express.Multer.File[] | undefined) ?? []; const attachmentBytes = files.reduce((sum, f) => sum + f.size, 0); let quote: Quote; try { quote = quoteCall( pricing, customer.tierId, endpointId, { metadataBytes, attachmentBytes }, customer.multiplierOverride, ); } catch (err) { res.status(403).json({ error: (err as Error).message }); return; } repo.record({ customerId: customer.id, endpointId, cents: quote.totalCents, metadataBytes, attachmentBytes, timestamp: new Date(), }); res.locals.quote = quote; next(); }; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test -- tests/meter.test.ts` Expected: PASS — 5 tests. - [ ] **Step 5: Commit** ```bash git add src/meter.ts tests/meter.test.ts git commit -m "feat: add metering middleware with tier overrides and 403 for unpriced calls" ``` --- ### Task 5: OpenAPI-driven HTTP API + docs **Files:** - Create: `openapi.yaml` - Create: `src/app.ts` - Create: `src/index.ts` - Test: `tests/app.test.ts` **Interfaces:** - Consumes: `apiKeyAuth`, `InMemoryCustomerRepo`, `CustomerRepo`, `Customer` (Task 3); `meter` (Task 4); `UsageRepo`, `InMemoryUsageRepo` (Task 2); `PricingContext`, `PricingStore`, `InMemoryPricingStore`, `ConfigTierCatalog` (Task 1). - Produces: - `openapi.yaml` — the API source of truth; `operationId`s: `status`, `transform`, `storage`, `storage-list`, `usage` (first four are rate-card keys). - `export interface StoredItem { id: string; customerId: string; metadata: Record; attachments: { filename: string; size: number }[]; createdAt: string }` - `export const DEFAULT_CUSTOMERS: Customer[]` — `key-ada` (free), `key-grace` (pro), `key-linus` (business). - `export interface AppDeps { usage?: UsageRepo; customers?: CustomerRepo; pricingStore?: PricingStore }` - `export function buildApp(deps?: AppDeps): { app: Express; usage: UsageRepo; customers: CustomerRepo; pricing: PricingContext; pricingStore: PricingStore; items: StoredItem[] }` — `pricing` is a **live** context whose getters read the store on every call, so admin edits (Task 8) take effect immediately. - `GET /docs` — Swagger UI rendering `openapi.yaml`. - [ ] **Step 1: Write the OpenAPI spec** Create `openapi.yaml`: ```yaml openapi: 3.0.3 info: title: Zappier API version: 0.1.0 description: Metered data API. Every priced call returns its quote. security: - apiKey: [] paths: /v1/status: get: operationId: status summary: Service status (free) responses: '200': description: OK /v1/transform: post: operationId: transform summary: Uppercase a string (fixed price) requestBody: required: true content: application/json: schema: type: object required: [text] properties: text: type: string responses: '200': description: OK /v1/storage: post: operationId: storage summary: Store metadata and file attachments (variable price by size) requestBody: required: true content: multipart/form-data: schema: type: object properties: metadata: type: string description: JSON string of metadata attachments: type: array items: type: string format: binary responses: '200': description: OK get: operationId: storage-list summary: List your stored items (free) responses: '200': description: OK /v1/usage: get: operationId: usage summary: Your usage summary for the current period responses: '200': description: OK components: securitySchemes: apiKey: type: apiKey in: header name: x-api-key ``` - [ ] **Step 2: Write the failing tests** Create `tests/app.test.ts`: ```ts import request from 'supertest'; import { buildApp } from '../src/app'; const KEY = 'key-ada'; // seeded free-tier customer describe('Zappier API', () => { it('rejects calls without an API key', async () => { const { app } = buildApp(); const res = await request(app).get('/v1/status'); expect(res.status).toBe(401); }); it('GET /v1/status is free', async () => { const { app } = buildApp(); const res = await request(app).get('/v1/status').set('x-api-key', KEY); expect(res.status).toBe(200); expect(res.body.status).toBe('ok'); expect(res.body.quote.totalCents).toBe(0); }); it('POST /v1/transform uppercases text at the multiplied fixed price', async () => { const { app } = buildApp(); const res = await request(app) .post('/v1/transform') .set('x-api-key', KEY) .send({ text: 'hello' }); expect(res.status).toBe(200); expect(res.body.output).toBe('HELLO'); expect(res.body.quote.totalCents).toBe(4); // list 4 x free-tier multiplier 1 }); it('rejects a request that violates the OpenAPI schema with 400', async () => { const { app } = buildApp(); const res = await request(app) .post('/v1/transform') .set('x-api-key', KEY) .send({ wrong: 1 }); expect(res.status).toBe(400); }); it('POST /v1/storage stores metadata plus attachments and quotes by size', async () => { const { app } = buildApp(); const res = await request(app) .post('/v1/storage') .set('x-api-key', KEY) .field('metadata', JSON.stringify({ title: 'report' })) .attach('attachments', Buffer.alloc(1024 * 1024), 'one.bin') .attach('attachments', Buffer.alloc(1024 * 1024), 'two.bin'); expect(res.status).toBe(200); expect(res.body.id).toBeTruthy(); // metadata string is 20 bytes -> 1 KB; 2 MB attachments // list 10 + 1 * 1 + 2 * 50 = 111, free-tier multiplier 1 expect(res.body.quote.totalCents).toBe(111); }); it('GET /v1/storage lists the caller items newest first', async () => { const { app } = buildApp(); await request(app) .post('/v1/storage') .set('x-api-key', KEY) .field('metadata', JSON.stringify({ title: 'a' })); await request(app) .post('/v1/storage') .set('x-api-key', KEY) .field('metadata', JSON.stringify({ title: 'b' })); const res = await request(app).get('/v1/storage').set('x-api-key', KEY); expect(res.status).toBe(200); expect(res.body.items).toHaveLength(2); expect(res.body.items[0].metadata.title).toBe('b'); }); it('GET /v1/usage returns the caller summary', async () => { const { app } = buildApp(); await request(app) .post('/v1/transform') .set('x-api-key', KEY) .send({ text: 'x' }); const res = await request(app).get('/v1/usage').set('x-api-key', KEY); expect(res.status).toBe(200); expect(res.body.customerId).toBe('cust_1'); expect(res.body.calls).toBe(1); expect(res.body.totalCents).toBe(4); }); it('charges less for a business-tier customer', async () => { const { app } = buildApp(); const res = await request(app) .post('/v1/transform') .set('x-api-key', 'key-linus') .send({ text: 'hello' }); expect(res.body.quote.totalCents).toBe(1); // list 4 x business multiplier 0.25 }); }); ``` - [ ] **Step 3: Run tests to verify they fail** Run: `npm test -- tests/app.test.ts` Expected: FAIL — `Cannot find module '../src/app'`. - [ ] **Step 4: Implement the app and server** Create `src/app.ts`: ```ts import path from 'path'; import { randomUUID } from 'crypto'; import express, { Express, NextFunction, Request, Response } from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; import swaggerUi from 'swagger-ui-express'; import YAML from 'yamljs'; import { apiKeyAuth, Customer, CustomerRepo, InMemoryCustomerRepo, } from './auth'; import { meter } from './meter'; import { ConfigTierCatalog, InMemoryPricingStore, PricingContext, PricingStore, } from './pricing'; import { InMemoryUsageRepo, UsageRepo } from './usage'; export interface StoredItem { id: string; customerId: string; metadata: Record; attachments: { filename: string; size: number }[]; createdAt: string; } export const DEFAULT_CUSTOMERS: Customer[] = [ { id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' }, { id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' }, { id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' }, ]; export interface AppDeps { usage?: UsageRepo; customers?: CustomerRepo; pricingStore?: PricingStore; } const SPEC_PATH = path.join(process.cwd(), 'openapi.yaml'); export function buildApp(deps: AppDeps = {}): { app: Express; usage: UsageRepo; customers: CustomerRepo; pricing: PricingContext; pricingStore: PricingStore; items: StoredItem[]; } { const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS); const usage = deps.usage ?? new InMemoryUsageRepo(); const pricingStore = deps.pricingStore ?? new InMemoryPricingStore(); // Live pricing context: every quote reads the store, so admin edits apply immediately. const pricing: PricingContext = { get rateCard() { return pricingStore.getRateCard(); }, get tiers() { return new ConfigTierCatalog(pricingStore.getTiers()); }, }; const items: StoredItem[] = []; const app = express(); app.use(express.json()); const spec = YAML.load(SPEC_PATH); app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec)); app.use('/v1', apiKeyAuth(customers)); app.use( OpenApiValidator.middleware({ apiSpec: SPEC_PATH, validateRequests: true, validateResponses: false, }), ); app.get('/v1/status', meter('status', usage, pricing), (req, res) => { res.json({ status: 'ok', quote: res.locals.quote }); }); app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => { const text = String(req.body?.text ?? ''); res.json({ output: text.toUpperCase(), quote: res.locals.quote }); }); app.post('/v1/storage', meter('storage', usage, pricing), (req, res) => { const raw = req.body?.metadata; const metadata = typeof raw === 'string' ? JSON.parse(raw) : ((raw ?? {}) as Record); const files = (req.files as Express.Multer.File[]) ?? []; const item: StoredItem = { id: randomUUID(), customerId: req.customer!.id, metadata, attachments: files.map((f) => ({ filename: f.originalname, size: f.size })), createdAt: new Date().toISOString(), }; items.unshift(item); res.json({ id: item.id, quote: res.locals.quote }); }); app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => { res.json({ items: items.filter((i) => i.customerId === req.customer!.id) }); }); app.get('/v1/usage', (req, res) => { res.json(usage.summaryFor(req.customer!.id)); }); app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); }); return { app, usage, customers, pricing, pricingStore, items }; } ``` Create `src/index.ts` (in-memory for now; Task 7 swaps in SQLite): ```ts import { buildApp } from './app'; const { app } = buildApp(); const port = Number(process.env.PORT ?? 3000); app.listen(port, () => { console.log(`Zappier API listening on http://localhost:${port}`); console.log(`OpenAPI docs at http://localhost:${port}/docs`); }); ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npm test` Expected: PASS — all suites, 35 tests total. - [ ] **Step 6: Smoke-test the running server** Run: `npm run dev` (in one terminal), then: ```bash curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/status curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \ -d '{"text":"hello"}' http://localhost:3000/v1/transform curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \ -d '{"wrong":1}' http://localhost:3000/v1/transform ``` Expected: `{"status":"ok",...}`, then `{"output":"HELLO",...}`, then a 400 validation error. Open http://localhost:3000/docs in a browser — Swagger UI renders the spec. Stop the server (Ctrl+C). - [ ] **Step 7: Commit** ```bash git add openapi.yaml src/app.ts src/index.ts tests/app.test.ts git commit -m "feat: add OpenAPI-driven metered API with Swagger docs" ``` --- ### Task 6: Monthly credit quotas **Files:** - Create: `src/billing/credit.ts` - Test: `tests/credit.test.ts` - Modify: `src/app.ts` (`GET /v1/usage` route) - Modify: `tests/app.test.ts` (usage-summary test) **Interfaces:** - Consumes: `UsageSummary` (Task 2); `TierConfig`, `PricingContext` (Task 1); `buildApp`'s `pricing` and `usage` (Task 5). - Produces: - `export interface BilledSummary extends UsageSummary { includedCents: number; billableCents: number }` - `export function applyMonthlyCredit(summary: UsageSummary, tier: TierConfig): BilledSummary` — `includedCents = min(totalCents, tier.monthlyCreditCents)`, `billableCents = totalCents - includedCents`. Used by the `/v1/usage` route here and by the Stripe job in Task 9. - [ ] **Step 1: Write the failing tests** Create `tests/credit.test.ts`: ```ts import { applyMonthlyCredit } from '../src/billing/credit'; import { DEFAULT_TIERS } from '../src/pricing'; import { UsageSummary } from '../src/usage'; const freeTier = DEFAULT_TIERS.find((t) => t.id === 'free')!; // monthlyCreditCents: 100 const summary = (totalCents: number): UsageSummary => ({ customerId: 'cust_1', totalCents, calls: 1, byEndpoint: {}, }); describe('applyMonthlyCredit', () => { it('covers usage fully when under the monthly credit', () => { const billed = applyMonthlyCredit(summary(60), freeTier); expect(billed.includedCents).toBe(60); expect(billed.billableCents).toBe(0); expect(billed.totalCents).toBe(60); }); it('bills only the overage when usage exceeds the credit', () => { const billed = applyMonthlyCredit(summary(250), freeTier); expect(billed.includedCents).toBe(100); expect(billed.billableCents).toBe(150); }); it('bills nothing when there is no usage', () => { const billed = applyMonthlyCredit(summary(0), freeTier); expect(billed.includedCents).toBe(0); expect(billed.billableCents).toBe(0); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- tests/credit.test.ts` Expected: FAIL — `Cannot find module '../src/billing/credit'`. - [ ] **Step 3: Implement the credit module** Create `src/billing/credit.ts`: ```ts import { TierConfig } from '../pricing'; import { UsageSummary } from '../usage'; export interface BilledSummary extends UsageSummary { includedCents: number; billableCents: number; } export function applyMonthlyCredit( summary: UsageSummary, tier: TierConfig, ): BilledSummary { const includedCents = Math.min(summary.totalCents, tier.monthlyCreditCents); return { ...summary, includedCents, billableCents: summary.totalCents - includedCents, }; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npm test -- tests/credit.test.ts` Expected: PASS — 3 tests. - [ ] **Step 5: Wire the credit into `GET /v1/usage`** In `src/app.ts`, add the import: ```ts import { applyMonthlyCredit } from './billing/credit'; ``` Replace the `/v1/usage` route with: ```ts app.get('/v1/usage', (req, res) => { const summary = usage.summaryFor(req.customer!.id); const tier = pricing.tiers.find(req.customer!.tierId); res.json(tier ? applyMonthlyCredit(summary, tier) : summary); }); ``` Replace the usage test in `tests/app.test.ts` with: ```ts it('GET /v1/usage returns the caller summary with monthly credit applied', async () => { const { app } = buildApp(); await request(app) .post('/v1/transform') .set('x-api-key', KEY) .send({ text: 'x' }); const res = await request(app).get('/v1/usage').set('x-api-key', KEY); expect(res.status).toBe(200); expect(res.body.customerId).toBe('cust_1'); expect(res.body.calls).toBe(1); expect(res.body.totalCents).toBe(4); expect(res.body.includedCents).toBe(4); // free-tier credit (100) covers it expect(res.body.billableCents).toBe(0); }); ``` - [ ] **Step 6: Run the full suite** Run: `npm test` Expected: PASS — 38 tests total. - [ ] **Step 7: Commit** ```bash git add src/billing/credit.ts src/app.ts tests/credit.test.ts tests/app.test.ts git commit -m "feat: apply monthly credit quotas to usage summaries" ``` --- ### Task 7: SQLite persistence (usage, customers, pricing) **Files:** - Modify: `package.json` (via npm install) - Create: `src/db/usage-repo.ts` - Create: `src/db/customer-repo.ts` - Create: `src/db/pricing-store.ts` - Modify: `src/index.ts` (inject SQLite repos + store) - Test: `tests/db-usage.test.ts` - Test: `tests/db-customer.test.ts` - Test: `tests/db-pricing.test.ts` **Interfaces:** - Consumes: `UsageRepo`, `UsageEntry`, `UsageSummary`, `summarize` (Task 2); `CustomerRepo`, `Customer` (Task 3); `PricingStore`, `PriceRule`, `TierConfig`, `RateCard`, `DEFAULT_RATE_CARD`, `DEFAULT_TIERS` (Task 1); `AppDeps`, `DEFAULT_CUSTOMERS`, `buildApp` (Task 5). - Produces: - `export class SqliteUsageRepo implements UsageRepo` — `constructor(db: Database.Database)`; creates table `usage_entries` on first use. - `export class SqliteCustomerRepo implements CustomerRepo` — `constructor(db: Database.Database, seed?: Customer[])`; creates table `customers` (with `multiplier_override`) and inserts `seed` only when the table is empty. Implements `save` as SQL upsert. - `export class SqlitePricingStore implements PricingStore` — `constructor(db: Database.Database, seedCard?: RateCard, seedTiers?: TierConfig[])`; creates tables `price_endpoints` + `tiers` and seeds them only when empty. - All three are injected into `buildApp({ usage, customers, pricingStore })` from `src/index.ts`; no route or middleware changes. - [ ] **Step 1: Install better-sqlite3** Run: `cd /Users/marchon/zappier && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3` Expected: both added to `package.json`. - [ ] **Step 2: Write the failing tests** Create `tests/db-usage.test.ts`: ```ts import Database from 'better-sqlite3'; import { SqliteUsageRepo } from '../src/db/usage-repo'; import { UsageEntry } from '../src/usage'; const entry = (over: Partial = {}): UsageEntry => ({ customerId: 'cust_1', endpointId: 'transform', cents: 4, metadataBytes: 0, attachmentBytes: 0, timestamp: new Date('2026-07-27T10:00:00Z'), ...over, }); describe('SqliteUsageRepo', () => { it('records and lists entries per customer', () => { const repo = new SqliteUsageRepo(new Database(':memory:')); repo.record(entry()); repo.record(entry({ customerId: 'cust_2' })); expect(repo.listFor('cust_1')).toHaveLength(1); expect(repo.listFor('cust_2')).toHaveLength(1); }); it('filters entries by since date', () => { const repo = new SqliteUsageRepo(new Database(':memory:')); repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') })); repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') })); expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1); }); it('summarizes totals by endpoint', () => { const repo = new SqliteUsageRepo(new Database(':memory:')); repo.record(entry()); repo.record(entry({ endpointId: 'storage', cents: 112 })); const s = repo.summaryFor('cust_1'); expect(s.calls).toBe(2); expect(s.totalCents).toBe(116); expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 }); }); }); ``` Create `tests/db-customer.test.ts`: ```ts import Database from 'better-sqlite3'; import { Customer } from '../src/auth'; import { SqliteCustomerRepo } from '../src/db/customer-repo'; const customer = (apiKey: string): Customer => ({ id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey, stripeCustomerId: 'cus_123', }); describe('SqliteCustomerRepo', () => { it('finds a customer by API key', () => { const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); expect(repo.findByApiKey('key-ada')?.tierId).toBe('pro'); }); it('returns undefined for an unknown key', () => { const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); expect(repo.findByApiKey('wrong')).toBeUndefined(); }); it('lists all customers with their Stripe ids', () => { const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); const all = repo.list(); expect(all).toHaveLength(1); expect(all[0].stripeCustomerId).toBe('cus_123'); }); it('seeds only when the table is empty', () => { const db = new Database(':memory:'); new SqliteCustomerRepo(db, [customer('key-ada')]); const again = new SqliteCustomerRepo(db, [customer('key-other')]); expect(again.list().map((c) => c.apiKey)).toEqual(['key-ada']); }); it('save() upserts including the multiplier override', () => { const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]); repo.save({ ...customer('key-ada'), tierId: 'business', multiplierOverride: 0.4 }); const updated = repo.findByApiKey('key-ada'); expect(updated?.tierId).toBe('business'); expect(updated?.multiplierOverride).toBe(0.4); expect(repo.list()).toHaveLength(1); }); }); ``` Create `tests/db-pricing.test.ts`: ```ts import Database from 'better-sqlite3'; import { SqlitePricingStore } from '../src/db/pricing-store'; describe('SqlitePricingStore', () => { it('seeds the default rate card and tiers when empty', () => { const store = new SqlitePricingStore(new Database(':memory:')); expect(store.getRateCard().endpoints.transform).toEqual({ kind: 'fixed', fixedCents: 4 }); expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']); }); it('seeds only once', () => { const db = new Database(':memory:'); const first = new SqlitePricingStore(db); first.deleteEndpoint('transform'); const second = new SqlitePricingStore(db); expect(second.getRateCard().endpoints.transform).toBeUndefined(); }); it('upserts and deletes endpoints', () => { const store = new SqlitePricingStore(new Database(':memory:')); store.upsertEndpoint('experimental', { kind: 'variable', baseCents: 3, perKbCents: 2, perMbCents: 20 }); expect(store.getRateCard().endpoints.experimental).toEqual({ kind: 'variable', baseCents: 3, perKbCents: 2, perMbCents: 20, }); store.deleteEndpoint('experimental'); expect(store.getRateCard().endpoints.experimental).toBeUndefined(); }); it('upserts tiers including default rules, and deletes them', () => { const store = new SqlitePricingStore(new Database(':memory:')); store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500, defaultRule: { kind: 'fixed', fixedCents: 6 }, }); expect(store.getTiers().find((t) => t.id === 'edu')?.defaultRule).toEqual({ kind: 'fixed', fixedCents: 6, }); store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 }); expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1); expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.3); store.deleteTier('edu'); expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined(); }); }); ``` - [ ] **Step 3: Run tests to verify they fail** Run: `npm test -- tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts` Expected: FAIL — `Cannot find module '../src/db/...'`. - [ ] **Step 4: Implement the SQLite repos and pricing store** Create `src/db/usage-repo.ts`: ```ts import Database from 'better-sqlite3'; import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage'; export class SqliteUsageRepo implements UsageRepo { constructor(private db: Database.Database) { this.db.exec(` CREATE TABLE IF NOT EXISTS usage_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id TEXT NOT NULL, endpoint_id TEXT NOT NULL, cents INTEGER NOT NULL, metadata_bytes INTEGER NOT NULL, attachment_bytes INTEGER NOT NULL, timestamp_ms INTEGER NOT NULL ) `); } record(entry: UsageEntry): void { this.db .prepare( `INSERT INTO usage_entries (customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms) VALUES (?, ?, ?, ?, ?, ?)`, ) .run( entry.customerId, entry.endpointId, entry.cents, entry.metadataBytes, entry.attachmentBytes, entry.timestamp.getTime(), ); } listFor(customerId: string, since?: Date): UsageEntry[] { const rows = ( since ? this.db .prepare( 'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms', ) .all(customerId, since.getTime()) : this.db .prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms') .all(customerId) ) as Record[]; return rows.map((r) => ({ customerId: r.customer_id as string, endpointId: r.endpoint_id as string, cents: r.cents as number, metadataBytes: r.metadata_bytes as number, attachmentBytes: r.attachment_bytes as number, timestamp: new Date(r.timestamp_ms as number), })); } summaryFor(customerId: string, since?: Date): UsageSummary { return summarize(customerId, this.listFor(customerId, since)); } } ``` Create `src/db/customer-repo.ts`: ```ts import Database from 'better-sqlite3'; import { Customer, CustomerRepo } from '../auth'; export class SqliteCustomerRepo implements CustomerRepo { constructor(private db: Database.Database, seed: Customer[] = []) { this.db.exec(` CREATE TABLE IF NOT EXISTS customers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, tier_id TEXT NOT NULL, api_key TEXT NOT NULL UNIQUE, stripe_customer_id TEXT, multiplier_override REAL ) `); const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }; if (n === 0) { for (const c of seed) this.save(c); } } findByApiKey(apiKey: string): Customer | undefined { const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as | Record | undefined; return r ? toCustomer(r) : undefined; } list(): Customer[] { const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record< string, unknown >[]; return rows.map(toCustomer); } save(customer: Customer): void { this.db .prepare( `INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override) VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride) ON CONFLICT(id) DO UPDATE SET name = @name, tier_id = @tierId, api_key = @apiKey, stripe_customer_id = @stripeCustomerId, multiplier_override = @multiplierOverride`, ) .run({ id: customer.id, name: customer.name, tierId: customer.tierId, apiKey: customer.apiKey, stripeCustomerId: customer.stripeCustomerId ?? null, multiplierOverride: customer.multiplierOverride ?? null, }); } } function toCustomer(r: Record): Customer { return { id: r.id as string, name: r.name as string, tierId: r.tier_id as string, apiKey: r.api_key as string, stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined, multiplierOverride: (r.multiplier_override as number | null) ?? undefined, }; } ``` Create `src/db/pricing-store.ts`: ```ts import Database from 'better-sqlite3'; import { DEFAULT_RATE_CARD, DEFAULT_TIERS, PriceRule, PricingStore, RateCard, TierConfig, } from '../pricing'; export class SqlitePricingStore implements PricingStore { constructor( private db: Database.Database, seedCard: RateCard = DEFAULT_RATE_CARD, seedTiers: TierConfig[] = DEFAULT_TIERS, ) { this.db.exec(` CREATE TABLE IF NOT EXISTS price_endpoints ( endpoint_id TEXT PRIMARY KEY, rule_json TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS tiers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, multiplier REAL NOT NULL, monthly_credit_cents INTEGER NOT NULL, default_rule_json TEXT ) `); const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM price_endpoints').get() as { n: number }; if (n === 0) { for (const [id, rule] of Object.entries(seedCard.endpoints)) { this.upsertEndpoint(id, rule); } for (const tier of seedTiers) { this.upsertTier(tier); } } } getRateCard(): RateCard { const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record[]; const endpoints: Record = {}; for (const r of rows) { endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule; } return { endpoints }; } getTiers(): TierConfig[] { const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record[]; return rows.map((r) => ({ id: r.id as string, name: r.name as string, multiplier: r.multiplier as number, monthlyCreditCents: r.monthly_credit_cents as number, defaultRule: r.default_rule_json ? (JSON.parse(r.default_rule_json as string) as PriceRule) : undefined, })); } upsertEndpoint(endpointId: string, rule: PriceRule): void { this.db .prepare( `INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?) ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`, ) .run(endpointId, JSON.stringify(rule)); } deleteEndpoint(endpointId: string): void { this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId); } upsertTier(tier: TierConfig): void { this.db .prepare( `INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json) VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson) ON CONFLICT(id) DO UPDATE SET name = @name, multiplier = @multiplier, monthly_credit_cents = @monthlyCreditCents, default_rule_json = @defaultRuleJson`, ) .run({ id: tier.id, name: tier.name, multiplier: tier.multiplier, monthlyCreditCents: tier.monthlyCreditCents, defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null, }); } deleteTier(tierId: string): void { this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId); } } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npm test` Expected: PASS — 50 tests total. - [ ] **Step 6: Inject SQLite into the server** Replace `src/index.ts` with: ```ts import Database from 'better-sqlite3'; import { buildApp, DEFAULT_CUSTOMERS } from './app'; import { SqliteCustomerRepo } from './db/customer-repo'; import { SqlitePricingStore } from './db/pricing-store'; import { SqliteUsageRepo } from './db/usage-repo'; const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db'); const usage = new SqliteUsageRepo(db); const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS); const pricingStore = new SqlitePricingStore(db); const { app } = buildApp({ usage, customers, pricingStore }); const port = Number(process.env.PORT ?? 3000); app.listen(port, () => { console.log(`Zappier API listening on http://localhost:${port}`); console.log(`OpenAPI docs at http://localhost:${port}/docs`); }); ``` - [ ] **Step 7: Verify persistence across restarts** ```bash cd /Users/marchon/zappier npm run dev & sleep 2 curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \ -d '{"text":"hello"}' http://localhost:3000/v1/transform kill %1 npm run dev & sleep 2 curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/usage kill %1 ``` Expected: the second `/v1/usage` response still shows `"calls":1,"totalCents":4` — usage survived the restart. Delete the smoke-test database afterwards: `rm -f zappier.db`. - [ ] **Step 8: Commit** ```bash git add package.json package-lock.json src/db src/index.ts tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts git commit -m "feat: persist usage, customers, and pricing in SQLite" ``` --- ### Task 8: Admin API + pricing web UI **Files:** - Create: `src/admin.ts` - Create: `admin/index.html` - Create: `admin/app.js` - Modify: `src/app.ts` (mount admin router + static UI) - Test: `tests/admin.test.ts` **Interfaces:** - Consumes: `PricingStore`, `PriceRule`, `TierConfig` (Task 1); `CustomerRepo`, `Customer` (Task 3); `buildApp` (Task 5). - Produces: - `export function adminAuth(): RequestHandler` — requires `x-admin-key` = `process.env.ADMIN_KEY ?? 'admin-dev-key'`, else 403. - `export function adminRouter(store: PricingStore, customers: CustomerRepo): Router` with routes: - `GET /pricing` → `{ rateCard, tiers }` - `PUT /endpoints/:id` body `PriceRule` → upsert (400 on invalid rule) - `DELETE /endpoints/:id` - `PUT /tiers/:id` body `TierConfig` → upsert (400 on invalid config) - `DELETE /tiers/:id` - `GET /customers` → `{ customers }` (apiKey masked) - `POST /customers` body `{ name, tierId }` → 201 with the created customer including its generated `apiKey` (shown once) - `PUT /customers/:id` body partial `{ name?, tierId?, multiplierOverride?, stripeCustomerId? }` → update (404 on unknown id) - `buildApp` mounts these at `/admin/api` (behind `adminAuth`) and serves the static UI at `/admin`. - [ ] **Step 1: Write the failing tests** Create `tests/admin.test.ts`: ```ts import request from 'supertest'; import { buildApp } from '../src/app'; const ADMIN = { 'x-admin-key': 'admin-dev-key' }; const KEY = 'key-ada'; describe('admin API', () => { it('rejects calls without an admin key', async () => { const { app } = buildApp(); const res = await request(app).get('/admin/api/pricing'); expect(res.status).toBe(403); }); it('returns the current pricing', async () => { const { app } = buildApp(); const res = await request(app).get('/admin/api/pricing').set(ADMIN); expect(res.status).toBe(200); expect(res.body.rateCard.endpoints.status).toEqual({ kind: 'free' }); expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(['free', 'pro', 'business']); }); it('rejects an invalid price rule with 400', async () => { const { app } = buildApp(); const res = await request(app) .put('/admin/api/endpoints/transform') .set(ADMIN) .send({ kind: 'sometimes' }); expect(res.status).toBe(400); }); it('reprices an endpoint live, without restart', async () => { const { app } = buildApp(); await request(app) .put('/admin/api/endpoints/transform') .set(ADMIN) .send({ kind: 'fixed', fixedCents: 10 }); const res = await request(app) .post('/v1/transform') .set('x-api-key', KEY) .send({ text: 'hi' }); expect(res.body.quote.totalCents).toBe(10); // was 4 }); it('creates a customer type live and prices calls for it', async () => { const { app } = buildApp(); await request(app) .put('/admin/api/tiers/edu') .set(ADMIN) .send({ id: 'edu', name: 'Education', multiplier: 0.5, monthlyCreditCents: 500 }); const created = await request(app) .post('/admin/api/customers') .set(ADMIN) .send({ name: 'School', tierId: 'edu' }); expect(created.status).toBe(201); expect(created.body.apiKey).toMatch(/^key-/); const res = await request(app) .post('/v1/transform') .set('x-api-key', created.body.apiKey) .send({ text: 'hi' }); expect(res.body.quote.totalCents).toBe(2); // list 4 x 0.5 }); it('sets a per-customer multiplier override live', async () => { const { app } = buildApp(); const res = await request(app) .put('/admin/api/customers/cust_1') .set(ADMIN) .send({ multiplierOverride: 0.5 }); expect(res.status).toBe(200); const call = await request(app) .post('/v1/transform') .set('x-api-key', KEY) .send({ text: 'hi' }); expect(call.body.quote.totalCents).toBe(2); // was 4 }); it('masks API keys in the customer list and 404s unknown customers', async () => { const { app } = buildApp(); const list = await request(app).get('/admin/api/customers').set(ADMIN); expect(list.status).toBe(200); expect(JSON.stringify(list.body)).not.toContain('key-ada'); const missing = await request(app) .put('/admin/api/customers/cust_nope') .set(ADMIN) .send({ tierId: 'pro' }); expect(missing.status).toBe(404); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npm test -- tests/admin.test.ts` Expected: FAIL — admin routes 404 (and `src/admin.ts` missing). - [ ] **Step 3: Implement the admin router** Create `src/admin.ts`: ```ts import { randomBytes, randomUUID } from 'crypto'; import { RequestHandler, Router } from 'express'; import { CustomerRepo } from './auth'; import { PriceRule, PricingStore, TierConfig } from './pricing'; export function adminAuth(): RequestHandler { return (req, res, next) => { const expected = process.env.ADMIN_KEY ?? 'admin-dev-key'; if (req.header('x-admin-key') !== expected) { res.status(403).json({ error: 'invalid or missing admin key' }); return; } next(); }; } function isValidRule(rule: unknown): rule is PriceRule { if (!rule || typeof rule !== 'object') return false; const r = rule as Record; if (r.kind === 'free') return true; if (r.kind === 'fixed') return typeof r.fixedCents === 'number'; if (r.kind === 'variable') { return ['baseCents', 'perKbCents', 'perMbCents'].every((k) => typeof r[k] === 'number'); } return false; } function isValidTier(tier: unknown): tier is TierConfig { if (!tier || typeof tier !== 'object') return false; const t = tier as Record; return ( typeof t.name === 'string' && typeof t.multiplier === 'number' && typeof t.monthlyCreditCents === 'number' && (t.defaultRule === undefined || isValidRule(t.defaultRule)) ); } export function adminRouter(store: PricingStore, customers: CustomerRepo): Router { const router = Router(); router.get('/pricing', (req, res) => { res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() }); }); router.put('/endpoints/:id', (req, res) => { if (!isValidRule(req.body)) { res.status(400).json({ error: 'invalid price rule' }); return; } store.upsertEndpoint(req.params.id, req.body); res.json({ ok: true }); }); router.delete('/endpoints/:id', (req, res) => { store.deleteEndpoint(req.params.id); res.json({ ok: true }); }); router.put('/tiers/:id', (req, res) => { if (!isValidTier(req.body)) { res.status(400).json({ error: 'invalid tier config' }); return; } store.upsertTier({ ...req.body, id: req.params.id }); res.json({ ok: true }); }); router.delete('/tiers/:id', (req, res) => { store.deleteTier(req.params.id); res.json({ ok: true }); }); router.get('/customers', (req, res) => { res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) }); }); router.post('/customers', (req, res) => { const { name, tierId } = req.body ?? {}; if (typeof name !== 'string' || typeof tierId !== 'string') { res.status(400).json({ error: 'name and tierId required' }); return; } const customer = { id: `cust_${randomUUID().slice(0, 8)}`, name, tierId, apiKey: `key-${randomBytes(12).toString('hex')}`, }; customers.save(customer); res.status(201).json(customer); }); router.put('/customers/:id', (req, res) => { const existing = customers.list().find((c) => c.id === req.params.id); if (!existing) { res.status(404).json({ error: 'customer not found' }); return; } const { name, tierId, multiplierOverride, stripeCustomerId } = req.body ?? {}; customers.save({ ...existing, ...(name !== undefined ? { name } : {}), ...(tierId !== undefined ? { tierId } : {}), ...(multiplierOverride !== undefined ? { multiplierOverride } : {}), ...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}), }); res.json({ ok: true }); }); return router; } ``` - [ ] **Step 4: Mount the admin API and static UI in the app** In `src/app.ts`, add the import: ```ts import { adminAuth, adminRouter } from './admin'; ``` Add these two lines immediately after `app.use('/docs', ...)` (before the `/v1` auth middleware): ```ts app.use('/admin/api', adminAuth(), adminRouter(pricingStore, customers)); app.use('/admin', express.static(path.join(process.cwd(), 'admin'))); ``` - [ ] **Step 5: Build the admin web UI** Create `admin/index.html`: ```html Zappier Pricing Admin

Zappier Pricing Admin

``` Create `admin/app.js`: ```js const state = { pricing: null, customers: [] }; function adminKey() { let key = localStorage.getItem('zappier-admin-key'); if (!key) { key = prompt('Admin key:'); localStorage.setItem('zappier-admin-key', key); } return key; } async function api(path, options = {}) { const res = await fetch(`/admin/api${path}`, { ...options, headers: { 'content-type': 'application/json', 'x-admin-key': adminKey() }, }); if (res.status === 403) { localStorage.removeItem('zappier-admin-key'); throw new Error('Admin key rejected — reload to re-enter.'); } if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`); return res.json(); } function say(msg) { document.getElementById('status').textContent = msg; } async function load() { state.pricing = await api('/pricing'); state.customers = (await api('/customers')).customers; renderEndpoints(); renderTiers(); renderCustomers(); } function ruleInputs(id, rule) { const fields = rule.kind === 'fixed' ? { fixedCents: rule.fixedCents } : rule.kind === 'variable' ? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents } : {}; return Object.entries(fields) .map( ([k, v]) => `${k} `, ) .join(' '); } function renderEndpoints() { const rows = Object.entries(state.pricing.rateCard.endpoints) .map( ([id, rule]) => ` ${id} ${ruleInputs(id, rule)} `, ) .join(''); document.getElementById('endpoints').innerHTML = `

Rate card

${rows}
Endpoint (operationId)KindPrices (cents)

Add endpoint

`; } async function saveEndpoint(id) { const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value; const rule = { kind }; document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => { rule[el.dataset.field] = Number(el.value); }); if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0; if (kind === 'variable') { rule.baseCents = rule.baseCents ?? 0; rule.perKbCents = rule.perKbCents ?? 0; rule.perMbCents = rule.perMbCents ?? 0; } await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) }); say(`Saved ${id}.`); await load(); } async function deleteEndpoint(id) { await api(`/endpoints/${id}`, { method: 'DELETE' }); say(`Deleted ${id}.`); await load(); } async function addEndpoint() { const id = document.getElementById('new-endpoint-id').value.trim(); const kind = document.getElementById('new-endpoint-kind').value; if (!id) return say('Endpoint id required.'); const rule = kind === 'free' ? { kind } : kind === 'fixed' ? { kind, fixedCents: 0 } : { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 }; await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) }); say(`Added ${id}.`); await load(); } function renderTiers() { const rows = state.pricing.tiers .map( (t) => ` ${t.id} `, ) .join(''); document.getElementById('tiers').innerHTML = `

Customer types

${rows}
IdNameMultiplierMonthly credit (cents)

Add customer type

multiplier `; } async function saveTier(id) { const body = { id }; document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => { body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value; }); const existing = state.pricing.tiers.find((t) => t.id === id); if (existing?.defaultRule) body.defaultRule = existing.defaultRule; await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) }); say(`Saved tier ${id}.`); await load(); } async function deleteTier(id) { await api(`/tiers/${id}`, { method: 'DELETE' }); say(`Deleted tier ${id}.`); await load(); } async function addTier() { const id = document.getElementById('new-tier-id').value.trim(); const name = document.getElementById('new-tier-name').value.trim(); const multiplier = Number(document.getElementById('new-tier-multiplier').value); if (!id || !name) return say('Tier id and name required.'); await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }), }); say(`Added tier ${id}.`); await load(); } function renderCustomers() { const tierOptions = (selected) => state.pricing.tiers .map((t) => ``) .join(''); const rows = state.customers .map( (c) => ` ${c.id} ${c.name} `, ) .join(''); document.getElementById('customers').innerHTML = `

Customers

${rows}
IdNameTypeMultiplier override

Add customer

A new customer's API key is shown once in the status line below.

`; } async function saveCustomer(id) { const body = {}; document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => { if (el.value === '') return; body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value; }); await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) }); say(`Saved customer ${id}.`); await load(); } async function addCustomer() { const name = document.getElementById('new-customer-name').value.trim(); const tierId = document.getElementById('new-customer-tier').value; if (!name) return say('Customer name required.'); const created = await api('/customers', { method: 'POST', body: JSON.stringify({ name, tierId }), }); say(`Created ${created.id} — API key: ${created.apiKey}`); await load(); } document.querySelectorAll('nav button').forEach((btn) => btn.addEventListener('click', () => { document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('section').forEach((s) => (s.hidden = true)); document.getElementById(btn.dataset.tab).hidden = false; }), ); load().catch((err) => say(err.message)); ``` - [ ] **Step 6: Run tests to verify they pass** Run: `npm test` Expected: PASS — 57 tests total. - [ ] **Step 7: Verify the UI end-to-end (manual)** 1. `npm run dev`, open http://localhost:3000/admin, enter `admin-dev-key`. 2. In **Rate card**, change `transform` to `fixedCents: 10`, Save. 3. `curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' -d '{"text":"hi"}' http://localhost:3000/v1/transform` → quote shows `totalCents: 10` (no restart). 4. In **Customer types**, add `edu` at 0.5×; in **Customers**, create a customer on `edu` and call the API with its new key. 5. Stop the server. Delete the smoke-test database: `rm -f zappier.db`. - [ ] **Step 8: Commit** ```bash git add src/admin.ts src/app.ts admin tests/admin.test.ts git commit -m "feat: add admin API and web UI for live pricing management" ``` --- ### Task 9: Stripe metered billing **Files:** - Create: `src/billing/stripe.ts` - Create: `src/jobs/report-usage.ts` - Test: `tests/stripe.test.ts` **Interfaces:** - Consumes: `UsageEntry` (Task 2); `PricingStore` (Task 1) / `SqlitePricingStore` (Task 7) for tier credits; `SqliteUsageRepo`, `SqliteCustomerRepo` (Task 7); `DEFAULT_CUSTOMERS` (Task 5); `Customer.stripeCustomerId` / `tierId` (Task 3); the credit semantics from Task 6. - Produces: - `export const METER_EVENT_NAME = 'zappier.api_cents'` - `export interface MeterEventClient { createMeterEvent(params: { eventName: string; customerId: string; value: string }): Promise }` - `export async function reportUsage(client: MeterEventClient, stripeCustomerId: string, entries: UsageEntry[], monthlyCreditCents: number): Promise` — sums entry cents, subtracts the monthly credit (floor 0), sends one meter event when the billable amount is > 0, returns the reported cents. - [ ] **Step 1: Create the Stripe account and billing meter (manual)** 1. Sign up / log in at https://dashboard.stripe.com (test mode is fine). 2. Create a product per customer type: `Zappier Free`, `Zappier Pro`, `Zappier Business`. 3. Under **Billing → Meters**, create a meter named `zappier.api_cents`, aggregation = **Sum** of `value`. 4. Add a metered price to each tier's subscription using that meter. 5. Copy the test secret key (`sk_test_...`) for the job below. Expected: meter visible in the dashboard with event name `zappier.api_cents`. - [ ] **Step 2: Write the failing tests** Create `tests/stripe.test.ts`: ```ts import { MeterEventClient, reportUsage, METER_EVENT_NAME } from '../src/billing/stripe'; import { UsageEntry } from '../src/usage'; const entry = (cents: number): UsageEntry => ({ customerId: 'cust_1', endpointId: 'storage', cents, metadataBytes: 0, attachmentBytes: 0, timestamp: new Date(), }); const fakeClient = () => { const calls: { eventName: string; customerId: string; value: string }[] = []; const client: MeterEventClient = { createMeterEvent: async (params) => { calls.push(params); }, }; return { client, calls }; }; describe('reportUsage', () => { it('reports usage minus the monthly credit as one meter event', async () => { const { client, calls } = fakeClient(); const reported = await reportUsage(client, 'cus_123', [entry(112), entry(4)], 100); expect(reported).toBe(16); // 116 - 100 credit expect(calls).toEqual([ { eventName: METER_EVENT_NAME, customerId: 'cus_123', value: '16' }, ]); }); it('reports nothing when the credit covers all usage', async () => { const { client, calls } = fakeClient(); const reported = await reportUsage(client, 'cus_123', [entry(4)], 100); expect(reported).toBe(0); expect(calls).toHaveLength(0); }); }); ``` - [ ] **Step 3: Run tests to verify they fail** Run: `npm test -- tests/stripe.test.ts` Expected: FAIL — `Cannot find module '../src/billing/stripe'`. - [ ] **Step 4: Implement the billing module and job** Create `src/billing/stripe.ts`: ```ts import { UsageEntry } from '../usage'; export const METER_EVENT_NAME = 'zappier.api_cents'; export interface MeterEventClient { createMeterEvent(params: { eventName: string; customerId: string; value: string; }): Promise; } export async function reportUsage( client: MeterEventClient, stripeCustomerId: string, entries: UsageEntry[], monthlyCreditCents: number, ): Promise { const totalCents = entries.reduce((sum, e) => sum + e.cents, 0); const billable = Math.max(0, totalCents - monthlyCreditCents); if (billable <= 0) return 0; await client.createMeterEvent({ eventName: METER_EVENT_NAME, customerId: stripeCustomerId, value: String(billable), }); return billable; } ``` Create `src/jobs/report-usage.ts` (run monthly, e.g. `STRIPE_SECRET_KEY=sk_test_... npx ts-node src/jobs/report-usage.ts`): ```ts import Database from 'better-sqlite3'; import Stripe from 'stripe'; import { DEFAULT_CUSTOMERS } from '../app'; import { METER_EVENT_NAME, reportUsage } from '../billing/stripe'; import { SqliteCustomerRepo } from '../db/customer-repo'; import { SqlitePricingStore } from '../db/pricing-store'; import { SqliteUsageRepo } from '../db/usage-repo'; async function main(): Promise { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const client = { createMeterEvent: async (p: { eventName: string; customerId: string; value: string }) => { await stripe.billing.meterEvents.create({ event_name: METER_EVENT_NAME, payload: { stripe_customer_id: p.customerId, value: p.value }, }); }, }; const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db'); const usage = new SqliteUsageRepo(db); const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS); const pricingStore = new SqlitePricingStore(db); const tiers = pricingStore.getTiers(); const since = new Date(); since.setUTCDate(1); since.setUTCHours(0, 0, 0, 0); for (const customer of customers.list()) { if (!customer.stripeCustomerId) continue; const tier = tiers.find((t) => t.id === customer.tierId); if (!tier) { console.warn(`${customer.id}: unknown tier ${customer.tierId}, skipped`); continue; } const entries = usage.listFor(customer.id, since); const reported = await reportUsage( client, customer.stripeCustomerId, entries, tier.monthlyCreditCents, ); console.log(`${customer.id}: reported ${reported} billable cents to Stripe`); } } main().catch((err) => { console.error(err); process.exit(1); }); ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npm test` Expected: PASS — 59 tests total. - [ ] **Step 6: Commit** ```bash git add src/billing/stripe.ts src/jobs/report-usage.ts tests/stripe.test.ts git commit -m "feat: report credit-adjusted monthly usage to Stripe billing meters" ``` --- ### Task 10: Zapier app — account, CLI, auth, trigger, action, publish **Files:** - Create: `zapier-app/package.json` - Create: `zapier-app/index.js` - Create: `zapier-app/authentication.js` - Create: `zapier-app/triggers/new_item.js` - Create: `zapier-app/creates/store_data.js` - Test: `zapier-app/test/app.test.js` **Interfaces:** - Consumes: the live API from Task 5 (`GET /v1/status`, `GET /v1/storage`, `POST /v1/storage`) with `x-api-key` auth. Pricing changes from Tasks 1–9 require **no** changes here — the Zapier app only calls endpoints; the server prices them. - Produces: a Zapier integration named `zappier` with: - Custom auth fields: `baseUrl` (default `http://localhost:3000`), `apiKey` (password). - Trigger `new_item` (polling): returns `GET {baseUrl}/v1/storage` → `items` array. - Action `store_data`: POSTs multipart `metadata` (JSON string of `{ title, note }`) + optional `attachments` file to `{baseUrl}/v1/storage`. - `bundle.authData.apiKey` is injected as the `x-api-key` header on every request via a `beforeRequest`. - [ ] **Step 1: Create the Zapier account (manual)** 1. Sign up at https://zapier.com (free plan is enough to build). 2. Open https://developer.zapier.com and accept the developer terms. 3. Install the CLI and log in: ```bash npm install -g zapier-platform-cli zapier login ``` Expected: browser OAuth completes; `zapier apps` runs without auth errors. - [ ] **Step 2: Scaffold the app package** ```bash mkdir -p /Users/marchon/zappier/zapier-app/triggers /Users/marchon/zappier/zapier-app/creates /Users/marchon/zappier/zapier-app/test ``` Create `zapier-app/package.json`: ```json { "name": "zappier", "version": "1.0.0", "description": "Store data and files through the metered Zappier API.", "main": "index.js", "scripts": { "test": "mocha --recursive --timeout 10000" } } ``` Run: `cd /Users/marchon/zappier/zapier-app && npm install zapier-platform-core form-data && npm install --save-dev mocha` Expected: dependencies written into `package.json`. - [ ] **Step 3: Write the failing tests** Create `zapier-app/test/app.test.js`: ```js const assert = require('assert'); const App = require('../index'); const storeData = require('../creates/store_data'); describe('Zapier app definition', () => { it('exposes custom auth, one trigger, and one action', () => { assert.equal(App.authentication.type, 'custom'); assert.ok(App.triggers.new_item); assert.ok(App.creates.store_data); assert.equal(App.beforeRequest.length, 1); }); }); describe('store_data perform', () => { it('posts metadata JSON to /v1/storage', async () => { const requests = []; const z = { request: async (opts) => { requests.push(opts); return { data: { id: 'item_1' } }; }, }; const bundle = { authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' }, inputData: { title: 'report', note: 'hello' }, }; const result = await storeData.operation.perform(z, bundle); assert.equal(result.id, 'item_1'); assert.equal(requests.length, 1); assert.equal(requests[0].method, 'POST'); assert.equal(requests[0].url, 'http://localhost:3000/v1/storage'); }); it('downloads the mapped file first, then posts it as an attachment', async () => { const requests = []; const z = { request: async (opts) => { requests.push(opts); return opts.raw ? { body: Buffer.from('x') } : { data: { id: 'item_2' } }; }, }; const bundle = { authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' }, inputData: { title: 'with file', file: 'https://example.com/f.pdf', filename: 'f.pdf' }, }; await storeData.operation.perform(z, bundle); assert.equal(requests.length, 2); assert.equal(requests[0].url, 'https://example.com/f.pdf'); assert.equal(requests[0].raw, true); assert.equal(requests[1].url, 'http://localhost:3000/v1/storage'); assert.equal(requests[1].method, 'POST'); }); }); ``` - [ ] **Step 4: Run tests to verify they fail** Run: `cd /Users/marchon/zappier/zapier-app && npm test` Expected: FAIL — `Cannot find module '../index'`. - [ ] **Step 5: Implement the Zapier app** Create `zapier-app/authentication.js`: ```js module.exports = { type: 'custom', test: (z, bundle) => z.request({ url: `${bundle.authData.baseUrl}/v1/status` }).then((r) => r.data), fields: [ { key: 'baseUrl', label: 'API Base URL', type: 'string', required: true, default: 'http://localhost:3000', helpText: 'Where your Zappier API is running.', }, { key: 'apiKey', label: 'API Key', type: 'password', required: true, helpText: 'Your Zappier customer API key.', }, ], connectionLabel: '{{bundle.authData.baseUrl}}', }; ``` Create `zapier-app/triggers/new_item.js`: ```js const perform = async (z, bundle) => { const response = await z.request({ url: `${bundle.authData.baseUrl}/v1/storage` }); return response.data.items; }; module.exports = { key: 'new_item', noun: 'Stored Item', display: { label: 'New Stored Item', description: 'Triggers when a new item is stored through the Zappier API.', }, operation: { type: 'polling', perform, sample: { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', customerId: 'cust_1', metadata: { title: 'example' }, attachments: [], createdAt: '2026-07-27T10:00:00.000Z', }, }, }; ``` Create `zapier-app/creates/store_data.js`: ```js const FormData = require('form-data'); const perform = async (z, bundle) => { const form = new FormData(); form.append( 'metadata', JSON.stringify({ title: bundle.inputData.title, note: bundle.inputData.note }), ); if (bundle.inputData.file) { const fileResponse = await z.request({ url: bundle.inputData.file, raw: true, redirect: 'follow', }); form.append('attachments', fileResponse.body, { filename: bundle.inputData.filename || 'attachment.bin', }); } const response = await z.request({ url: `${bundle.authData.baseUrl}/v1/storage`, method: 'POST', body: form, headers: form.getHeaders(), }); return response.data; }; module.exports = { key: 'store_data', noun: 'Stored Item', display: { label: 'Store Data', description: 'Stores metadata and an optional file attachment. Priced per call plus metadata KB and attachment MB on your plan.', }, operation: { inputFields: [ { key: 'title', label: 'Title', type: 'string', required: true }, { key: 'note', label: 'Note', type: 'text', required: false }, { key: 'file', label: 'Attachment', type: 'file', required: false, helpText: 'Optional file. Attachment size is billed per MB on your plan.', }, { key: 'filename', label: 'Filename', type: 'string', required: false }, ], perform, sample: { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }, }, }; ``` Create `zapier-app/index.js`: ```js const authentication = require('./authentication'); const newItem = require('./triggers/new_item'); const storeData = require('./creates/store_data'); const addApiKeyHeader = (request, z, bundle) => { request.headers = request.headers || {}; request.headers['x-api-key'] = bundle.authData.apiKey; return request; }; module.exports = { version: require('./package.json').version, platformVersion: require('zapier-platform-core').version, authentication, beforeRequest: [addApiKeyHeader], triggers: { [newItem.key]: newItem }, creates: { [storeData.key]: storeData }, }; ``` - [ ] **Step 6: Run tests to verify they pass** Run: `cd /Users/marchon/zappier/zapier-app && npm test` Expected: PASS — 3 tests. - [ ] **Step 7: End-to-end check against the live API** 1. Start the API: `cd /Users/marchon/zappier && npm run dev`. 2. In `zapier-app/`, run `zapier test` (validates against the platform core too). 3. Manually verify with curl that `POST /v1/storage` accepts a multipart request matching the action's shape. Expected: tests pass; a stored item appears in `GET /v1/storage`. Stop the server afterwards. - [ ] **Step 8: Push to Zapier and build a test Zap** ```bash cd /Users/marchon/zappier/zapier-app zapier push ``` Expected: `Push successful`; the app appears at https://developer.zapier.com. Then in https://zapier.com create a private Zap: trigger **New Stored Item**, connect with `baseUrl` + `key-ada`, and add the **Store Data** action in a second Zap. Turn them on and confirm runs in the Zap history. - [ ] **Step 9: Commit** ```bash cd /Users/marchon/zappier git add zapier-app git commit -m "feat: add Zapier integration with auth, polling trigger, and store action" ``` --- ## Self-review notes - **Spec coverage:** Swagger/OpenAPI API definitions → Task 5 (`openapi.yaml` source of truth, request validation, Swagger UI at `/docs`; rate card keys = spec `operationId`s). Web-UI pricing per plan/customer type → Task 8 (rate card, tier, and customer editors) backed by Task 7's `SqlitePricingStore`; live (no-restart) repricing proven by admin tests. Many API calls → rate card keyed by `operationId` + per-tier default rule for unpriced endpoints + 403 for tiers without a default. Many customer types → tier CRUD in admin UI (config-only). Per-customer pricing → `multiplierOverride` (Tasks 1, 3, 4, 8). Free calls → `status` / `storage-list` free rules at 0 on all tiers. Fixed-price calls → `transform`. Variable size-based pricing for metadata + file attachments → `storage` rule and Tasks 4–5 size metering. Free quotas → Task 6 monthly credits. Billing collection → Task 9 Stripe meters (credit-adjusted). Zapier availability + account → Task 10. Persistence → Task 7 SQLite. - **Placeholder scan:** no TBDs; every code step has full code, including the entire admin UI; manual steps (Stripe/Zapier account creation) have exact URLs and expected outcomes. - **Type consistency:** `quoteCall(pricing, tierId, endpointId, usage, multiplierOverride?)` / `Quote` / `PricingContext` / `PricingStore` / `InMemoryPricingStore` (Task 1) used unchanged in Tasks 4–9; `UsageEntry` / `UsageRepo` / `summarize` (Task 2) used unchanged in Tasks 4, 7, 9; `Customer` (with `tierId`, `multiplierOverride`) / `CustomerRepo` (with `save`) (Task 3) used in Tasks 4, 5, 7, 8, 9; `meter(endpointId, repo, pricing)` (Task 4) used unchanged in Task 5; `AppDeps` / `DEFAULT_CUSTOMERS` / `buildApp(deps)` / live `pricing` context (Task 5) consumed by Tasks 6–9; `applyMonthlyCredit` / `BilledSummary` (Task 6) matches the credit semantics inside `reportUsage` (Task 9); `SqliteUsageRepo` / `SqliteCustomerRepo` / `SqlitePricingStore` (Task 7) are exactly the `UsageRepo` / `CustomerRepo` / `PricingStore` implementations `buildApp` and the Stripe job expect; `adminAuth` / `adminRouter` (Task 8) mounted in Task 5's `buildApp`; endpoint ids identical in `openapi.yaml` `operationId`s, rate card, routes, and Zapier app URLs; seeded test math consistent across suites (transform = 4/2/1 cents by tier; storage example = 112 list cents; admin reprice 4 → 10).