master-zapier-plan-draft/packages/zappier/docs/superpowers/plans/2026-07-27-api-pricing-zapier-launch.md
George Lambert b4150c8250 Milestone 0: import zappier billing, Verae middleware, and Zapier research
Compose-ready workspace: packages/zappier (rate card, portal, Stripe),
packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier
(CLI app), vendor/zapier-platform, and research/zapier vendor corpus.

Gate 0 structure checks pass. Product code and research are not yet wired.
2026-09-09 02:37:36 -04:00

100 KiB
Raw Blame History

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 operationIds 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:

{
  "name": "zappier",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.19.2",
    "express-openapi-validator": "^5.3.0",
    "stripe": "^16.0.0",
    "swagger-ui-express": "^5.0.0",
    "yamljs": "^0.3.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/jest": "^29.5.12",
    "@types/multer": "^1.4.11",
    "@types/node": "^20.14.0",
    "@types/supertest": "^6.0.2",
    "@types/swagger-ui-express": "^4.1.6",
    "@types/yamljs": "^0.2.34",
    "jest": "^29.7.0",
    "supertest": "^7.0.0",
    "ts-jest": "^29.1.4",
    "ts-node": "^10.9.2",
    "typescript": "^5.5.0"
  }
}

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node", "jest", "multer"]
  },
  "include": ["src"]
}

Create jest.config.js:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/tests'],
};

Run: cd /Users/marchon/zappier && npm install Expected: installs cleanly, node_modules/ exists.

  • Step 2: Write the failing pricing tests

Create tests/pricing.test.ts:

import {
  ConfigTierCatalog,
  DEFAULT_PRICING,
  DEFAULT_TIERS,
  InMemoryPricingStore,
  quoteCall,
} from '../src/pricing';

const noUsage = { metadataBytes: 0, attachmentBytes: 0 };

describe('quoteCall', () => {
  it('prices free endpoints at 0 on every tier, ignoring the multiplier', () => {
    for (const tier of DEFAULT_TIERS) {
      expect(quoteCall(DEFAULT_PRICING, tier.id, 'status', noUsage).totalCents).toBe(0);
      expect(quoteCall(DEFAULT_PRICING, tier.id, 'storage-list', noUsage).totalCents).toBe(0);
    }
  });

  it('applies the tier multiplier to fixed list prices', () => {
    // transform list price: 4 cents
    expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage).totalCents).toBe(4);
    expect(quoteCall(DEFAULT_PRICING, 'pro', 'transform', noUsage).totalCents).toBe(2);
    expect(quoteCall(DEFAULT_PRICING, 'business', 'transform', noUsage).totalCents).toBe(1);
  });

  it('prices storage as base + per-KB metadata + per-MB attachments at list rates', () => {
    const usage = { metadataBytes: 2048, attachmentBytes: 2 * 1024 * 1024 };
    // list: 10 + 2 * 1 + 2 * 50 = 112
    expect(quoteCall(DEFAULT_PRICING, 'free', 'storage', usage).totalCents).toBe(112);
    expect(quoteCall(DEFAULT_PRICING, 'pro', 'storage', usage).totalCents).toBe(56);
    expect(quoteCall(DEFAULT_PRICING, 'business', 'storage', usage).totalCents).toBe(28);
  });

  it('rounds partial KB and MB up before applying the multiplier', () => {
    const q = quoteCall(DEFAULT_PRICING, 'free', 'storage', {
      metadataBytes: 1,
      attachmentBytes: 1,
    });
    // list: 10 + 1 KB * 1 + 1 MB * 50 = 61
    expect(q.listCents).toBe(61);
    expect(q.totalCents).toBe(61);
  });

  it('exposes the list-price breakdown and the multiplied total', () => {
    const q = quoteCall(DEFAULT_PRICING, 'pro', 'storage', {
      metadataBytes: 1024,
      attachmentBytes: 0,
    });
    expect(q.breakdown).toEqual({ baseCents: 10, metadataCents: 1, attachmentCents: 0 });
    expect(q.listCents).toBe(11);
    expect(q.totalCents).toBe(6); // Math.round(11 * 0.5)
  });

  it('lets a per-customer multiplier override beat the tier multiplier', () => {
    expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage, 0.5).totalCents).toBe(2);
    expect(quoteCall(DEFAULT_PRICING, 'free', 'status', noUsage, 0.5).totalCents).toBe(0);
  });

  it('falls back to the tier default rule for endpoints not on the rate card', () => {
    // pro default rule: fixed 8 list -> round(8 * 0.5) = 4
    expect(quoteCall(DEFAULT_PRICING, 'pro', 'experimental', noUsage).totalCents).toBe(4);
  });

  it('throws for an endpoint with no rate-card entry and no tier default', () => {
    expect(() => quoteCall(DEFAULT_PRICING, 'free', 'experimental', noUsage)).toThrow(
      'No price rule',
    );
  });

  it('throws for an unknown tier', () => {
    expect(() => quoteCall(DEFAULT_PRICING, 'platinum', 'status', noUsage)).toThrow(
      'Unknown tier',
    );
  });
});

describe('ConfigTierCatalog', () => {
  it('finds tiers by id and lists them', () => {
    const catalog = new ConfigTierCatalog(DEFAULT_TIERS);
    expect(catalog.find('pro')?.multiplier).toBe(0.5);
    expect(catalog.find('nope')).toBeUndefined();
    expect(catalog.list().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
  });

  it('supports adding a customer type as pure config', () => {
    const catalog = new ConfigTierCatalog([
      ...DEFAULT_TIERS,
      { id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 },
    ]);
    expect(catalog.find('edu')?.name).toBe('Education');
  });
});

describe('InMemoryPricingStore', () => {
  it('seeds from the default rate card and tiers', () => {
    const store = new InMemoryPricingStore();
    expect(store.getRateCard().endpoints.status).toEqual({ kind: 'free' });
    expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
  });

  it('upserts and deletes endpoints', () => {
    const store = new InMemoryPricingStore();
    store.upsertEndpoint('experimental', { kind: 'fixed', fixedCents: 9 });
    expect(store.getRateCard().endpoints.experimental).toEqual({
      kind: 'fixed',
      fixedCents: 9,
    });
    store.deleteEndpoint('experimental');
    expect(store.getRateCard().endpoints.experimental).toBeUndefined();
  });

  it('upserts and deletes tiers', () => {
    const store = new InMemoryPricingStore();
    store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 });
    expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.4);
    store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 });
    expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1);
    store.deleteTier('edu');
    expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined();
  });
});
  • Step 3: Run tests to verify they fail

Run: npm test Expected: FAIL — Cannot find module '../src/pricing'.

  • Step 4: Implement the pricing engine and stores

Create src/pricing.ts:

export type PriceRule =
  | { kind: 'free' }
  | { kind: 'fixed'; fixedCents: number }
  | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number };

export interface TierConfig {
  id: string;
  name: string;
  multiplier: number;
  monthlyCreditCents: number;
  defaultRule?: PriceRule;
}

export interface RateCard {
  endpoints: Record<string, PriceRule>;
}

export interface TierCatalog {
  find(id: string): TierConfig | undefined;
  list(): TierConfig[];
}

export class ConfigTierCatalog implements TierCatalog {
  constructor(private tiers: TierConfig[]) {}

  find(id: string): TierConfig | undefined {
    return this.tiers.find((t) => t.id === id);
  }

  list(): TierConfig[] {
    return [...this.tiers];
  }
}

export interface CallUsage {
  metadataBytes: number;
  attachmentBytes: number;
}

export interface Quote {
  endpointId: string;
  listCents: number;
  totalCents: number;
  breakdown: { baseCents: number; metadataCents: number; attachmentCents: number };
}

export interface PricingContext {
  rateCard: RateCard;
  tiers: TierCatalog;
}

export interface PricingStore {
  getRateCard(): RateCard;
  getTiers(): TierConfig[];
  upsertEndpoint(endpointId: string, rule: PriceRule): void;
  deleteEndpoint(endpointId: string): void;
  upsertTier(tier: TierConfig): void;
  deleteTier(tierId: string): void;
}

export const DEFAULT_RATE_CARD: RateCard = {
  endpoints: {
    status: { kind: 'free' },
    'storage-list': { kind: 'free' },
    transform: { kind: 'fixed', fixedCents: 4 },
    storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 },
  },
};

export const DEFAULT_TIERS: TierConfig[] = [
  { id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 },
  {
    id: 'pro',
    name: 'Pro',
    multiplier: 0.5,
    monthlyCreditCents: 1000,
    defaultRule: { kind: 'fixed', fixedCents: 8 },
  },
  {
    id: 'business',
    name: 'Business',
    multiplier: 0.25,
    monthlyCreditCents: 10000,
    defaultRule: { kind: 'fixed', fixedCents: 8 },
  },
];

export const DEFAULT_PRICING: PricingContext = {
  rateCard: DEFAULT_RATE_CARD,
  tiers: new ConfigTierCatalog(DEFAULT_TIERS),
};

export class InMemoryPricingStore implements PricingStore {
  private endpoints: Record<string, PriceRule>;
  private tiers: TierConfig[];

  constructor(rateCard: RateCard = DEFAULT_RATE_CARD, tiers: TierConfig[] = DEFAULT_TIERS) {
    this.endpoints = { ...rateCard.endpoints };
    this.tiers = [...tiers];
  }

  getRateCard(): RateCard {
    return { endpoints: { ...this.endpoints } };
  }

  getTiers(): TierConfig[] {
    return [...this.tiers];
  }

  upsertEndpoint(endpointId: string, rule: PriceRule): void {
    this.endpoints[endpointId] = rule;
  }

  deleteEndpoint(endpointId: string): void {
    delete this.endpoints[endpointId];
  }

  upsertTier(tier: TierConfig): void {
    const i = this.tiers.findIndex((t) => t.id === tier.id);
    if (i >= 0) this.tiers[i] = tier;
    else this.tiers.push(tier);
  }

  deleteTier(tierId: string): void {
    this.tiers = this.tiers.filter((t) => t.id !== tierId);
  }
}

