100 KiB
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 loadsopenapi.yamlandadmin/viaprocess.cwd()). openapi.yamlis the API source of truth. Rate-card endpoint ids must match specoperationIds 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-keyheader (envADMIN_KEY, dev defaultadmin-dev-key). Never commit a real key. - Customer-facing endpoints require the
x-api-keyheader. - 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 theZAPPIER_DBenv 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<string, PriceRule> }export interface TierCatalog { find(id: string): TierConfig | undefined; list(): TierConfig[] }andexport class ConfigTierCatalog implements TierCatalog(constructor takesTierConfig[])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— throwsError("Unknown tier: <id>")orError("No price rule for <tierId>/<endpointId>").multiplierOverride(per-customer) beats the tier multiplier.
-
Step 1: Scaffold the project
Create package.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:
{
"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:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/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:
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:
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<string, PriceRule>;
}
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<string, PriceRule>;
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:
## 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
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<string, { calls: number; cents: number }> }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:
import { InMemoryUsageRepo, UsageEntry } from '../src/usage';
const entry = (over: Partial<UsageEntry> = {}): 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:
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<string, { calls: number; cents: number }>;
}
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
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
tierIdstring resolved byPricingContext.tiersat 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 }—saveupserts byid(used by the admin API in Task 8).export class InMemoryCustomerRepo implements CustomerRepo— constructor takesCustomer[].export function apiKeyAuth(repo: CustomerRepo): RequestHandler— readsx-api-keyheader, setsreq.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:
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:
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
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_PRICINGfromsrc/pricing.ts;UsageRepofromsrc/usage.ts;req.customer(withtierIdandmultiplierOverride) fromsrc/auth.ts. -
Produces:
export function meter(endpointId: string, repo: UsageRepo, pricing: PricingContext): RequestHandler— computesmetadataBytesfromreq.body.metadata(byte length of its JSON form,{}when absent) andattachmentBytesfromreq.files(Multer-shaped array provided by express-openapi-validator, 0 when absent); quotes viaquoteCall(pricing, req.customer.tierId, endpointId, usage, req.customer.multiplierOverride); on a pricing error responds 403 with the error message and records nothing; otherwise records aUsageEntrywithcents = quote.totalCentsand setsres.locals.quote. Must run afterapiKeyAuthand after the OpenAPI validator (which parses multipart bodies). -
Step 1: Write the failing tests
Create tests/meter.test.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:
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
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;operationIds:status,transform,storage,storage-list,usage(first four are rate-card keys).export interface StoredItem { id: string; customerId: string; metadata: Record<string, unknown>; 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[] }—pricingis a live context whose getters read the store on every call, so admin edits (Task 8) take effect immediately.GET /docs— Swagger UI renderingopenapi.yaml.
-
Step 1: Write the OpenAPI spec
Create openapi.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:
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:
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<string, unknown>;
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<string, unknown>);
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):
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:
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
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/usageroute) - Modify:
tests/app.test.ts(usage-summary test)
Interfaces:
-
Consumes:
UsageSummary(Task 2);TierConfig,PricingContext(Task 1);buildApp'spricingandusage(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/usageroute here and by the Stripe job in Task 9.
-
Step 1: Write the failing tests
Create tests/credit.test.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:
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:
import { applyMonthlyCredit } from './billing/credit';
Replace the /v1/usage route with:
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:
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
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 tableusage_entrieson first use.export class SqliteCustomerRepo implements CustomerRepo—constructor(db: Database.Database, seed?: Customer[]); creates tablecustomers(withmultiplier_override) and insertsseedonly when the table is empty. Implementssaveas SQL upsert.export class SqlitePricingStore implements PricingStore—constructor(db: Database.Database, seedCard?: RateCard, seedTiers?: TierConfig[]); creates tablesprice_endpoints+tiersand seeds them only when empty.- All three are injected into
buildApp({ usage, customers, pricingStore })fromsrc/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:
import Database from 'better-sqlite3';
import { SqliteUsageRepo } from '../src/db/usage-repo';
import { UsageEntry } from '../src/usage';
const entry = (over: Partial<UsageEntry> = {}): 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:
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:
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:
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<string, unknown>[];
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:
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<string, unknown>
| 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<string, unknown>): 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:
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<string, unknown>[];
const endpoints: Record<string, PriceRule> = {};
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<string, unknown>[];
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:
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
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
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— requiresx-admin-key=process.env.ADMIN_KEY ?? 'admin-dev-key', else 403.export function adminRouter(store: PricingStore, customers: CustomerRepo): Routerwith routes:GET /pricing→{ rateCard, tiers }PUT /endpoints/:idbodyPriceRule→ upsert (400 on invalid rule)DELETE /endpoints/:idPUT /tiers/:idbodyTierConfig→ upsert (400 on invalid config)DELETE /tiers/:idGET /customers→{ customers }(apiKey masked)POST /customersbody{ name, tierId }→ 201 with the created customer including its generatedapiKey(shown once)PUT /customers/:idbody partial{ name?, tierId?, multiplierOverride?, stripeCustomerId? }→ update (404 on unknown id)
buildAppmounts these at/admin/api(behindadminAuth) and serves the static UI at/admin.
-
Step 1: Write the failing tests
Create tests/admin.test.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:
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<string, unknown>;
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<string, unknown>;
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:
import { adminAuth, adminRouter } from './admin';
Add these two lines immediately after app.use('/docs', ...) (before the /v1 auth middleware):
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:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zappier Pricing Admin</title>
<style>
body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 2rem; color: #222; }
nav button { margin-right: 0.5rem; padding: 0.4rem 0.9rem; }
nav button.active { font-weight: 700; text-decoration: underline; }
table { border-collapse: collapse; margin-top: 1rem; }
th, td { border: 1px solid #ccc; padding: 0.35rem 0.6rem; text-align: left; }
input, select { padding: 0.25rem; }
.row-actions button { margin-right: 0.25rem; }
#status { margin-top: 1rem; color: #060; }
</style>
</head>
<body>
<h1>Zappier Pricing Admin</h1>
<nav>
<button data-tab="endpoints" class="active">Rate card</button>
<button data-tab="tiers">Customer types</button>
<button data-tab="customers">Customers</button>
</nav>
<section id="endpoints"></section>
<section id="tiers" hidden></section>
<section id="customers" hidden></section>
<p id="status"></p>
<script src="app.js"></script>
</body>
</html>
Create admin/app.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} <input data-endpoint="${id}" data-field="${k}" type="number" step="any" value="${v}" size="6">`,
)
.join(' ');
}
function renderEndpoints() {
const rows = Object.entries(state.pricing.rateCard.endpoints)
.map(
([id, rule]) => `<tr>
<td>${id}</td>
<td><select data-endpoint-kind="${id}">
${['free', 'fixed', 'variable'].map((k) => `<option ${k === rule.kind ? 'selected' : ''}>${k}</option>`).join('')}
</select></td>
<td>${ruleInputs(id, rule)}</td>
<td class="row-actions">
<button onclick="saveEndpoint('${id}')">Save</button>
<button onclick="deleteEndpoint('${id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('endpoints').innerHTML = `
<h2>Rate card</h2>
<table><tr><th>Endpoint (operationId)</th><th>Kind</th><th>Prices (cents)</th><th></th></tr>${rows}</table>
<h3>Add endpoint</h3>
<input id="new-endpoint-id" placeholder="operationId">
<select id="new-endpoint-kind"><option>free</option><option selected>fixed</option><option>variable</option></select>
<button onclick="addEndpoint()">Add</button>`;
}
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) => `<tr>
<td>${t.id}</td>
<td><input data-tier="${t.id}" data-field="name" value="${t.name}"></td>
<td><input data-tier="${t.id}" data-field="multiplier" type="number" step="any" value="${t.multiplier}" size="5"></td>
<td><input data-tier="${t.id}" data-field="monthlyCreditCents" type="number" value="${t.monthlyCreditCents}" size="8"></td>
<td class="row-actions">
<button onclick="saveTier('${t.id}')">Save</button>
<button onclick="deleteTier('${t.id}')">Delete</button>
</td>
</tr>`,
)
.join('');
document.getElementById('tiers').innerHTML = `
<h2>Customer types</h2>
<table><tr><th>Id</th><th>Name</th><th>Multiplier</th><th>Monthly credit (cents)</th><th></th></tr>${rows}</table>
<h3>Add customer type</h3>
<input id="new-tier-id" placeholder="id">
<input id="new-tier-name" placeholder="name">
<input id="new-tier-multiplier" type="number" step="any" value="1" size="5"> multiplier
<button onclick="addTier()">Add</button>`;
}
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) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
.join('');
const rows = state.customers
.map(
(c) => `<tr>
<td>${c.id}</td>
<td>${c.name}</td>
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
<td><button onclick="saveCustomer('${c.id}')">Save</button></td>
</tr>`,
)
.join('');
document.getElementById('customers').innerHTML = `
<h2>Customers</h2>
<table><tr><th>Id</th><th>Name</th><th>Type</th><th>Multiplier override</th><th></th></tr>${rows}</table>
<h3>Add customer</h3>
<input id="new-customer-name" placeholder="name">
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
<button onclick="addCustomer()">Create</button>
<p>A new customer's API key is shown once in the status line below.</p>`;
}
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)
npm run dev, open http://localhost:3000/admin, enteradmin-dev-key.- In Rate card, change
transformtofixedCents: 10, Save. curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' -d '{"text":"hi"}' http://localhost:3000/v1/transform→ quote showstotalCents: 10(no restart).- In Customer types, add
eduat 0.5×; in Customers, create a customer oneduand call the API with its new key. - Stop the server. Delete the smoke-test database:
rm -f zappier.db.
- Step 8: Commit
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<void> }export async function reportUsage(client: MeterEventClient, stripeCustomerId: string, entries: UsageEntry[], monthlyCreditCents: number): Promise<number>— 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)
- Sign up / log in at https://dashboard.stripe.com (test mode is fine).
- Create a product per customer type:
Zappier Free,Zappier Pro,Zappier Business. - Under Billing → Meters, create a meter named
zappier.api_cents, aggregation = Sum ofvalue. - Add a metered price to each tier's subscription using that meter.
- 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:
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:
import { UsageEntry } from '../usage';
export const METER_EVENT_NAME = 'zappier.api_cents';
export interface MeterEventClient {
createMeterEvent(params: {
eventName: string;
customerId: string;
value: string;
}): Promise<void>;
}
export async function reportUsage(
client: MeterEventClient,
stripeCustomerId: string,
entries: UsageEntry[],
monthlyCreditCents: number,
): Promise<number> {
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):
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<void> {
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
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) withx-api-keyauth. 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
zappierwith:- Custom auth fields:
baseUrl(defaulthttp://localhost:3000),apiKey(password). - Trigger
new_item(polling): returnsGET {baseUrl}/v1/storage→itemsarray. - Action
store_data: POSTs multipartmetadata(JSON string of{ title, note }) + optionalattachmentsfile to{baseUrl}/v1/storage. bundle.authData.apiKeyis injected as thex-api-keyheader on every request via abeforeRequest.
- Custom auth fields:
-
Step 1: Create the Zapier account (manual)
- Sign up at https://zapier.com (free plan is enough to build).
- Open https://developer.zapier.com and accept the developer terms.
- Install the CLI and log in:
npm install -g zapier-platform-cli
zapier login
Expected: browser OAuth completes; zapier apps runs without auth errors.
- Step 2: Scaffold the app package
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:
{
"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:
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:
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:
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:
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:
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
- Start the API:
cd /Users/marchon/zappier && npm run dev. - In
zapier-app/, runzapier test(validates against the platform core too). - Manually verify with curl that
POST /v1/storageaccepts 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
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
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.yamlsource of truth, request validation, Swagger UI at/docs; rate card keys = specoperationIds). Web-UI pricing per plan/customer type → Task 8 (rate card, tier, and customer editors) backed by Task 7'sSqlitePricingStore; live (no-restart) repricing proven by admin tests. Many API calls → rate card keyed byoperationId+ 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-listfree rules at 0 on all tiers. Fixed-price calls →transform. Variable size-based pricing for metadata + file attachments →storagerule 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(withtierId,multiplierOverride) /CustomerRepo(withsave) (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)/ livepricingcontext (Task 5) consumed by Tasks 6–9;applyMonthlyCredit/BilledSummary(Task 6) matches the credit semantics insidereportUsage(Task 9);SqliteUsageRepo/SqliteCustomerRepo/SqlitePricingStore(Task 7) are exactly theUsageRepo/CustomerRepo/PricingStoreimplementationsbuildAppand the Stripe job expect;adminAuth/adminRouter(Task 8) mounted in Task 5'sbuildApp; endpoint ids identical inopenapi.yamloperationIds, 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).