zappier-edge/docs/superpowers/plans/2026-07-27-api-pricing-zapier-launch.md

2981 lines
100 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, PriceRule> }`
- `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: <id>")` or `Error("No price rule for <tierId>/<endpointId>")`. `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: ['<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`:
```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<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:
```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<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`:
```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`:
```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**
```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<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[] }``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<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):
```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> = {}): 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<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`:
```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`:
```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:
```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<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:
```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
<!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`:
```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)**
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<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)**
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<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`):
```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**
```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 19 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 45 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 49; `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 69; `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).