export function quoteCall(
  pricing: PricingContext,
  tierId: string,
  endpointId: string,
  usage: CallUsage,
  multiplierOverride?: number,
): Quote {
  const tier = pricing.tiers.find(tierId);
  if (!tier) throw new Error(`Unknown tier: ${tierId}`);
  const rule = pricing.rateCard.endpoints[endpointId] ?? tier.defaultRule;
  if (!rule) throw new Error(`No price rule for ${tierId}/${endpointId}`);

  if (rule.kind === 'free') {
    return {
      endpointId,
      listCents: 0,
      totalCents: 0,
      breakdown: { baseCents: 0, metadataCents: 0, attachmentCents: 0 },
    };
  }

  let breakdown: Quote['breakdown'];
  if (rule.kind === 'fixed') {
    breakdown = { baseCents: rule.fixedCents, metadataCents: 0, attachmentCents: 0 };
  } else {
    breakdown = {
      baseCents: rule.baseCents,
      metadataCents: Math.ceil(usage.metadataBytes / 1024) * rule.perKbCents,
      attachmentCents:
        Math.ceil(usage.attachmentBytes / (1024 * 1024)) * rule.perMbCents,
    };
  }
  const listCents = breakdown.baseCents + breakdown.metadataCents + breakdown.attachmentCents;
  const multiplier = multiplierOverride ?? tier.multiplier;
  return {
    endpointId,
    listCents,
    totalCents: Math.round(listCents * multiplier),
    breakdown,
  };
}
  • Step 5: Run tests to verify they pass

Run: npm test Expected: PASS — 14 tests in tests/pricing.test.ts.

  • Step 6: Document the pricing model in the README

Replace the body of README.md below the title with:

## Pricing model

`openapi.yaml` defines the API surface; each `operationId` is a rate-card key.
Endpoints carry **list prices** (seed: `src/pricing.ts``DEFAULT_RATE_CARD`).
Customer types are **tier configs** (`DEFAULT_TIERS`) with a `multiplier`, a
`monthlyCreditCents` quota, and an optional `defaultRule` for endpoints not on the card.
Individual customers can carry a `multiplierOverride`.
Billed price = `round(list price × multiplier)`; usage up to the monthly credit is free.
Pricing is editable at runtime via the admin UI at `/admin` (see Task 8).

### Seed rate card (list prices, cents per call)

| Endpoint       | Model    | List price                                   |
| -------------- | -------- | -------------------------------------------- |
| `status`       | free     | 0                                            |
| `storage-list` | free     | 0                                            |
| `transform`    | fixed    | 4                                            |
| `storage`      | variable | 10 + 1 per KB metadata + 50 per MB attached  |

### Seed customer types

| Tier       | Multiplier | Monthly credit | Default rule (unlisted endpoints) |
| ---------- | ---------- | -------------- | --------------------------------- |
| `free`     | 1.0        | 100 cents      | none — call rejected with 403     |
| `pro`      | 0.5        | 1000 cents     | fixed 8 list → 4 billed           |
| `business` | 0.25       | 10000 cents    | fixed 8 list → 2 billed           |

Adding a new API call = add it to `openapi.yaml`, then price it in the admin UI.
Adding a customer type = create it in the admin UI. Variable pricing = base per call +
metadata size (rounded up to KB) + attachment size (rounded up to MB), then the multiplier.
  • Step 7: Commit
cd /Users/marchon/zappier && git init -q 2>/dev/null; git add -A
git commit -m "feat: scaffold project with rate-card pricing engine and pricing stores"

Task 2: Usage log

Files:

  • Create: src/usage.ts
  • Test: tests/usage.test.ts

Interfaces:

  • Consumes: nothing from Task 1 (independent module).

  • Produces:

    • export interface UsageEntry { customerId: string; endpointId: string; cents: number; metadataBytes: number; attachmentBytes: number; timestamp: Date }
    • export interface UsageSummary { customerId: string; totalCents: number; calls: number; byEndpoint: Record<string, { calls: number; cents: number }> }
    • export function summarize(customerId: string, list: UsageEntry[]): UsageSummary — shared aggregation helper (used again by the SQLite repo in Task 7).
    • export interface UsageRepo { record(entry: UsageEntry): void; listFor(customerId: string, since?: Date): UsageEntry[]; summaryFor(customerId: string, since?: Date): UsageSummary }
    • export class InMemoryUsageRepo implements UsageRepo
  • Step 1: Write the failing tests

Create tests/usage.test.ts:

import { InMemoryUsageRepo, UsageEntry } from '../src/usage';

const entry = (over: Partial<UsageEntry> = {}): UsageEntry => ({
  customerId: 'cust_1',
  endpointId: 'transform',
  cents: 4,
  metadataBytes: 0,
  attachmentBytes: 0,
  timestamp: new Date('2026-07-27T10:00:00Z'),
  ...over,
});

describe('InMemoryUsageRepo', () => {
  it('records and lists entries per customer', () => {
    const repo = new InMemoryUsageRepo();
    repo.record(entry());
    repo.record(entry({ customerId: 'cust_2' }));
    expect(repo.listFor('cust_1')).toHaveLength(1);
    expect(repo.listFor('cust_2')).toHaveLength(1);
  });

  it('filters entries by since date', () => {
    const repo = new InMemoryUsageRepo();
    repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') }));
    repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') }));
    expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1);
  });

  it('summarizes totals by endpoint', () => {
    const repo = new InMemoryUsageRepo();
    repo.record(entry());
    repo.record(entry({ endpointId: 'storage', cents: 112 }));
    repo.record(entry());
    const s = repo.summaryFor('cust_1');
    expect(s.customerId).toBe('cust_1');
    expect(s.calls).toBe(3);
    expect(s.totalCents).toBe(120);
    expect(s.byEndpoint.transform).toEqual({ calls: 2, cents: 8 });
    expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 });
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- tests/usage.test.ts Expected: FAIL — Cannot find module '../src/usage'.

  • Step 3: Implement the usage repo

Create src/usage.ts:

export interface UsageEntry {
  customerId: string;
  endpointId: string;
  cents: number;
  metadataBytes: number;
  attachmentBytes: number;
  timestamp: Date;
}

export interface UsageSummary {
  customerId: string;
  totalCents: number;
  calls: number;
  byEndpoint: Record<string, { calls: number; cents: number }>;
}

export function summarize(customerId: string, list: UsageEntry[]): UsageSummary {
  const byEndpoint: UsageSummary['byEndpoint'] = {};
  for (const e of list) {
    const bucket = (byEndpoint[e.endpointId] ??= { calls: 0, cents: 0 });
    bucket.calls += 1;
    bucket.cents += e.cents;
  }
  return {
    customerId,
    calls: list.length,
    totalCents: list.reduce((sum, e) => sum + e.cents, 0),
    byEndpoint,
  };
}

export interface UsageRepo {
  record(entry: UsageEntry): void;
  listFor(customerId: string, since?: Date): UsageEntry[];
  summaryFor(customerId: string, since?: Date): UsageSummary;
}

export class InMemoryUsageRepo implements UsageRepo {
  private entries: UsageEntry[] = [];

  record(entry: UsageEntry): void {
    this.entries.push(entry);
  }

  listFor(customerId: string, since?: Date): UsageEntry[] {
    return this.entries.filter(
      (e) => e.customerId === customerId && (!since || e.timestamp >= since),
    );
  }

  summaryFor(customerId: string, since?: Date): UsageSummary {
    return summarize(customerId, this.listFor(customerId, since));
  }
}
  • Step 4: Run tests to verify they pass

Run: npm test -- tests/usage.test.ts Expected: PASS — 3 tests.

  • Step 5: Commit
git add src/usage.ts tests/usage.test.ts
git commit -m "feat: add usage log with shared summarizer and per-customer summaries"

Task 3: API-key auth + customer model

Files:

  • Create: src/auth.ts
  • Test: tests/auth.test.ts

Interfaces:

  • Consumes: nothing (a customer's tier is a plain 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:

import express from 'express';
import request from 'supertest';
import { apiKeyAuth, InMemoryCustomerRepo } from '../src/auth';

const repo = new InMemoryCustomerRepo([
  { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' },
  { id: 'cust_2', name: 'Grace', tierId: 'business', apiKey: 'key-grace', stripeCustomerId: 'cus_123' },
]);

const app = express();
app.use(apiKeyAuth(repo));
app.get('/ping', (req, res) =>
  res.json({ customerId: req.customer!.id, tierId: req.customer!.tierId }),
);

describe('apiKeyAuth', () => {
  it('rejects a missing key with 401', async () => {
    const res = await request(app).get('/ping');
    expect(res.status).toBe(401);
    expect(res.body.error).toMatch(/API key/);
  });

  it('rejects an unknown key with 401', async () => {
    const res = await request(app).get('/ping').set('x-api-key', 'wrong');
    expect(res.status).toBe(401);
  });

  it('attaches the customer for a valid key', async () => {
    const res = await request(app).get('/ping').set('x-api-key', 'key-ada');
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ customerId: 'cust_1', tierId: 'pro' });
  });
});

describe('InMemoryCustomerRepo', () => {
  it('lists all customers', () => {
    expect(repo.list().map((c) => c.id)).toEqual(['cust_1', 'cust_2']);
  });

  it('save() upserts by id', () => {
    const local = new InMemoryCustomerRepo([
      { id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' },
    ]);
    local.save({ id: 'cust_1', name: 'Ada', tierId: 'business', apiKey: 'key-ada', multiplierOverride: 0.4 });
    local.save({ id: 'cust_9', name: 'New', tierId: 'free', apiKey: 'key-new' });
    expect(local.list()).toHaveLength(2);
    const updated = local.findByApiKey('key-ada');
    expect(updated?.tierId).toBe('business');
    expect(updated?.multiplierOverride).toBe(0.4);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- tests/auth.test.ts Expected: FAIL — Cannot find module '../src/auth'.

  • Step 3: Implement auth

Create src/auth.ts:

import { RequestHandler } from 'express';

export interface Customer {
  id: string;
  name: string;
  tierId: string;
  apiKey: string;
  stripeCustomerId?: string;
  multiplierOverride?: number;
}

export interface CustomerRepo {
  findByApiKey(apiKey: string): Customer | undefined;
  list(): Customer[];
  save(customer: Customer): void;
}

export class InMemoryCustomerRepo implements CustomerRepo {
  constructor(private customers: Customer[] = []) {}

  findByApiKey(apiKey: string): Customer | undefined {
    return this.customers.find((c) => c.apiKey === apiKey);
  }

  list(): Customer[] {
    return [...this.customers];
  }

  save(customer: Customer): void {
    const i = this.customers.findIndex((c) => c.id === customer.id);
    if (i >= 0) this.customers[i] = customer;
    else this.customers.push(customer);
  }
}

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Express {
    interface Request {
      customer?: Customer;
    }
  }
}

export function apiKeyAuth(repo: CustomerRepo): RequestHandler {
  return (req, res, next) => {
    const key = req.header('x-api-key');
    const customer = key ? repo.findByApiKey(key) : undefined;
    if (!customer) {
      res.status(401).json({ error: 'invalid or missing API key' });
      return;
    }
    req.customer = customer;
    next();
  };
}
  • Step 4: Run tests to verify they pass

Run: npm test -- tests/auth.test.ts Expected: PASS — 5 tests.

  • Step 5: Commit
git add src/auth.ts tests/auth.test.ts
git commit -m "feat: add API-key auth and upsertable customer model with tier overrides"

Task 4: Metering middleware

Files:

  • Create: src/meter.ts
  • Test: tests/meter.test.ts

Interfaces:

  • Consumes: quoteCall, PricingContext, Quote, DEFAULT_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:

import express from 'express';
import request from 'supertest';
import { apiKeyAuth, Customer, InMemoryCustomerRepo } from '../src/auth';
import { meter } from '../src/meter';
import { DEFAULT_PRICING } from '../src/pricing';
import { InMemoryUsageRepo } from '../src/usage';

const FREE_ADA: Customer = { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' };

function buildApp(seed: Customer[] = [FREE_ADA]) {
  const customers = new InMemoryCustomerRepo(seed);
  const usage = new InMemoryUsageRepo();
  const app = express();
  app.use(express.json());
  app.use(apiKeyAuth(customers));
  app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) =>
    res.json({ quote: res.locals.quote }),
  );
  app.post('/storage', meter('storage', usage, DEFAULT_PRICING), (req, res) =>
    res.json({ quote: res.locals.quote }),
  );
  app.post('/experimental', meter('experimental', usage, DEFAULT_PRICING), (req, res) =>
    res.json({ quote: res.locals.quote }),
  );
  return { app, usage };
}

describe('meter middleware', () => {
  it('quotes a fixed endpoint at the multiplied tier price and records usage', async () => {
    const { app, usage } = buildApp();
    const res = await request(app)
      .post('/transform')
      .set('x-api-key', 'key-ada')
      .send({ text: 'hi' });
    expect(res.status).toBe(200);
    expect(res.body.quote.totalCents).toBe(4); // free tier, multiplier 1
    const entries = usage.listFor('cust_1');
    expect(entries).toHaveLength(1);
    expect(entries[0].endpointId).toBe('transform');
    expect(entries[0].cents).toBe(4);
  });

  it('charges per KB of metadata on variable endpoints', async () => {
    const { app } = buildApp();
    const res = await request(app)
      .post('/storage')
      .set('x-api-key', 'key-ada')
      .send({ metadata: { note: 'x'.repeat(2048) } });
    // JSON of metadata is 2059 bytes -> 3 KB -> list 10 + 3 * 1 = 13
    expect(res.body.quote.totalCents).toBe(13);
  });

  it('applies a per-customer multiplier override', async () => {
    const { app } = buildApp([
      { id: 'cust_vip', name: 'Vip', tierId: 'free', apiKey: 'key-vip', multiplierOverride: 0.5 },
    ]);
    const res = await request(app)
      .post('/transform')
      .set('x-api-key', 'key-vip')
      .send({ text: 'hi' });
    expect(res.body.quote.totalCents).toBe(2); // list 4 x override 0.5
  });

  it('returns 401 when no customer is attached', async () => {
    const usage = new InMemoryUsageRepo();
    const app = express();
    app.use(express.json());
    app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) =>
      res.json({}),
    );
    const res = await request(app).post('/transform').send({ text: 'hi' });
    expect(res.status).toBe(401);
    expect(usage.listFor('cust_1')).toHaveLength(0);
  });

  it('returns 403 for an endpoint with no price rule on the caller tier', async () => {
    const { app, usage } = buildApp();
    const res = await request(app)
      .post('/experimental')
      .set('x-api-key', 'key-ada')
      .send({});
    expect(res.status).toBe(403);
    expect(res.body.error).toMatch(/No price rule/);
    expect(usage.listFor('cust_1')).toHaveLength(0);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- tests/meter.test.ts Expected: FAIL — Cannot find module '../src/meter'.

  • Step 3: Implement the middleware

Create src/meter.ts:

import { RequestHandler } from 'express';
import { PricingContext, Quote, quoteCall } from './pricing';
import { UsageRepo } from './usage';

export function meter(
  endpointId: string,
  repo: UsageRepo,
  pricing: PricingContext,
): RequestHandler {
  return (req, res, next) => {
    const customer = req.customer;
    if (!customer) {
      res.status(401).json({ error: 'unauthenticated' });
      return;
    }
    const metadataBytes = Buffer.byteLength(
      JSON.stringify(req.body?.metadata ?? {}),
      'utf8',
    );
    const files = (req.files as Express.Multer.File[] | undefined) ?? [];
    const attachmentBytes = files.reduce((sum, f) => sum + f.size, 0);

    let quote: Quote;
    try {
      quote = quoteCall(
        pricing,
        customer.tierId,
        endpointId,
        { metadataBytes, attachmentBytes },
        customer.multiplierOverride,
      );
    } catch (err) {
      res.status(403).json({ error: (err as Error).message });
      return;
    }

    repo.record({
      customerId: customer.id,
      endpointId,
      cents: quote.totalCents,
      metadataBytes,
      attachmentBytes,
      timestamp: new Date(),
    });
    res.locals.quote = quote;
    next();
  };
}
  • Step 4: Run tests to verify they pass

Run: npm test -- tests/meter.test.ts Expected: PASS — 5 tests.

  • Step 5: Commit
git add src/meter.ts tests/meter.test.ts
git commit -m "feat: add metering middleware with tier overrides and 403 for unpriced calls"

Task 5: OpenAPI-driven HTTP API + docs

Files:

  • Create: openapi.yaml
  • Create: src/app.ts
  • Create: src/index.ts
  • Test: tests/app.test.ts

Interfaces:

  • Consumes: apiKeyAuth, InMemoryCustomerRepo, CustomerRepo, Customer (Task 3); meter (Task 4); UsageRepo, InMemoryUsageRepo (Task 2); PricingContext, PricingStore, InMemoryPricingStore, ConfigTierCatalog (Task 1).

  • Produces:

    • openapi.yaml — the API source of truth; operationIds: status, transform, storage, storage-list, usage (first four are rate-card keys).
    • export interface StoredItem { id: string; customerId: string; metadata: Record<string, unknown>; attachments: { filename: string; size: number }[]; createdAt: string }
    • export const DEFAULT_CUSTOMERS: Customer[]key-ada (free), key-grace (pro), key-linus (business).
    • export interface AppDeps { usage?: UsageRepo; customers?: CustomerRepo; pricingStore?: PricingStore }
    • export function buildApp(deps?: AppDeps): { app: Express; usage: UsageRepo; customers: CustomerRepo; pricing: PricingContext; pricingStore: PricingStore; items: StoredItem[] }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:

openapi: 3.0.3
info:
  title: Zappier API
  version: 0.1.0
  description: Metered data API. Every priced call returns its quote.
security:
  - apiKey: []
paths:
  /v1/status:
    get:
      operationId: status
      summary: Service status (free)
      responses:
        '200':
          description: OK
  /v1/transform:
    post:
      operationId: transform
      summary: Uppercase a string (fixed price)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
      responses:
        '200':
          description: OK
  /v1/storage:
    post:
      operationId: storage
      summary: Store metadata and file attachments (variable price by size)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                metadata:
                  type: string
                  description: JSON string of metadata
                attachments:
                  type: array
                  items:
                    type: string
                    format: binary
      responses:
        '200':
          description: OK
    get:
      operationId: storage-list
      summary: List your stored items (free)
      responses:
        '200':
          description: OK
  /v1/usage:
    get:
      operationId: usage
      summary: Your usage summary for the current period
      responses:
        '200':
          description: OK
components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: x-api-key
  • Step 2: Write the failing tests

Create tests/app.test.ts:

import request from 'supertest';
import { buildApp } from '../src/app';

const KEY = 'key-ada'; // seeded free-tier customer

describe('Zappier API', () => {
  it('rejects calls without an API key', async () => {
    const { app } = buildApp();
    const res = await request(app).get('/v1/status');
    expect(res.status).toBe(401);
  });

  it('GET /v1/status is free', async () => {
    const { app } = buildApp();
    const res = await request(app).get('/v1/status').set('x-api-key', KEY);
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('ok');
    expect(res.body.quote.totalCents).toBe(0);
  });

  it('POST /v1/transform uppercases text at the multiplied fixed price', async () => {
    const { app } = buildApp();
    const res = await request(app)
      .post('/v1/transform')
      .set('x-api-key', KEY)
      .send({ text: 'hello' });
    expect(res.status).toBe(200);
    expect(res.body.output).toBe('HELLO');
    expect(res.body.quote.totalCents).toBe(4); // list 4 x free-tier multiplier 1
  });

  it('rejects a request that violates the OpenAPI schema with 400', async () => {
    const { app } = buildApp();
    const res = await request(app)
      .post('/v1/transform')
      .set('x-api-key', KEY)
      .send({ wrong: 1 });
    expect(res.status).toBe(400);
  });

  it('POST /v1/storage stores metadata plus attachments and quotes by size', async () => {
    const { app } = buildApp();
    const res = await request(app)
      .post('/v1/storage')
      .set('x-api-key', KEY)
      .field('metadata', JSON.stringify({ title: 'report' }))
      .attach('attachments', Buffer.alloc(1024 * 1024), 'one.bin')
      .attach('attachments', Buffer.alloc(1024 * 1024), 'two.bin');
    expect(res.status).toBe(200);
    expect(res.body.id).toBeTruthy();
    // metadata string is 20 bytes -> 1 KB; 2 MB attachments
    // list 10 + 1 * 1 + 2 * 50 = 111, free-tier multiplier 1
    expect(res.body.quote.totalCents).toBe(111);
  });

  it('GET /v1/storage lists the caller items newest first', async () => {
    const { app } = buildApp();
    await request(app)
      .post('/v1/storage')
      .set('x-api-key', KEY)
      .field('metadata', JSON.stringify({ title: 'a' }));
    await request(app)
      .post('/v1/storage')
      .set('x-api-key', KEY)
      .field('metadata', JSON.stringify({ title: 'b' }));
    const res = await request(app).get('/v1/storage').set('x-api-key', KEY);
    expect(res.status).toBe(200);
    expect(res.body.items).toHaveLength(2);
    expect(res.body.items[0].metadata.title).toBe('b');
  });

  it('GET /v1/usage returns the caller summary', async () => {
    const { app } = buildApp();
    await request(app)
      .post('/v1/transform')
      .set('x-api-key', KEY)
      .send({ text: 'x' });
    const res = await request(app).get('/v1/usage').set('x-api-key', KEY);
    expect(res.status).toBe(200);
    expect(res.body.customerId).toBe('cust_1');
    expect(res.body.calls).toBe(1);
    expect(res.body.totalCents).toBe(4);
  });

  it('charges less for a business-tier customer', async () => {
    const { app } = buildApp();
    const res = await request(app)
      .post('/v1/transform')
      .set('x-api-key', 'key-linus')
      .send({ text: 'hello' });
    expect(res.body.quote.totalCents).toBe(1); // list 4 x business multiplier 0.25
  });
});
  • Step 3: Run tests to verify they fail

Run: npm test -- tests/app.test.ts Expected: FAIL — Cannot find module '../src/app'.

  • Step 4: Implement the app and server

Create src/app.ts:

import path from 'path';
import { randomUUID } from 'crypto';
import express, { Express, NextFunction, Request, Response } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yamljs';
import {
  apiKeyAuth,
  Customer,
  CustomerRepo,
  InMemoryCustomerRepo,
} from './auth';
import { meter } from './meter';
import {
  ConfigTierCatalog,
  InMemoryPricingStore,
  PricingContext,
  PricingStore,
} from './pricing';
import { InMemoryUsageRepo, UsageRepo } from './usage';

export interface StoredItem {
  id: string;
  customerId: string;
  metadata: Record<string, unknown>;
  attachments: { filename: string; size: number }[];
  createdAt: string;
}

export const DEFAULT_CUSTOMERS: Customer[] = [
  { id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' },
  { id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' },
  { id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' },
];

export interface AppDeps {
  usage?: UsageRepo;
  customers?: CustomerRepo;
  pricingStore?: PricingStore;
}

const SPEC_PATH = path.join(process.cwd(), 'openapi.yaml');

export function buildApp(deps: AppDeps = {}): {
  app: Express;
  usage: UsageRepo;
  customers: CustomerRepo;
  pricing: PricingContext;
  pricingStore: PricingStore;
  items: StoredItem[];
} {
  const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS);
  const usage = deps.usage ?? new InMemoryUsageRepo();
  const pricingStore = deps.pricingStore ?? new InMemoryPricingStore();
  // Live pricing context: every quote reads the store, so admin edits apply immediately.
  const pricing: PricingContext = {
    get rateCard() {
      return pricingStore.getRateCard();
    },
    get tiers() {
      return new ConfigTierCatalog(pricingStore.getTiers());
    },
  };
  const items: StoredItem[] = [];

  const app = express();
  app.use(express.json());

  const spec = YAML.load(SPEC_PATH);
  app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));

  app.use('/v1', apiKeyAuth(customers));
  app.use(
    OpenApiValidator.middleware({
      apiSpec: SPEC_PATH,
      validateRequests: true,
      validateResponses: false,
    }),
  );

  app.get('/v1/status', meter('status', usage, pricing), (req, res) => {
    res.json({ status: 'ok', quote: res.locals.quote });
  });

  app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => {
    const text = String(req.body?.text ?? '');
    res.json({ output: text.toUpperCase(), quote: res.locals.quote });
  });

  app.post('/v1/storage', meter('storage', usage, pricing), (req, res) => {
    const raw = req.body?.metadata;
    const metadata =
      typeof raw === 'string' ? JSON.parse(raw) : ((raw ?? {}) as Record<string, unknown>);
    const files = (req.files as Express.Multer.File[]) ?? [];
    const item: StoredItem = {
      id: randomUUID(),
      customerId: req.customer!.id,
      metadata,
      attachments: files.map((f) => ({ filename: f.originalname, size: f.size })),
      createdAt: new Date().toISOString(),
    };
    items.unshift(item);
    res.json({ id: item.id, quote: res.locals.quote });
  });

  app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => {
    res.json({ items: items.filter((i) => i.customerId === req.customer!.id) });
  });

  app.get('/v1/usage', (req, res) => {
    res.json(usage.summaryFor(req.customer!.id));
  });

  app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => {
    res.status(err.status ?? 500).json({ error: err.message });
  });

  return { app, usage, customers, pricing, pricingStore, items };
}

Create src/index.ts (in-memory for now; Task 7 swaps in SQLite):

import { buildApp } from './app';

const { app } = buildApp();
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
  console.log(`Zappier API listening on http://localhost:${port}`);
  console.log(`OpenAPI docs at http://localhost:${port}/docs`);
});
  • Step 5: Run tests to verify they pass

Run: npm test Expected: PASS — all suites, 35 tests total.

  • Step 6: Smoke-test the running server

Run: npm run dev (in one terminal), then:

curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/status
curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \
  -d '{"text":"hello"}' http://localhost:3000/v1/transform
curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \
  -d '{"wrong":1}' http://localhost:3000/v1/transform

Expected: {"status":"ok",...}, then {"output":"HELLO",...}, then a 400 validation error. Open http://localhost:3000/docs in a browser — Swagger UI renders the spec. Stop the server (Ctrl+C).

  • Step 7: Commit
git add openapi.yaml src/app.ts src/index.ts tests/app.test.ts
git commit -m "feat: add OpenAPI-driven metered API with Swagger docs"

Task 6: Monthly credit quotas

Files:

  • Create: src/billing/credit.ts
  • Test: tests/credit.test.ts
  • Modify: src/app.ts (GET /v1/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): BilledSummaryincludedCents = 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:

import { applyMonthlyCredit } from '../src/billing/credit';
import { DEFAULT_TIERS } from '../src/pricing';
import { UsageSummary } from '../src/usage';

const freeTier = DEFAULT_TIERS.find((t) => t.id === 'free')!; // monthlyCreditCents: 100

const summary = (totalCents: number): UsageSummary => ({
  customerId: 'cust_1',
  totalCents,
  calls: 1,
  byEndpoint: {},
});

describe('applyMonthlyCredit', () => {
  it('covers usage fully when under the monthly credit', () => {
    const billed = applyMonthlyCredit(summary(60), freeTier);
    expect(billed.includedCents).toBe(60);
    expect(billed.billableCents).toBe(0);
    expect(billed.totalCents).toBe(60);
  });

  it('bills only the overage when usage exceeds the credit', () => {
    const billed = applyMonthlyCredit(summary(250), freeTier);
    expect(billed.includedCents).toBe(100);
    expect(billed.billableCents).toBe(150);
  });

  it('bills nothing when there is no usage', () => {
    const billed = applyMonthlyCredit(summary(0), freeTier);
    expect(billed.includedCents).toBe(0);
    expect(billed.billableCents).toBe(0);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- tests/credit.test.ts Expected: FAIL — Cannot find module '../src/billing/credit'.

  • Step 3: Implement the credit module

Create src/billing/credit.ts:

import { TierConfig } from '../pricing';
import { UsageSummary } from '../usage';

export interface BilledSummary extends UsageSummary {
  includedCents: number;
  billableCents: number;
}

export function applyMonthlyCredit(
  summary: UsageSummary,
  tier: TierConfig,
): BilledSummary {
  const includedCents = Math.min(summary.totalCents, tier.monthlyCreditCents);
  return {
    ...summary,
    includedCents,
    billableCents: summary.totalCents - includedCents,
  };
}
  • Step 4: Run tests to verify they pass

Run: npm test -- tests/credit.test.ts Expected: PASS — 3 tests.

  • Step 5: Wire the credit into GET /v1/usage

In src/app.ts, add the import:

import { applyMonthlyCredit } from './billing/credit';

Replace the /v1/usage route with:

  app.get('/v1/usage', (req, res) => {
    const summary = usage.summaryFor(req.customer!.id);
    const tier = pricing.tiers.find(req.customer!.tierId);
    res.json(tier ? applyMonthlyCredit(summary, tier) : summary);
  });

Replace the usage test in tests/app.test.ts with:

  it('GET /v1/usage returns the caller summary with monthly credit applied', async () => {
    const { app } = buildApp();
    await request(app)
      .post('/v1/transform')
      .set('x-api-key', KEY)
      .send({ text: 'x' });
    const res = await request(app).get('/v1/usage').set('x-api-key', KEY);
    expect(res.status).toBe(200);
    expect(res.body.customerId).toBe('cust_1');
    expect(res.body.calls).toBe(1);
    expect(res.body.totalCents).toBe(4);
    expect(res.body.includedCents).toBe(4); // free-tier credit (100) covers it
    expect(res.body.billableCents).toBe(0);
  });
  • Step 6: Run the full suite

Run: npm test Expected: PASS — 38 tests total.

  • Step 7: Commit
git add src/billing/credit.ts src/app.ts tests/credit.test.ts tests/app.test.ts
git commit -m "feat: apply monthly credit quotas to usage summaries"

Task 7: SQLite persistence (usage, customers, pricing)

Files:

  • Modify: package.json (via npm install)
  • Create: src/db/usage-repo.ts
  • Create: src/db/customer-repo.ts
  • Create: src/db/pricing-store.ts
  • Modify: src/index.ts (inject SQLite repos + store)
  • Test: tests/db-usage.test.ts
  • Test: tests/db-customer.test.ts
  • Test: tests/db-pricing.test.ts

Interfaces:

  • Consumes: UsageRepo, UsageEntry, UsageSummary, summarize (Task 2); CustomerRepo, Customer (Task 3); PricingStore, PriceRule, TierConfig, RateCard, DEFAULT_RATE_CARD, DEFAULT_TIERS (Task 1); AppDeps, DEFAULT_CUSTOMERS, buildApp (Task 5).

  • Produces:

    • export class SqliteUsageRepo implements UsageRepoconstructor(db: Database.Database); creates table usage_entries on first use.
    • export class SqliteCustomerRepo implements CustomerRepoconstructor(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 PricingStoreconstructor(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:

import Database from 'better-sqlite3';
import { SqliteUsageRepo } from '../src/db/usage-repo';
import { UsageEntry } from '../src/usage';

const entry = (over: Partial<UsageEntry> = {}): UsageEntry => ({
  customerId: 'cust_1',
  endpointId: 'transform',
  cents: 4,
  metadataBytes: 0,
  attachmentBytes: 0,
  timestamp: new Date('2026-07-27T10:00:00Z'),
  ...over,
});

describe('SqliteUsageRepo', () => {
  it('records and lists entries per customer', () => {
    const repo = new SqliteUsageRepo(new Database(':memory:'));
    repo.record(entry());
    repo.record(entry({ customerId: 'cust_2' }));
    expect(repo.listFor('cust_1')).toHaveLength(1);
    expect(repo.listFor('cust_2')).toHaveLength(1);
  });

  it('filters entries by since date', () => {
    const repo = new SqliteUsageRepo(new Database(':memory:'));
    repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') }));
    repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') }));
    expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1);
  });

  it('summarizes totals by endpoint', () => {
    const repo = new SqliteUsageRepo(new Database(':memory:'));
    repo.record(entry());
    repo.record(entry({ endpointId: 'storage', cents: 112 }));
    const s = repo.summaryFor('cust_1');
    expect(s.calls).toBe(2);
    expect(s.totalCents).toBe(116);
    expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 });
  });
});

Create tests/db-customer.test.ts:

import Database from 'better-sqlite3';
import { Customer } from '../src/auth';
import { SqliteCustomerRepo } from '../src/db/customer-repo';

const customer = (apiKey: string): Customer => ({
  id: 'cust_1',
  name: 'Ada',
  tierId: 'pro',
  apiKey,
  stripeCustomerId: 'cus_123',
});

describe('SqliteCustomerRepo', () => {
  it('finds a customer by API key', () => {
    const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
    expect(repo.findByApiKey('key-ada')?.tierId).toBe('pro');
  });

  it('returns undefined for an unknown key', () => {
    const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
    expect(repo.findByApiKey('wrong')).toBeUndefined();
  });

  it('lists all customers with their Stripe ids', () => {
    const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
    const all = repo.list();
    expect(all).toHaveLength(1);
    expect(all[0].stripeCustomerId).toBe('cus_123');
  });

  it('seeds only when the table is empty', () => {
    const db = new Database(':memory:');
    new SqliteCustomerRepo(db, [customer('key-ada')]);
    const again = new SqliteCustomerRepo(db, [customer('key-other')]);
    expect(again.list().map((c) => c.apiKey)).toEqual(['key-ada']);
  });

  it('save() upserts including the multiplier override', () => {
    const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
    repo.save({ ...customer('key-ada'), tierId: 'business', multiplierOverride: 0.4 });
    const updated = repo.findByApiKey('key-ada');
    expect(updated?.tierId).toBe('business');
    expect(updated?.multiplierOverride).toBe(0.4);
    expect(repo.list()).toHaveLength(1);
  });
});

Create tests/db-pricing.test.ts:

import Database from 'better-sqlite3';
import { SqlitePricingStore } from '../src/db/pricing-store';

describe('SqlitePricingStore', () => {
  it('seeds the default rate card and tiers when empty', () => {
    const store = new SqlitePricingStore(new Database(':memory:'));
    expect(store.getRateCard().endpoints.transform).toEqual({ kind: 'fixed', fixedCents: 4 });
    expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
  });

  it('seeds only once', () => {
    const db = new Database(':memory:');
    const first = new SqlitePricingStore(db);
    first.deleteEndpoint('transform');
    const second = new SqlitePricingStore(db);
    expect(second.getRateCard().endpoints.transform).toBeUndefined();
  });

  it('upserts and deletes endpoints', () => {
    const store = new SqlitePricingStore(new Database(':memory:'));
    store.upsertEndpoint('experimental', { kind: 'variable', baseCents: 3, perKbCents: 2, perMbCents: 20 });
    expect(store.getRateCard().endpoints.experimental).toEqual({
      kind: 'variable',
      baseCents: 3,
      perKbCents: 2,
      perMbCents: 20,
    });
    store.deleteEndpoint('experimental');
    expect(store.getRateCard().endpoints.experimental).toBeUndefined();
  });

  it('upserts tiers including default rules, and deletes them', () => {
    const store = new SqlitePricingStore(new Database(':memory:'));
    store.upsertTier({
      id: 'edu',
      name: 'Education',
      multiplier: 0.4,
      monthlyCreditCents: 500,
      defaultRule: { kind: 'fixed', fixedCents: 6 },
    });
    expect(store.getTiers().find((t) => t.id === 'edu')?.defaultRule).toEqual({
      kind: 'fixed',
      fixedCents: 6,
    });
    store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 });
    expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1);
    expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.3);
    store.deleteTier('edu');
    expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined();
  });
});
  • Step 3: Run tests to verify they fail

Run: npm test -- tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts Expected: FAIL — Cannot find module '../src/db/...'.

  • Step 4: Implement the SQLite repos and pricing store

Create src/db/usage-repo.ts:

import Database from 'better-sqlite3';
import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage';

export class SqliteUsageRepo implements UsageRepo {
  constructor(private db: Database.Database) {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS usage_entries (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        customer_id TEXT NOT NULL,
        endpoint_id TEXT NOT NULL,
        cents INTEGER NOT NULL,
        metadata_bytes INTEGER NOT NULL,
        attachment_bytes INTEGER NOT NULL,
        timestamp_ms INTEGER NOT NULL
      )
    `);
  }

  record(entry: UsageEntry): void {
    this.db
      .prepare(
        `INSERT INTO usage_entries
         (customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms)
         VALUES (?, ?, ?, ?, ?, ?)`,
      )
      .run(
        entry.customerId,
        entry.endpointId,
        entry.cents,
        entry.metadataBytes,
        entry.attachmentBytes,
        entry.timestamp.getTime(),
      );
  }

  listFor(customerId: string, since?: Date): UsageEntry[] {
    const rows = (
      since
        ? this.db
            .prepare(
              'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms',
            )
            .all(customerId, since.getTime())
        : this.db
            .prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms')
            .all(customerId)
    ) as Record<string, unknown>[];
    return rows.map((r) => ({
      customerId: r.customer_id as string,
      endpointId: r.endpoint_id as string,
      cents: r.cents as number,
      metadataBytes: r.metadata_bytes as number,
      attachmentBytes: r.attachment_bytes as number,
      timestamp: new Date(r.timestamp_ms as number),
    }));
  }

  summaryFor(customerId: string, since?: Date): UsageSummary {
    return summarize(customerId, this.listFor(customerId, since));
  }
}

Create src/db/customer-repo.ts:

import Database from 'better-sqlite3';
import { Customer, CustomerRepo } from '../auth';

export class SqliteCustomerRepo implements CustomerRepo {
  constructor(private db: Database.Database, seed: Customer[] = []) {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS customers (
        id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        tier_id TEXT NOT NULL,
        api_key TEXT NOT NULL UNIQUE,
        stripe_customer_id TEXT,
        multiplier_override REAL
      )
    `);
    const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number };
    if (n === 0) {
      for (const c of seed) this.save(c);
    }
  }

  findByApiKey(apiKey: string): Customer | undefined {
    const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as
      | Record<string, unknown>
      | undefined;
    return r ? toCustomer(r) : undefined;
  }

  list(): Customer[] {
    const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record<
      string,
      unknown
    >[];
    return rows.map(toCustomer);
  }

  save(customer: Customer): void {
    this.db
      .prepare(
        `INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override)
         VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride)
         ON CONFLICT(id) DO UPDATE SET
           name = @name,
           tier_id = @tierId,
           api_key = @apiKey,
           stripe_customer_id = @stripeCustomerId,
           multiplier_override = @multiplierOverride`,
      )
      .run({
        id: customer.id,
        name: customer.name,
        tierId: customer.tierId,
        apiKey: customer.apiKey,
        stripeCustomerId: customer.stripeCustomerId ?? null,
        multiplierOverride: customer.multiplierOverride ?? null,
      });
  }
}

function toCustomer(r: Record<string, unknown>): Customer {
  return {
    id: r.id as string,
    name: r.name as string,
    tierId: r.tier_id as string,
    apiKey: r.api_key as string,
    stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined,
    multiplierOverride: (r.multiplier_override as number | null) ?? undefined,
  };
}

Create src/db/pricing-store.ts:

import Database from 'better-sqlite3';
import {
  DEFAULT_RATE_CARD,
  DEFAULT_TIERS,
  PriceRule,
  PricingStore,
  RateCard,
  TierConfig,
} from '../pricing';

export class SqlitePricingStore implements PricingStore {
  constructor(
    private db: Database.Database,
    seedCard: RateCard = DEFAULT_RATE_CARD,
    seedTiers: TierConfig[] = DEFAULT_TIERS,
  ) {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS price_endpoints (
        endpoint_id TEXT PRIMARY KEY,
        rule_json TEXT NOT NULL
      );
      CREATE TABLE IF NOT EXISTS tiers (
        id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        multiplier REAL NOT NULL,
        monthly_credit_cents INTEGER NOT NULL,
        default_rule_json TEXT
      )
    `);
    const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM price_endpoints').get() as { n: number };
    if (n === 0) {
      for (const [id, rule] of Object.entries(seedCard.endpoints)) {
        this.upsertEndpoint(id, rule);
      }
      for (const tier of seedTiers) {
        this.upsertTier(tier);
      }
    }
  }

  getRateCard(): RateCard {
    const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record<string, unknown>[];
    const endpoints: Record<string, PriceRule> = {};
    for (const r of rows) {
      endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule;
    }
    return { endpoints };
  }

  getTiers(): TierConfig[] {
    const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record<string, unknown>[];
    return rows.map((r) => ({
      id: r.id as string,
      name: r.name as string,
      multiplier: r.multiplier as number,
      monthlyCreditCents: r.monthly_credit_cents as number,
      defaultRule: r.default_rule_json
        ? (JSON.parse(r.default_rule_json as string) as PriceRule)
        : undefined,
    }));
  }

  upsertEndpoint(endpointId: string, rule: PriceRule): void {
    this.db
      .prepare(
        `INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?)
         ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`,
      )
      .run(endpointId, JSON.stringify(rule));
  }

  deleteEndpoint(endpointId: string): void {
    this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId);
  }

  upsertTier(tier: TierConfig): void {
    this.db
      .prepare(
        `INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json)
         VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson)
         ON CONFLICT(id) DO UPDATE SET
           name = @name,
           multiplier = @multiplier,
           monthly_credit_cents = @monthlyCreditCents,
           default_rule_json = @defaultRuleJson`,
      )
      .run({
        id: tier.id,
        name: tier.name,
        multiplier: tier.multiplier,
        monthlyCreditCents: tier.monthlyCreditCents,
        defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null,
      });
  }

  deleteTier(tierId: string): void {
    this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId);
  }
}
  • Step 5: Run tests to verify they pass

Run: npm test Expected: PASS — 50 tests total.

  • Step 6: Inject SQLite into the server

Replace src/index.ts with:

import Database from 'better-sqlite3';
import { buildApp, DEFAULT_CUSTOMERS } from './app';
import { SqliteCustomerRepo } from './db/customer-repo';
import { SqlitePricingStore } from './db/pricing-store';
import { SqliteUsageRepo } from './db/usage-repo';

const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db');
const usage = new SqliteUsageRepo(db);
const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS);
const pricingStore = new SqlitePricingStore(db);

const { app } = buildApp({ usage, customers, pricingStore });
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
  console.log(`Zappier API listening on http://localhost:${port}`);
  console.log(`OpenAPI docs at http://localhost:${port}/docs`);
});
  • Step 7: Verify persistence across restarts
cd /Users/marchon/zappier
npm run dev &
sleep 2
curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \
  -d '{"text":"hello"}' http://localhost:3000/v1/transform
kill %1
npm run dev &
sleep 2
curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/usage
kill %1

Expected: the second /v1/usage response still shows "calls":1,"totalCents":4 — usage survived the restart. Delete the smoke-test database afterwards: rm -f zappier.db.

  • Step 8: Commit
git add package.json package-lock.json src/db src/index.ts tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts
git commit -m "feat: persist usage, customers, and pricing in SQLite"

Task 8: Admin API + pricing web UI

Files:

  • Create: src/admin.ts
  • Create: admin/index.html
  • Create: admin/app.js
  • Modify: src/app.ts (mount admin router + static UI)
  • Test: tests/admin.test.ts

Interfaces:

  • Consumes: PricingStore, PriceRule, TierConfig (Task 1); CustomerRepo, Customer (Task 3); buildApp (Task 5).

  • Produces:

    • export function adminAuth(): RequestHandler — 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:

import request from 'supertest';
import { buildApp } from '../src/app';

const ADMIN = { 'x-admin-key': 'admin-dev-key' };
const KEY = 'key-ada';

describe('admin API', () => {
  it('rejects calls without an admin key', async () => {
    const { app } = buildApp();
    const res = await request(app).get('/admin/api/pricing');
    expect(res.status).toBe(403);
  });

  it('returns the current pricing', async () => {
    const { app } = buildApp();
    const res = await request(app).get('/admin/api/pricing').set(ADMIN);
    expect(res.status).toBe(200);
    expect(res.body.rateCard.endpoints.status).toEqual({ kind: 'free' });
    expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(['free', 'pro', 'business']);
  });

  it('rejects an invalid price rule with 400', async () => {
    const { app } = buildApp();
    const res = await request(app)
      .put('/admin/api/endpoints/transform')
      .set(ADMIN)
      .send({ kind: 'sometimes' });
    expect(res.status).toBe(400);
  });

  it('reprices an endpoint live, without restart', async () => {
    const { app } = buildApp();
    await request(app)
      .put('/admin/api/endpoints/transform')
      .set(ADMIN)
      .send({ kind: 'fixed', fixedCents: 10 });
    const res = await request(app)
      .post('/v1/transform')
      .set('x-api-key', KEY)
      .send({ text: 'hi' });
    expect(res.body.quote.totalCents).toBe(10); // was 4
  });

  it('creates a customer type live and prices calls for it', async () => {
    const { app } = buildApp();
    await request(app)
      .put('/admin/api/tiers/edu')
      .set(ADMIN)
      .send({ id: 'edu', name: 'Education', multiplier: 0.5, monthlyCreditCents: 500 });
    const created = await request(app)
      .post('/admin/api/customers')
      .set(ADMIN)
      .send({ name: 'School', tierId: 'edu' });
    expect(created.status).toBe(201);
    expect(created.body.apiKey).toMatch(/^key-/);
    const res = await request(app)
      .post('/v1/transform')
      .set('x-api-key', created.body.apiKey)
      .send({ text: 'hi' });
    expect(res.body.quote.totalCents).toBe(2); // list 4 x 0.5
  });

  it('sets a per-customer multiplier override live', async () => {
    const { app } = buildApp();
    const res = await request(app)
      .put('/admin/api/customers/cust_1')
      .set(ADMIN)
      .send({ multiplierOverride: 0.5 });
    expect(res.status).toBe(200);
    const call = await request(app)
      .post('/v1/transform')
      .set('x-api-key', KEY)
      .send({ text: 'hi' });
    expect(call.body.quote.totalCents).toBe(2); // was 4
  });

  it('masks API keys in the customer list and 404s unknown customers', async () => {
    const { app } = buildApp();
    const list = await request(app).get('/admin/api/customers').set(ADMIN);
    expect(list.status).toBe(200);
    expect(JSON.stringify(list.body)).not.toContain('key-ada');
    const missing = await request(app)
      .put('/admin/api/customers/cust_nope')
      .set(ADMIN)
      .send({ tierId: 'pro' });
    expect(missing.status).toBe(404);
  });
});
  • Step 2: Run tests to verify they fail

Run: npm test -- tests/admin.test.ts Expected: FAIL — admin routes 404 (and src/admin.ts missing).

  • Step 3: Implement the admin router

Create src/admin.ts:

import { randomBytes, randomUUID } from 'crypto';
import { RequestHandler, Router } from 'express';
import { CustomerRepo } from './auth';
import { PriceRule, PricingStore, TierConfig } from './pricing';

export function adminAuth(): RequestHandler {
  return (req, res, next) => {
    const expected = process.env.ADMIN_KEY ?? 'admin-dev-key';
    if (req.header('x-admin-key') !== expected) {
      res.status(403).json({ error: 'invalid or missing admin key' });
      return;
    }
    next();
  };
}

function isValidRule(rule: unknown): rule is PriceRule {
  if (!rule || typeof rule !== 'object') return false;
  const r = rule as Record<string, unknown>;
  if (r.kind === 'free') return true;
  if (r.kind === 'fixed') return typeof r.fixedCents === 'number';
  if (r.kind === 'variable') {
    return ['baseCents', 'perKbCents', 'perMbCents'].every((k) => typeof r[k] === 'number');
  }
  return false;
}

function isValidTier(tier: unknown): tier is TierConfig {
  if (!tier || typeof tier !== 'object') return false;
  const t = tier as Record<string, unknown>;
  return (
    typeof t.name === 'string' &&
    typeof t.multiplier === 'number' &&
    typeof t.monthlyCreditCents === 'number' &&
    (t.defaultRule === undefined || isValidRule(t.defaultRule))
  );
}

export function adminRouter(store: PricingStore, customers: CustomerRepo): Router {
  const router = Router();

  router.get('/pricing', (req, res) => {
    res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() });
  });

  router.put('/endpoints/:id', (req, res) => {
    if (!isValidRule(req.body)) {
      res.status(400).json({ error: 'invalid price rule' });
      return;
    }
    store.upsertEndpoint(req.params.id, req.body);
    res.json({ ok: true });
  });

  router.delete('/endpoints/:id', (req, res) => {
    store.deleteEndpoint(req.params.id);
    res.json({ ok: true });
  });

  router.put('/tiers/:id', (req, res) => {
    if (!isValidTier(req.body)) {
      res.status(400).json({ error: 'invalid tier config' });
      return;
    }
    store.upsertTier({ ...req.body, id: req.params.id });
    res.json({ ok: true });
  });

  router.delete('/tiers/:id', (req, res) => {
    store.deleteTier(req.params.id);
    res.json({ ok: true });
  });

  router.get('/customers', (req, res) => {
    res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) });
  });

  router.post('/customers', (req, res) => {
    const { name, tierId } = req.body ?? {};
    if (typeof name !== 'string' || typeof tierId !== 'string') {
      res.status(400).json({ error: 'name and tierId required' });
      return;
    }
    const customer = {
      id: `cust_${randomUUID().slice(0, 8)}`,
      name,
      tierId,
      apiKey: `key-${randomBytes(12).toString('hex')}`,
    };
    customers.save(customer);
    res.status(201).json(customer);
  });

  router.put('/customers/:id', (req, res) => {
    const existing = customers.list().find((c) => c.id === req.params.id);
    if (!existing) {
      res.status(404).json({ error: 'customer not found' });
      return;
    }
    const { name, tierId, multiplierOverride, stripeCustomerId } = req.body ?? {};
    customers.save({
      ...existing,
      ...(name !== undefined ? { name } : {}),
      ...(tierId !== undefined ? { tierId } : {}),
      ...(multiplierOverride !== undefined ? { multiplierOverride } : {}),
      ...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
    });
    res.json({ ok: true });
  });

  return router;
}
  • Step 4: Mount the admin API and static UI in the app

In src/app.ts, add the import:

import { adminAuth, adminRouter } from './admin';

Add these two lines immediately after app.use('/docs', ...) (before the /v1 auth middleware):

  app.use('/admin/api', adminAuth(), adminRouter(pricingStore, customers));
  app.use('/admin', express.static(path.join(process.cwd(), 'admin')));
  • Step 5: Build the admin web UI

Create admin/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Zappier Pricing Admin</title>
    <style>
      body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 2rem; color: #222; }
      nav button { margin-right: 0.5rem; padding: 0.4rem 0.9rem; }
      nav button.active { font-weight: 700; text-decoration: underline; }
      table { border-collapse: collapse; margin-top: 1rem; }
      th, td { border: 1px solid #ccc; padding: 0.35rem 0.6rem; text-align: left; }
      input, select { padding: 0.25rem; }
      .row-actions button { margin-right: 0.25rem; }
      #status { margin-top: 1rem; color: #060; }
    </style>
  </head>
  <body>
    <h1>Zappier Pricing Admin</h1>
    <nav>
      <button data-tab="endpoints" class="active">Rate card</button>
      <button data-tab="tiers">Customer types</button>
      <button data-tab="customers">Customers</button>
    </nav>
    <section id="endpoints"></section>
    <section id="tiers" hidden></section>
    <section id="customers" hidden></section>
    <p id="status"></p>
    <script src="app.js"></script>
  </body>
</html>

Create admin/app.js:

const state = { pricing: null, customers: [] };

function adminKey() {
  let key = localStorage.getItem('zappier-admin-key');
  if (!key) {
    key = prompt('Admin key:');
    localStorage.setItem('zappier-admin-key', key);
  }
  return key;
}

async function api(path, options = {}) {
  const res = await fetch(`/admin/api${path}`, {
    ...options,
    headers: { 'content-type': 'application/json', 'x-admin-key': adminKey() },
  });
  if (res.status === 403) {
    localStorage.removeItem('zappier-admin-key');
    throw new Error('Admin key rejected — reload to re-enter.');
  }
  if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
  return res.json();
}

function say(msg) {
  document.getElementById('status').textContent = msg;
}

async function load() {
  state.pricing = await api('/pricing');
  state.customers = (await api('/customers')).customers;
  renderEndpoints();
  renderTiers();
  renderCustomers();
}

function ruleInputs(id, rule) {
  const fields =
    rule.kind === 'fixed'
      ? { fixedCents: rule.fixedCents }
      : rule.kind === 'variable'
        ? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents }
        : {};
  return Object.entries(fields)
    .map(
      ([k, v]) =>
        `${k} <input data-endpoint="${id}" data-field="${k}" type="number" step="any" value="${v}" size="6">`,
    )
    .join(' ');
}

function renderEndpoints() {
  const rows = Object.entries(state.pricing.rateCard.endpoints)
    .map(
      ([id, rule]) => `<tr>
        <td>${id}</td>
        <td><select data-endpoint-kind="${id}">
          ${['free', 'fixed', 'variable'].map((k) => `<option ${k === rule.kind ? 'selected' : ''}>${k}</option>`).join('')}
        </select></td>
        <td>${ruleInputs(id, rule)}</td>
        <td class="row-actions">
          <button onclick="saveEndpoint('${id}')">Save</button>
          <button onclick="deleteEndpoint('${id}')">Delete</button>
        </td>
      </tr>`,
    )
    .join('');
  document.getElementById('endpoints').innerHTML = `
    <h2>Rate card</h2>
    <table><tr><th>Endpoint (operationId)</th><th>Kind</th><th>Prices (cents)</th><th></th></tr>${rows}</table>
    <h3>Add endpoint</h3>
    <input id="new-endpoint-id" placeholder="operationId">
    <select id="new-endpoint-kind"><option>free</option><option selected>fixed</option><option>variable</option></select>
    <button onclick="addEndpoint()">Add</button>`;
}

async function saveEndpoint(id) {
  const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value;
  const rule = { kind };
  document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => {
    rule[el.dataset.field] = Number(el.value);
  });
  if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0;
  if (kind === 'variable') {
    rule.baseCents = rule.baseCents ?? 0;
    rule.perKbCents = rule.perKbCents ?? 0;
    rule.perMbCents = rule.perMbCents ?? 0;
  }
  await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
  say(`Saved ${id}.`);
  await load();
}

async function deleteEndpoint(id) {
  await api(`/endpoints/${id}`, { method: 'DELETE' });
  say(`Deleted ${id}.`);
  await load();
}

async function addEndpoint() {
  const id = document.getElementById('new-endpoint-id').value.trim();
  const kind = document.getElementById('new-endpoint-kind').value;
  if (!id) return say('Endpoint id required.');
  const rule =
    kind === 'free'
      ? { kind }
      : kind === 'fixed'
        ? { kind, fixedCents: 0 }
        : { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 };
  await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
  say(`Added ${id}.`);
  await load();
}

function renderTiers() {
  const rows = state.pricing.tiers
    .map(
      (t) => `<tr>
        <td>${t.id}</td>
        <td><input data-tier="${t.id}" data-field="name" value="${t.name}"></td>
        <td><input data-tier="${t.id}" data-field="multiplier" type="number" step="any" value="${t.multiplier}" size="5"></td>
        <td><input data-tier="${t.id}" data-field="monthlyCreditCents" type="number" value="${t.monthlyCreditCents}" size="8"></td>
        <td class="row-actions">
          <button onclick="saveTier('${t.id}')">Save</button>
          <button onclick="deleteTier('${t.id}')">Delete</button>
        </td>
      </tr>`,
    )
    .join('');
  document.getElementById('tiers').innerHTML = `
    <h2>Customer types</h2>
    <table><tr><th>Id</th><th>Name</th><th>Multiplier</th><th>Monthly credit (cents)</th><th></th></tr>${rows}</table>
    <h3>Add customer type</h3>
    <input id="new-tier-id" placeholder="id">
    <input id="new-tier-name" placeholder="name">
    <input id="new-tier-multiplier" type="number" step="any" value="1" size="5"> multiplier
    <button onclick="addTier()">Add</button>`;
}

async function saveTier(id) {
  const body = { id };
  document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => {
    body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
  });
  const existing = state.pricing.tiers.find((t) => t.id === id);
  if (existing?.defaultRule) body.defaultRule = existing.defaultRule;
  await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
  say(`Saved tier ${id}.`);
  await load();
}

async function deleteTier(id) {
  await api(`/tiers/${id}`, { method: 'DELETE' });
  say(`Deleted tier ${id}.`);
  await load();
}

async function addTier() {
  const id = document.getElementById('new-tier-id').value.trim();
  const name = document.getElementById('new-tier-name').value.trim();
  const multiplier = Number(document.getElementById('new-tier-multiplier').value);
  if (!id || !name) return say('Tier id and name required.');
  await api(`/tiers/${id}`, {
    method: 'PUT',
    body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }),
  });
  say(`Added tier ${id}.`);
  await load();
}

function renderCustomers() {
  const tierOptions = (selected) =>
    state.pricing.tiers
      .map((t) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
      .join('');
  const rows = state.customers
    .map(
      (c) => `<tr>
        <td>${c.id}</td>
        <td>${c.name}</td>
        <td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
        <td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
        <td><button onclick="saveCustomer('${c.id}')">Save</button></td>
      </tr>`,
    )
    .join('');
  document.getElementById('customers').innerHTML = `
    <h2>Customers</h2>
    <table><tr><th>Id</th><th>Name</th><th>Type</th><th>Multiplier override</th><th></th></tr>${rows}</table>
    <h3>Add customer</h3>
    <input id="new-customer-name" placeholder="name">
    <select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
    <button onclick="addCustomer()">Create</button>
    <p>A new customer's API key is shown once in the status line below.</p>`;
}

async function saveCustomer(id) {
  const body = {};
  document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => {
    if (el.value === '') return;
    body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
  });
  await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
  say(`Saved customer ${id}.`);
  await load();
}

async function addCustomer() {
  const name = document.getElementById('new-customer-name').value.trim();
  const tierId = document.getElementById('new-customer-tier').value;
  if (!name) return say('Customer name required.');
  const created = await api('/customers', {
    method: 'POST',
    body: JSON.stringify({ name, tierId }),
  });
  say(`Created ${created.id} — API key: ${created.apiKey}`);
  await load();
}

document.querySelectorAll('nav button').forEach((btn) =>
  btn.addEventListener('click', () => {
    document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
    btn.classList.add('active');
    document.querySelectorAll('section').forEach((s) => (s.hidden = true));
    document.getElementById(btn.dataset.tab).hidden = false;
  }),
);

load().catch((err) => say(err.message));
  • Step 6: Run tests to verify they pass

Run: npm test Expected: PASS — 57 tests total.

  • Step 7: Verify the UI end-to-end (manual)
  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
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:

import { MeterEventClient, reportUsage, METER_EVENT_NAME } from '../src/billing/stripe';
import { UsageEntry } from '../src/usage';

const entry = (cents: number): UsageEntry => ({
  customerId: 'cust_1',
  endpointId: 'storage',
  cents,
  metadataBytes: 0,
  attachmentBytes: 0,
  timestamp: new Date(),
});

const fakeClient = () => {
  const calls: { eventName: string; customerId: string; value: string }[] = [];
  const client: MeterEventClient = {
    createMeterEvent: async (params) => {
      calls.push(params);
    },
  };
  return { client, calls };
};

describe('reportUsage', () => {
  it('reports usage minus the monthly credit as one meter event', async () => {
    const { client, calls } = fakeClient();
    const reported = await reportUsage(client, 'cus_123', [entry(112), entry(4)], 100);
    expect(reported).toBe(16); // 116 - 100 credit
    expect(calls).toEqual([
      { eventName: METER_EVENT_NAME, customerId: 'cus_123', value: '16' },
    ]);
  });

  it('reports nothing when the credit covers all usage', async () => {
    const { client, calls } = fakeClient();
    const reported = await reportUsage(client, 'cus_123', [entry(4)], 100);
    expect(reported).toBe(0);
    expect(calls).toHaveLength(0);
  });
});
  • Step 3: Run tests to verify they fail

Run: npm test -- tests/stripe.test.ts Expected: FAIL — Cannot find module '../src/billing/stripe'.

  • Step 4: Implement the billing module and job

Create src/billing/stripe.ts:

import { UsageEntry } from '../usage';

export const METER_EVENT_NAME = 'zappier.api_cents';

export interface MeterEventClient {
  createMeterEvent(params: {
    eventName: string;
    customerId: string;
    value: string;
  }): Promise<void>;
}

export async function reportUsage(
  client: MeterEventClient,
  stripeCustomerId: string,
  entries: UsageEntry[],
  monthlyCreditCents: number,
): Promise<number> {
  const totalCents = entries.reduce((sum, e) => sum + e.cents, 0);
  const billable = Math.max(0, totalCents - monthlyCreditCents);
  if (billable <= 0) return 0;
  await client.createMeterEvent({
    eventName: METER_EVENT_NAME,
    customerId: stripeCustomerId,
    value: String(billable),
  });
  return billable;
}

Create src/jobs/report-usage.ts (run monthly, e.g. STRIPE_SECRET_KEY=sk_test_... npx ts-node src/jobs/report-usage.ts):

import Database from 'better-sqlite3';
import Stripe from 'stripe';
import { DEFAULT_CUSTOMERS } from '../app';
import { METER_EVENT_NAME, reportUsage } from '../billing/stripe';
import { SqliteCustomerRepo } from '../db/customer-repo';
import { SqlitePricingStore } from '../db/pricing-store';
import { SqliteUsageRepo } from '../db/usage-repo';

async function main(): Promise<void> {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  const client = {
    createMeterEvent: async (p: { eventName: string; customerId: string; value: string }) => {
      await stripe.billing.meterEvents.create({
        event_name: METER_EVENT_NAME,
        payload: { stripe_customer_id: p.customerId, value: p.value },
      });
    },
  };

  const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db');
  const usage = new SqliteUsageRepo(db);
  const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS);
  const pricingStore = new SqlitePricingStore(db);
  const tiers = pricingStore.getTiers();

  const since = new Date();
  since.setUTCDate(1);
  since.setUTCHours(0, 0, 0, 0);

  for (const customer of customers.list()) {
    if (!customer.stripeCustomerId) continue;
    const tier = tiers.find((t) => t.id === customer.tierId);
    if (!tier) {
      console.warn(`${customer.id}: unknown tier ${customer.tierId}, skipped`);
      continue;
    }
    const entries = usage.listFor(customer.id, since);
    const reported = await reportUsage(
      client,
      customer.stripeCustomerId,
      entries,
      tier.monthlyCreditCents,
    );
    console.log(`${customer.id}: reported ${reported} billable cents to Stripe`);
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
  • Step 5: Run tests to verify they pass

Run: npm test Expected: PASS — 59 tests total.

  • Step 6: Commit
git add src/billing/stripe.ts src/jobs/report-usage.ts tests/stripe.test.ts
git commit -m "feat: report credit-adjusted monthly usage to Stripe billing meters"

Task 10: Zapier app — account, CLI, auth, trigger, action, publish

Files:

  • Create: zapier-app/package.json
  • Create: zapier-app/index.js
  • Create: zapier-app/authentication.js
  • Create: zapier-app/triggers/new_item.js
  • Create: zapier-app/creates/store_data.js
  • Test: zapier-app/test/app.test.js

Interfaces:

  • Consumes: the live API from Task 5 (GET /v1/status, GET /v1/storage, POST /v1/storage) 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/storageitems 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:
npm install -g zapier-platform-cli
zapier login

Expected: browser OAuth completes; zapier apps runs without auth errors.

  • Step 2: Scaffold the app package
mkdir -p /Users/marchon/zappier/zapier-app/triggers /Users/marchon/zappier/zapier-app/creates /Users/marchon/zappier/zapier-app/test

Create zapier-app/package.json:

{
  "name": "zappier",
  "version": "1.0.0",
  "description": "Store data and files through the metered Zappier API.",
  "main": "index.js",
  "scripts": {
    "test": "mocha --recursive --timeout 10000"
  }
}

Run: cd /Users/marchon/zappier/zapier-app && npm install zapier-platform-core form-data && npm install --save-dev mocha Expected: dependencies written into package.json.

  • Step 3: Write the failing tests

Create zapier-app/test/app.test.js:

const assert = require('assert');
const App = require('../index');
const storeData = require('../creates/store_data');

describe('Zapier app definition', () => {
  it('exposes custom auth, one trigger, and one action', () => {
    assert.equal(App.authentication.type, 'custom');
    assert.ok(App.triggers.new_item);
    assert.ok(App.creates.store_data);
    assert.equal(App.beforeRequest.length, 1);
  });
});

describe('store_data perform', () => {
  it('posts metadata JSON to /v1/storage', async () => {
    const requests = [];
    const z = {
      request: async (opts) => {
        requests.push(opts);
        return { data: { id: 'item_1' } };
      },
    };
    const bundle = {
      authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' },
      inputData: { title: 'report', note: 'hello' },
    };
    const result = await storeData.operation.perform(z, bundle);
    assert.equal(result.id, 'item_1');
    assert.equal(requests.length, 1);
    assert.equal(requests[0].method, 'POST');
    assert.equal(requests[0].url, 'http://localhost:3000/v1/storage');
  });

  it('downloads the mapped file first, then posts it as an attachment', async () => {
    const requests = [];
    const z = {
      request: async (opts) => {
        requests.push(opts);
        return opts.raw ? { body: Buffer.from('x') } : { data: { id: 'item_2' } };
      },
    };
    const bundle = {
      authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' },
      inputData: { title: 'with file', file: 'https://example.com/f.pdf', filename: 'f.pdf' },
    };
    await storeData.operation.perform(z, bundle);
    assert.equal(requests.length, 2);
    assert.equal(requests[0].url, 'https://example.com/f.pdf');
    assert.equal(requests[0].raw, true);
    assert.equal(requests[1].url, 'http://localhost:3000/v1/storage');
    assert.equal(requests[1].method, 'POST');
  });
});
  • Step 4: Run tests to verify they fail

Run: cd /Users/marchon/zappier/zapier-app && npm test Expected: FAIL — Cannot find module '../index'.

  • Step 5: Implement the Zapier app

Create zapier-app/authentication.js:

module.exports = {
  type: 'custom',
  test: (z, bundle) =>
    z.request({ url: `${bundle.authData.baseUrl}/v1/status` }).then((r) => r.data),
  fields: [
    {
      key: 'baseUrl',
      label: 'API Base URL',
      type: 'string',
      required: true,
      default: 'http://localhost:3000',
      helpText: 'Where your Zappier API is running.',
    },
    {
      key: 'apiKey',
      label: 'API Key',
      type: 'password',
      required: true,
      helpText: 'Your Zappier customer API key.',
    },
  ],
  connectionLabel: '{{bundle.authData.baseUrl}}',
};

Create zapier-app/triggers/new_item.js:

const perform = async (z, bundle) => {
  const response = await z.request({ url: `${bundle.authData.baseUrl}/v1/storage` });
  return response.data.items;
};

module.exports = {
  key: 'new_item',
  noun: 'Stored Item',
  display: {
    label: 'New Stored Item',
    description: 'Triggers when a new item is stored through the Zappier API.',
  },
  operation: {
    type: 'polling',
    perform,
    sample: {
      id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
      customerId: 'cust_1',
      metadata: { title: 'example' },
      attachments: [],
      createdAt: '2026-07-27T10:00:00.000Z',
    },
  },
};

Create zapier-app/creates/store_data.js:

const FormData = require('form-data');

const perform = async (z, bundle) => {
  const form = new FormData();
  form.append(
    'metadata',
    JSON.stringify({ title: bundle.inputData.title, note: bundle.inputData.note }),
  );

  if (bundle.inputData.file) {
    const fileResponse = await z.request({
      url: bundle.inputData.file,
      raw: true,
      redirect: 'follow',
    });
    form.append('attachments', fileResponse.body, {
      filename: bundle.inputData.filename || 'attachment.bin',
    });
  }

  const response = await z.request({
    url: `${bundle.authData.baseUrl}/v1/storage`,
    method: 'POST',
    body: form,
    headers: form.getHeaders(),
  });
  return response.data;
};

module.exports = {
  key: 'store_data',
  noun: 'Stored Item',
  display: {
    label: 'Store Data',
    description:
      'Stores metadata and an optional file attachment. Priced per call plus metadata KB and attachment MB on your plan.',
  },
  operation: {
    inputFields: [
      { key: 'title', label: 'Title', type: 'string', required: true },
      { key: 'note', label: 'Note', type: 'text', required: false },
      {
        key: 'file',
        label: 'Attachment',
        type: 'file',
        required: false,
        helpText: 'Optional file. Attachment size is billed per MB on your plan.',
      },
      { key: 'filename', label: 'Filename', type: 'string', required: false },
    ],
    perform,
    sample: { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6' },
  },
};

Create zapier-app/index.js:

const authentication = require('./authentication');
const newItem = require('./triggers/new_item');
const storeData = require('./creates/store_data');

const addApiKeyHeader = (request, z, bundle) => {
  request.headers = request.headers || {};
  request.headers['x-api-key'] = bundle.authData.apiKey;
  return request;
};

module.exports = {
  version: require('./package.json').version,
  platformVersion: require('zapier-platform-core').version,
  authentication,
  beforeRequest: [addApiKeyHeader],
  triggers: { [newItem.key]: newItem },
  creates: { [storeData.key]: storeData },
};
  • Step 6: Run tests to verify they pass

Run: cd /Users/marchon/zappier/zapier-app && npm test Expected: PASS — 3 tests.

  • Step 7: End-to-end check against the live API
  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
cd /Users/marchon/zappier/zapier-app
zapier push

Expected: Push successful; the app appears at https://developer.zapier.com.

Then in https://zapier.com create a private Zap: trigger New Stored Item, connect with baseUrl + key-ada, and add the Store Data action in a second Zap. Turn them on and confirm runs in the Zap history.

  • Step 9: Commit
cd /Users/marchon/zappier
git add zapier-app
git commit -m "feat: add Zapier integration with auth, polling trigger, and store action"

Self-review notes

  • Spec coverage: Swagger/OpenAPI API definitions → Task 5 (openapi.yaml source of truth, request validation, Swagger UI at /docs; rate card keys = spec operationIds). 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 operationIds, 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).