2981 lines
100 KiB
Markdown
2981 lines
100 KiB
Markdown
# Metered API + Tiered Pricing + Admin UI + Zapier Distribution — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the Zappier HTTP API driven by an OpenAPI spec, with per-call usage metering, runtime-editable pricing (rate card × tier multiplier + per-customer overrides + monthly credits) managed through an admin web UI, SQLite persistence, Stripe metered billing, and a published Zapier app.
|
||
|
||
**Architecture:** `openapi.yaml` is the API source of truth — `express-openapi-validator` validates requests against it and `swagger-ui-express` serves docs at `/docs`; each spec `operationId` is a rate-card key. A pure pricing module quotes every call: endpoints carry **list prices** on a rate card, customer types are **tier configs** (multiplier + monthly credit + optional default rule), customers may carry a **multiplier override**; billed price = `round(list × multiplier)`. Pricing lives in a `PricingStore` (SQLite in the server, in-memory in tests) and is editable at runtime through an admin API + vanilla-JS admin UI at `/admin` — changes take effect on the next request. Every call is recorded in a usage repository; a monthly job subtracts the tier credit and reports the billable remainder to a Stripe Billing Meter. A separate `zapier-app/` package (Zapier Platform CLI, plain JS) exposes API-key auth, a polling trigger, and a create action against the same API.
|
||
|
||
**Tech Stack:** Node 20, TypeScript (strict), Express 4, express-openapi-validator, swagger-ui-express, better-sqlite3, Jest + ts-jest + supertest, Stripe Node SDK (Billing Meters), Zapier Platform CLI.
|
||
|
||
## Global Constraints
|
||
|
||
- Project root: `/Users/marchon/zappier`. All paths below are relative to it; the server must be started from the project root (it loads `openapi.yaml` and `admin/` via `process.cwd()`).
|
||
- `openapi.yaml` is the API source of truth. Rate-card endpoint ids must match spec `operationId`s exactly.
|
||
- All money values are integer cents. List prices are computed first (per-KB / per-MB sizes rounded up), then the multiplier is applied once with `Math.round`.
|
||
- Free rules price at 0 cents on **every** tier, regardless of multiplier or overrides.
|
||
- Pricing is runtime-editable through the admin API/UI; changes take effect on the next request, no restart.
|
||
- Admin endpoints require the `x-admin-key` header (env `ADMIN_KEY`, dev default `admin-dev-key`). Never commit a real key.
|
||
- Customer-facing endpoints require the `x-api-key` header.
|
||
- Tests never touch real Stripe or Zapier accounts — unit tests use fakes; account steps are manual.
|
||
- Jest tests use in-memory repositories/stores. The server (`src/index.ts`) and the Stripe job use SQLite (`zappier.db`, override with the `ZAPPIER_DB` env var).
|
||
|
||
---
|
||
|
||
### Task 1: Project scaffold + pricing engine + pricing stores
|
||
|
||
**Files:**
|
||
- Create: `package.json`
|
||
- Create: `tsconfig.json`
|
||
- Create: `jest.config.js`
|
||
- Create: `src/pricing.ts`
|
||
- Test: `tests/pricing.test.ts`
|
||
- Modify: `README.md` (replace pricing section)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces:
|
||
- `export type PriceRule = { kind: 'free' } | { kind: 'fixed'; fixedCents: number } | { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number }`
|
||
- `export interface TierConfig { id: string; name: string; multiplier: number; monthlyCreditCents: number; defaultRule?: PriceRule }`
|
||
- `export interface RateCard { endpoints: Record<string, PriceRule> }`
|
||
- `export interface TierCatalog { find(id: string): TierConfig | undefined; list(): TierConfig[] }` and `export class ConfigTierCatalog implements TierCatalog` (constructor takes `TierConfig[]`)
|
||
- `export interface CallUsage { metadataBytes: number; attachmentBytes: number }`
|
||
- `export interface Quote { endpointId: string; listCents: number; totalCents: number; breakdown: { baseCents: number; metadataCents: number; attachmentCents: number } }`
|
||
- `export interface PricingContext { rateCard: RateCard; tiers: TierCatalog }`
|
||
- `export interface PricingStore { getRateCard(): RateCard; getTiers(): TierConfig[]; upsertEndpoint(endpointId: string, rule: PriceRule): void; deleteEndpoint(endpointId: string): void; upsertTier(tier: TierConfig): void; deleteTier(tierId: string): void }`
|
||
- `export class InMemoryPricingStore implements PricingStore` — constructor `(rateCard?: RateCard, tiers?: TierConfig[])`, defaults to the seeds below.
|
||
- `export const DEFAULT_RATE_CARD`, `DEFAULT_TIERS`, `DEFAULT_PRICING` (seed data; also used to seed SQLite in Task 7)
|
||
- `export function quoteCall(pricing: PricingContext, tierId: string, endpointId: string, usage: CallUsage, multiplierOverride?: number): Quote` — throws `Error("Unknown tier: <id>")` or `Error("No price rule for <tierId>/<endpointId>")`. `multiplierOverride` (per-customer) beats the tier multiplier.
|
||
|
||
- [ ] **Step 1: Scaffold the project**
|
||
|
||
Create `package.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "zappier",
|
||
"version": "0.1.0",
|
||
"private": true,
|
||
"scripts": {
|
||
"build": "tsc -p tsconfig.json",
|
||
"start": "node dist/index.js",
|
||
"dev": "ts-node src/index.ts",
|
||
"test": "jest"
|
||
},
|
||
"dependencies": {
|
||
"express": "^4.19.2",
|
||
"express-openapi-validator": "^5.3.0",
|
||
"stripe": "^16.0.0",
|
||
"swagger-ui-express": "^5.0.0",
|
||
"yamljs": "^0.3.0"
|
||
},
|
||
"devDependencies": {
|
||
"@types/express": "^4.17.21",
|
||
"@types/jest": "^29.5.12",
|
||
"@types/multer": "^1.4.11",
|
||
"@types/node": "^20.14.0",
|
||
"@types/supertest": "^6.0.2",
|
||
"@types/swagger-ui-express": "^4.1.6",
|
||
"@types/yamljs": "^0.2.34",
|
||
"jest": "^29.7.0",
|
||
"supertest": "^7.0.0",
|
||
"ts-jest": "^29.1.4",
|
||
"ts-node": "^10.9.2",
|
||
"typescript": "^5.5.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `tsconfig.json`:
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"module": "commonjs",
|
||
"outDir": "dist",
|
||
"rootDir": "src",
|
||
"strict": true,
|
||
"esModuleInterop": true,
|
||
"skipLibCheck": true,
|
||
"types": ["node", "jest", "multer"]
|
||
},
|
||
"include": ["src"]
|
||
}
|
||
```
|
||
|
||
Create `jest.config.js`:
|
||
|
||
```js
|
||
module.exports = {
|
||
preset: 'ts-jest',
|
||
testEnvironment: 'node',
|
||
roots: ['<rootDir>/tests'],
|
||
};
|
||
```
|
||
|
||
Run: `cd /Users/marchon/zappier && npm install`
|
||
Expected: installs cleanly, `node_modules/` exists.
|
||
|
||
- [ ] **Step 2: Write the failing pricing tests**
|
||
|
||
Create `tests/pricing.test.ts`:
|
||
|
||
```ts
|
||
import {
|
||
ConfigTierCatalog,
|
||
DEFAULT_PRICING,
|
||
DEFAULT_TIERS,
|
||
InMemoryPricingStore,
|
||
quoteCall,
|
||
} from '../src/pricing';
|
||
|
||
const noUsage = { metadataBytes: 0, attachmentBytes: 0 };
|
||
|
||
describe('quoteCall', () => {
|
||
it('prices free endpoints at 0 on every tier, ignoring the multiplier', () => {
|
||
for (const tier of DEFAULT_TIERS) {
|
||
expect(quoteCall(DEFAULT_PRICING, tier.id, 'status', noUsage).totalCents).toBe(0);
|
||
expect(quoteCall(DEFAULT_PRICING, tier.id, 'storage-list', noUsage).totalCents).toBe(0);
|
||
}
|
||
});
|
||
|
||
it('applies the tier multiplier to fixed list prices', () => {
|
||
// transform list price: 4 cents
|
||
expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage).totalCents).toBe(4);
|
||
expect(quoteCall(DEFAULT_PRICING, 'pro', 'transform', noUsage).totalCents).toBe(2);
|
||
expect(quoteCall(DEFAULT_PRICING, 'business', 'transform', noUsage).totalCents).toBe(1);
|
||
});
|
||
|
||
it('prices storage as base + per-KB metadata + per-MB attachments at list rates', () => {
|
||
const usage = { metadataBytes: 2048, attachmentBytes: 2 * 1024 * 1024 };
|
||
// list: 10 + 2 * 1 + 2 * 50 = 112
|
||
expect(quoteCall(DEFAULT_PRICING, 'free', 'storage', usage).totalCents).toBe(112);
|
||
expect(quoteCall(DEFAULT_PRICING, 'pro', 'storage', usage).totalCents).toBe(56);
|
||
expect(quoteCall(DEFAULT_PRICING, 'business', 'storage', usage).totalCents).toBe(28);
|
||
});
|
||
|
||
it('rounds partial KB and MB up before applying the multiplier', () => {
|
||
const q = quoteCall(DEFAULT_PRICING, 'free', 'storage', {
|
||
metadataBytes: 1,
|
||
attachmentBytes: 1,
|
||
});
|
||
// list: 10 + 1 KB * 1 + 1 MB * 50 = 61
|
||
expect(q.listCents).toBe(61);
|
||
expect(q.totalCents).toBe(61);
|
||
});
|
||
|
||
it('exposes the list-price breakdown and the multiplied total', () => {
|
||
const q = quoteCall(DEFAULT_PRICING, 'pro', 'storage', {
|
||
metadataBytes: 1024,
|
||
attachmentBytes: 0,
|
||
});
|
||
expect(q.breakdown).toEqual({ baseCents: 10, metadataCents: 1, attachmentCents: 0 });
|
||
expect(q.listCents).toBe(11);
|
||
expect(q.totalCents).toBe(6); // Math.round(11 * 0.5)
|
||
});
|
||
|
||
it('lets a per-customer multiplier override beat the tier multiplier', () => {
|
||
expect(quoteCall(DEFAULT_PRICING, 'free', 'transform', noUsage, 0.5).totalCents).toBe(2);
|
||
expect(quoteCall(DEFAULT_PRICING, 'free', 'status', noUsage, 0.5).totalCents).toBe(0);
|
||
});
|
||
|
||
it('falls back to the tier default rule for endpoints not on the rate card', () => {
|
||
// pro default rule: fixed 8 list -> round(8 * 0.5) = 4
|
||
expect(quoteCall(DEFAULT_PRICING, 'pro', 'experimental', noUsage).totalCents).toBe(4);
|
||
});
|
||
|
||
it('throws for an endpoint with no rate-card entry and no tier default', () => {
|
||
expect(() => quoteCall(DEFAULT_PRICING, 'free', 'experimental', noUsage)).toThrow(
|
||
'No price rule',
|
||
);
|
||
});
|
||
|
||
it('throws for an unknown tier', () => {
|
||
expect(() => quoteCall(DEFAULT_PRICING, 'platinum', 'status', noUsage)).toThrow(
|
||
'Unknown tier',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('ConfigTierCatalog', () => {
|
||
it('finds tiers by id and lists them', () => {
|
||
const catalog = new ConfigTierCatalog(DEFAULT_TIERS);
|
||
expect(catalog.find('pro')?.multiplier).toBe(0.5);
|
||
expect(catalog.find('nope')).toBeUndefined();
|
||
expect(catalog.list().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
|
||
});
|
||
|
||
it('supports adding a customer type as pure config', () => {
|
||
const catalog = new ConfigTierCatalog([
|
||
...DEFAULT_TIERS,
|
||
{ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 },
|
||
]);
|
||
expect(catalog.find('edu')?.name).toBe('Education');
|
||
});
|
||
});
|
||
|
||
describe('InMemoryPricingStore', () => {
|
||
it('seeds from the default rate card and tiers', () => {
|
||
const store = new InMemoryPricingStore();
|
||
expect(store.getRateCard().endpoints.status).toEqual({ kind: 'free' });
|
||
expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
|
||
});
|
||
|
||
it('upserts and deletes endpoints', () => {
|
||
const store = new InMemoryPricingStore();
|
||
store.upsertEndpoint('experimental', { kind: 'fixed', fixedCents: 9 });
|
||
expect(store.getRateCard().endpoints.experimental).toEqual({
|
||
kind: 'fixed',
|
||
fixedCents: 9,
|
||
});
|
||
store.deleteEndpoint('experimental');
|
||
expect(store.getRateCard().endpoints.experimental).toBeUndefined();
|
||
});
|
||
|
||
it('upserts and deletes tiers', () => {
|
||
const store = new InMemoryPricingStore();
|
||
store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.4, monthlyCreditCents: 500 });
|
||
expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.4);
|
||
store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 });
|
||
expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1);
|
||
store.deleteTier('edu');
|
||
expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
Run: `npm test`
|
||
Expected: FAIL — `Cannot find module '../src/pricing'`.
|
||
|
||
- [ ] **Step 4: Implement the pricing engine and stores**
|
||
|
||
Create `src/pricing.ts`:
|
||
|
||
```ts
|
||
export type PriceRule =
|
||
| { kind: 'free' }
|
||
| { kind: 'fixed'; fixedCents: number }
|
||
| { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number };
|
||
|
||
export interface TierConfig {
|
||
id: string;
|
||
name: string;
|
||
multiplier: number;
|
||
monthlyCreditCents: number;
|
||
defaultRule?: PriceRule;
|
||
}
|
||
|
||
export interface RateCard {
|
||
endpoints: Record<string, PriceRule>;
|
||
}
|
||
|
||
export interface TierCatalog {
|
||
find(id: string): TierConfig | undefined;
|
||
list(): TierConfig[];
|
||
}
|
||
|
||
export class ConfigTierCatalog implements TierCatalog {
|
||
constructor(private tiers: TierConfig[]) {}
|
||
|
||
find(id: string): TierConfig | undefined {
|
||
return this.tiers.find((t) => t.id === id);
|
||
}
|
||
|
||
list(): TierConfig[] {
|
||
return [...this.tiers];
|
||
}
|
||
}
|
||
|
||
export interface CallUsage {
|
||
metadataBytes: number;
|
||
attachmentBytes: number;
|
||
}
|
||
|
||
export interface Quote {
|
||
endpointId: string;
|
||
listCents: number;
|
||
totalCents: number;
|
||
breakdown: { baseCents: number; metadataCents: number; attachmentCents: number };
|
||
}
|
||
|
||
export interface PricingContext {
|
||
rateCard: RateCard;
|
||
tiers: TierCatalog;
|
||
}
|
||
|
||
export interface PricingStore {
|
||
getRateCard(): RateCard;
|
||
getTiers(): TierConfig[];
|
||
upsertEndpoint(endpointId: string, rule: PriceRule): void;
|
||
deleteEndpoint(endpointId: string): void;
|
||
upsertTier(tier: TierConfig): void;
|
||
deleteTier(tierId: string): void;
|
||
}
|
||
|
||
export const DEFAULT_RATE_CARD: RateCard = {
|
||
endpoints: {
|
||
status: { kind: 'free' },
|
||
'storage-list': { kind: 'free' },
|
||
transform: { kind: 'fixed', fixedCents: 4 },
|
||
storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 },
|
||
},
|
||
};
|
||
|
||
export const DEFAULT_TIERS: TierConfig[] = [
|
||
{ id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 },
|
||
{
|
||
id: 'pro',
|
||
name: 'Pro',
|
||
multiplier: 0.5,
|
||
monthlyCreditCents: 1000,
|
||
defaultRule: { kind: 'fixed', fixedCents: 8 },
|
||
},
|
||
{
|
||
id: 'business',
|
||
name: 'Business',
|
||
multiplier: 0.25,
|
||
monthlyCreditCents: 10000,
|
||
defaultRule: { kind: 'fixed', fixedCents: 8 },
|
||
},
|
||
];
|
||
|
||
export const DEFAULT_PRICING: PricingContext = {
|
||
rateCard: DEFAULT_RATE_CARD,
|
||
tiers: new ConfigTierCatalog(DEFAULT_TIERS),
|
||
};
|
||
|
||
export class InMemoryPricingStore implements PricingStore {
|
||
private endpoints: Record<string, PriceRule>;
|
||
private tiers: TierConfig[];
|
||
|
||
constructor(rateCard: RateCard = DEFAULT_RATE_CARD, tiers: TierConfig[] = DEFAULT_TIERS) {
|
||
this.endpoints = { ...rateCard.endpoints };
|
||
this.tiers = [...tiers];
|
||
}
|
||
|
||
getRateCard(): RateCard {
|
||
return { endpoints: { ...this.endpoints } };
|
||
}
|
||
|
||
getTiers(): TierConfig[] {
|
||
return [...this.tiers];
|
||
}
|
||
|
||
upsertEndpoint(endpointId: string, rule: PriceRule): void {
|
||
this.endpoints[endpointId] = rule;
|
||
}
|
||
|
||
deleteEndpoint(endpointId: string): void {
|
||
delete this.endpoints[endpointId];
|
||
}
|
||
|
||
upsertTier(tier: TierConfig): void {
|
||
const i = this.tiers.findIndex((t) => t.id === tier.id);
|
||
if (i >= 0) this.tiers[i] = tier;
|
||
else this.tiers.push(tier);
|
||
}
|
||
|
||
deleteTier(tierId: string): void {
|
||
this.tiers = this.tiers.filter((t) => t.id !== tierId);
|
||
}
|
||
}
|
||
|
||
export function quoteCall(
|
||
pricing: PricingContext,
|
||
tierId: string,
|
||
endpointId: string,
|
||
usage: CallUsage,
|
||
multiplierOverride?: number,
|
||
): Quote {
|
||
const tier = pricing.tiers.find(tierId);
|
||
if (!tier) throw new Error(`Unknown tier: ${tierId}`);
|
||
const rule = pricing.rateCard.endpoints[endpointId] ?? tier.defaultRule;
|
||
if (!rule) throw new Error(`No price rule for ${tierId}/${endpointId}`);
|
||
|
||
if (rule.kind === 'free') {
|
||
return {
|
||
endpointId,
|
||
listCents: 0,
|
||
totalCents: 0,
|
||
breakdown: { baseCents: 0, metadataCents: 0, attachmentCents: 0 },
|
||
};
|
||
}
|
||
|
||
let breakdown: Quote['breakdown'];
|
||
if (rule.kind === 'fixed') {
|
||
breakdown = { baseCents: rule.fixedCents, metadataCents: 0, attachmentCents: 0 };
|
||
} else {
|
||
breakdown = {
|
||
baseCents: rule.baseCents,
|
||
metadataCents: Math.ceil(usage.metadataBytes / 1024) * rule.perKbCents,
|
||
attachmentCents:
|
||
Math.ceil(usage.attachmentBytes / (1024 * 1024)) * rule.perMbCents,
|
||
};
|
||
}
|
||
const listCents = breakdown.baseCents + breakdown.metadataCents + breakdown.attachmentCents;
|
||
const multiplier = multiplierOverride ?? tier.multiplier;
|
||
return {
|
||
endpointId,
|
||
listCents,
|
||
totalCents: Math.round(listCents * multiplier),
|
||
breakdown,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS — 14 tests in `tests/pricing.test.ts`.
|
||
|
||
- [ ] **Step 6: Document the pricing model in the README**
|
||
|
||
Replace the body of `README.md` below the title with:
|
||
|
||
```markdown
|
||
## Pricing model
|
||
|
||
`openapi.yaml` defines the API surface; each `operationId` is a rate-card key.
|
||
Endpoints carry **list prices** (seed: `src/pricing.ts` → `DEFAULT_RATE_CARD`).
|
||
Customer types are **tier configs** (`DEFAULT_TIERS`) with a `multiplier`, a
|
||
`monthlyCreditCents` quota, and an optional `defaultRule` for endpoints not on the card.
|
||
Individual customers can carry a `multiplierOverride`.
|
||
Billed price = `round(list price × multiplier)`; usage up to the monthly credit is free.
|
||
Pricing is editable at runtime via the admin UI at `/admin` (see Task 8).
|
||
|
||
### Seed rate card (list prices, cents per call)
|
||
|
||
| Endpoint | Model | List price |
|
||
| -------------- | -------- | -------------------------------------------- |
|
||
| `status` | free | 0 |
|
||
| `storage-list` | free | 0 |
|
||
| `transform` | fixed | 4 |
|
||
| `storage` | variable | 10 + 1 per KB metadata + 50 per MB attached |
|
||
|
||
### Seed customer types
|
||
|
||
| Tier | Multiplier | Monthly credit | Default rule (unlisted endpoints) |
|
||
| ---------- | ---------- | -------------- | --------------------------------- |
|
||
| `free` | 1.0 | 100 cents | none — call rejected with 403 |
|
||
| `pro` | 0.5 | 1000 cents | fixed 8 list → 4 billed |
|
||
| `business` | 0.25 | 10000 cents | fixed 8 list → 2 billed |
|
||
|
||
Adding a new API call = add it to `openapi.yaml`, then price it in the admin UI.
|
||
Adding a customer type = create it in the admin UI. Variable pricing = base per call +
|
||
metadata size (rounded up to KB) + attachment size (rounded up to MB), then the multiplier.
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /Users/marchon/zappier && git init -q 2>/dev/null; git add -A
|
||
git commit -m "feat: scaffold project with rate-card pricing engine and pricing stores"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Usage log
|
||
|
||
**Files:**
|
||
- Create: `src/usage.ts`
|
||
- Test: `tests/usage.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing from Task 1 (independent module).
|
||
- Produces:
|
||
- `export interface UsageEntry { customerId: string; endpointId: string; cents: number; metadataBytes: number; attachmentBytes: number; timestamp: Date }`
|
||
- `export interface UsageSummary { customerId: string; totalCents: number; calls: number; byEndpoint: Record<string, { calls: number; cents: number }> }`
|
||
- `export function summarize(customerId: string, list: UsageEntry[]): UsageSummary` — shared aggregation helper (used again by the SQLite repo in Task 7).
|
||
- `export interface UsageRepo { record(entry: UsageEntry): void; listFor(customerId: string, since?: Date): UsageEntry[]; summaryFor(customerId: string, since?: Date): UsageSummary }`
|
||
- `export class InMemoryUsageRepo implements UsageRepo`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/usage.test.ts`:
|
||
|
||
```ts
|
||
import { InMemoryUsageRepo, UsageEntry } from '../src/usage';
|
||
|
||
const entry = (over: Partial<UsageEntry> = {}): UsageEntry => ({
|
||
customerId: 'cust_1',
|
||
endpointId: 'transform',
|
||
cents: 4,
|
||
metadataBytes: 0,
|
||
attachmentBytes: 0,
|
||
timestamp: new Date('2026-07-27T10:00:00Z'),
|
||
...over,
|
||
});
|
||
|
||
describe('InMemoryUsageRepo', () => {
|
||
it('records and lists entries per customer', () => {
|
||
const repo = new InMemoryUsageRepo();
|
||
repo.record(entry());
|
||
repo.record(entry({ customerId: 'cust_2' }));
|
||
expect(repo.listFor('cust_1')).toHaveLength(1);
|
||
expect(repo.listFor('cust_2')).toHaveLength(1);
|
||
});
|
||
|
||
it('filters entries by since date', () => {
|
||
const repo = new InMemoryUsageRepo();
|
||
repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') }));
|
||
repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') }));
|
||
expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1);
|
||
});
|
||
|
||
it('summarizes totals by endpoint', () => {
|
||
const repo = new InMemoryUsageRepo();
|
||
repo.record(entry());
|
||
repo.record(entry({ endpointId: 'storage', cents: 112 }));
|
||
repo.record(entry());
|
||
const s = repo.summaryFor('cust_1');
|
||
expect(s.customerId).toBe('cust_1');
|
||
expect(s.calls).toBe(3);
|
||
expect(s.totalCents).toBe(120);
|
||
expect(s.byEndpoint.transform).toEqual({ calls: 2, cents: 8 });
|
||
expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/usage.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/usage'`.
|
||
|
||
- [ ] **Step 3: Implement the usage repo**
|
||
|
||
Create `src/usage.ts`:
|
||
|
||
```ts
|
||
export interface UsageEntry {
|
||
customerId: string;
|
||
endpointId: string;
|
||
cents: number;
|
||
metadataBytes: number;
|
||
attachmentBytes: number;
|
||
timestamp: Date;
|
||
}
|
||
|
||
export interface UsageSummary {
|
||
customerId: string;
|
||
totalCents: number;
|
||
calls: number;
|
||
byEndpoint: Record<string, { calls: number; cents: number }>;
|
||
}
|
||
|
||
export function summarize(customerId: string, list: UsageEntry[]): UsageSummary {
|
||
const byEndpoint: UsageSummary['byEndpoint'] = {};
|
||
for (const e of list) {
|
||
const bucket = (byEndpoint[e.endpointId] ??= { calls: 0, cents: 0 });
|
||
bucket.calls += 1;
|
||
bucket.cents += e.cents;
|
||
}
|
||
return {
|
||
customerId,
|
||
calls: list.length,
|
||
totalCents: list.reduce((sum, e) => sum + e.cents, 0),
|
||
byEndpoint,
|
||
};
|
||
}
|
||
|
||
export interface UsageRepo {
|
||
record(entry: UsageEntry): void;
|
||
listFor(customerId: string, since?: Date): UsageEntry[];
|
||
summaryFor(customerId: string, since?: Date): UsageSummary;
|
||
}
|
||
|
||
export class InMemoryUsageRepo implements UsageRepo {
|
||
private entries: UsageEntry[] = [];
|
||
|
||
record(entry: UsageEntry): void {
|
||
this.entries.push(entry);
|
||
}
|
||
|
||
listFor(customerId: string, since?: Date): UsageEntry[] {
|
||
return this.entries.filter(
|
||
(e) => e.customerId === customerId && (!since || e.timestamp >= since),
|
||
);
|
||
}
|
||
|
||
summaryFor(customerId: string, since?: Date): UsageSummary {
|
||
return summarize(customerId, this.listFor(customerId, since));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- tests/usage.test.ts`
|
||
Expected: PASS — 3 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/usage.ts tests/usage.test.ts
|
||
git commit -m "feat: add usage log with shared summarizer and per-customer summaries"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: API-key auth + customer model
|
||
|
||
**Files:**
|
||
- Create: `src/auth.ts`
|
||
- Test: `tests/auth.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing (a customer's tier is a plain `tierId` string resolved by `PricingContext.tiers` at quote time — auth never imports pricing).
|
||
- Produces:
|
||
- `export interface Customer { id: string; name: string; tierId: string; apiKey: string; stripeCustomerId?: string; multiplierOverride?: number }`
|
||
- `export interface CustomerRepo { findByApiKey(apiKey: string): Customer | undefined; list(): Customer[]; save(customer: Customer): void }` — `save` upserts by `id` (used by the admin API in Task 8).
|
||
- `export class InMemoryCustomerRepo implements CustomerRepo` — constructor takes `Customer[]`.
|
||
- `export function apiKeyAuth(repo: CustomerRepo): RequestHandler` — reads `x-api-key` header, sets `req.customer`, else 401 JSON `{ error: 'invalid or missing API key' }`.
|
||
- Global augmentation: `Express.Request.customer?: Customer`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/auth.test.ts`:
|
||
|
||
```ts
|
||
import express from 'express';
|
||
import request from 'supertest';
|
||
import { apiKeyAuth, InMemoryCustomerRepo } from '../src/auth';
|
||
|
||
const repo = new InMemoryCustomerRepo([
|
||
{ id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' },
|
||
{ id: 'cust_2', name: 'Grace', tierId: 'business', apiKey: 'key-grace', stripeCustomerId: 'cus_123' },
|
||
]);
|
||
|
||
const app = express();
|
||
app.use(apiKeyAuth(repo));
|
||
app.get('/ping', (req, res) =>
|
||
res.json({ customerId: req.customer!.id, tierId: req.customer!.tierId }),
|
||
);
|
||
|
||
describe('apiKeyAuth', () => {
|
||
it('rejects a missing key with 401', async () => {
|
||
const res = await request(app).get('/ping');
|
||
expect(res.status).toBe(401);
|
||
expect(res.body.error).toMatch(/API key/);
|
||
});
|
||
|
||
it('rejects an unknown key with 401', async () => {
|
||
const res = await request(app).get('/ping').set('x-api-key', 'wrong');
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('attaches the customer for a valid key', async () => {
|
||
const res = await request(app).get('/ping').set('x-api-key', 'key-ada');
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toEqual({ customerId: 'cust_1', tierId: 'pro' });
|
||
});
|
||
});
|
||
|
||
describe('InMemoryCustomerRepo', () => {
|
||
it('lists all customers', () => {
|
||
expect(repo.list().map((c) => c.id)).toEqual(['cust_1', 'cust_2']);
|
||
});
|
||
|
||
it('save() upserts by id', () => {
|
||
const local = new InMemoryCustomerRepo([
|
||
{ id: 'cust_1', name: 'Ada', tierId: 'pro', apiKey: 'key-ada' },
|
||
]);
|
||
local.save({ id: 'cust_1', name: 'Ada', tierId: 'business', apiKey: 'key-ada', multiplierOverride: 0.4 });
|
||
local.save({ id: 'cust_9', name: 'New', tierId: 'free', apiKey: 'key-new' });
|
||
expect(local.list()).toHaveLength(2);
|
||
const updated = local.findByApiKey('key-ada');
|
||
expect(updated?.tierId).toBe('business');
|
||
expect(updated?.multiplierOverride).toBe(0.4);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/auth.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/auth'`.
|
||
|
||
- [ ] **Step 3: Implement auth**
|
||
|
||
Create `src/auth.ts`:
|
||
|
||
```ts
|
||
import { RequestHandler } from 'express';
|
||
|
||
export interface Customer {
|
||
id: string;
|
||
name: string;
|
||
tierId: string;
|
||
apiKey: string;
|
||
stripeCustomerId?: string;
|
||
multiplierOverride?: number;
|
||
}
|
||
|
||
export interface CustomerRepo {
|
||
findByApiKey(apiKey: string): Customer | undefined;
|
||
list(): Customer[];
|
||
save(customer: Customer): void;
|
||
}
|
||
|
||
export class InMemoryCustomerRepo implements CustomerRepo {
|
||
constructor(private customers: Customer[] = []) {}
|
||
|
||
findByApiKey(apiKey: string): Customer | undefined {
|
||
return this.customers.find((c) => c.apiKey === apiKey);
|
||
}
|
||
|
||
list(): Customer[] {
|
||
return [...this.customers];
|
||
}
|
||
|
||
save(customer: Customer): void {
|
||
const i = this.customers.findIndex((c) => c.id === customer.id);
|
||
if (i >= 0) this.customers[i] = customer;
|
||
else this.customers.push(customer);
|
||
}
|
||
}
|
||
|
||
declare global {
|
||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||
namespace Express {
|
||
interface Request {
|
||
customer?: Customer;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function apiKeyAuth(repo: CustomerRepo): RequestHandler {
|
||
return (req, res, next) => {
|
||
const key = req.header('x-api-key');
|
||
const customer = key ? repo.findByApiKey(key) : undefined;
|
||
if (!customer) {
|
||
res.status(401).json({ error: 'invalid or missing API key' });
|
||
return;
|
||
}
|
||
req.customer = customer;
|
||
next();
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- tests/auth.test.ts`
|
||
Expected: PASS — 5 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/auth.ts tests/auth.test.ts
|
||
git commit -m "feat: add API-key auth and upsertable customer model with tier overrides"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Metering middleware
|
||
|
||
**Files:**
|
||
- Create: `src/meter.ts`
|
||
- Test: `tests/meter.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `quoteCall`, `PricingContext`, `Quote`, `DEFAULT_PRICING` from `src/pricing.ts`; `UsageRepo` from `src/usage.ts`; `req.customer` (with `tierId` and `multiplierOverride`) from `src/auth.ts`.
|
||
- Produces: `export function meter(endpointId: string, repo: UsageRepo, pricing: PricingContext): RequestHandler` — computes `metadataBytes` from `req.body.metadata` (byte length of its JSON form, `{}` when absent) and `attachmentBytes` from `req.files` (Multer-shaped array provided by express-openapi-validator, 0 when absent); quotes via `quoteCall(pricing, req.customer.tierId, endpointId, usage, req.customer.multiplierOverride)`; on a pricing error responds **403** with the error message and records nothing; otherwise records a `UsageEntry` with `cents = quote.totalCents` and sets `res.locals.quote`. Must run **after** `apiKeyAuth` and after the OpenAPI validator (which parses multipart bodies).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/meter.test.ts`:
|
||
|
||
```ts
|
||
import express from 'express';
|
||
import request from 'supertest';
|
||
import { apiKeyAuth, Customer, InMemoryCustomerRepo } from '../src/auth';
|
||
import { meter } from '../src/meter';
|
||
import { DEFAULT_PRICING } from '../src/pricing';
|
||
import { InMemoryUsageRepo } from '../src/usage';
|
||
|
||
const FREE_ADA: Customer = { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' };
|
||
|
||
function buildApp(seed: Customer[] = [FREE_ADA]) {
|
||
const customers = new InMemoryCustomerRepo(seed);
|
||
const usage = new InMemoryUsageRepo();
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use(apiKeyAuth(customers));
|
||
app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) =>
|
||
res.json({ quote: res.locals.quote }),
|
||
);
|
||
app.post('/storage', meter('storage', usage, DEFAULT_PRICING), (req, res) =>
|
||
res.json({ quote: res.locals.quote }),
|
||
);
|
||
app.post('/experimental', meter('experimental', usage, DEFAULT_PRICING), (req, res) =>
|
||
res.json({ quote: res.locals.quote }),
|
||
);
|
||
return { app, usage };
|
||
}
|
||
|
||
describe('meter middleware', () => {
|
||
it('quotes a fixed endpoint at the multiplied tier price and records usage', async () => {
|
||
const { app, usage } = buildApp();
|
||
const res = await request(app)
|
||
.post('/transform')
|
||
.set('x-api-key', 'key-ada')
|
||
.send({ text: 'hi' });
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.quote.totalCents).toBe(4); // free tier, multiplier 1
|
||
const entries = usage.listFor('cust_1');
|
||
expect(entries).toHaveLength(1);
|
||
expect(entries[0].endpointId).toBe('transform');
|
||
expect(entries[0].cents).toBe(4);
|
||
});
|
||
|
||
it('charges per KB of metadata on variable endpoints', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app)
|
||
.post('/storage')
|
||
.set('x-api-key', 'key-ada')
|
||
.send({ metadata: { note: 'x'.repeat(2048) } });
|
||
// JSON of metadata is 2059 bytes -> 3 KB -> list 10 + 3 * 1 = 13
|
||
expect(res.body.quote.totalCents).toBe(13);
|
||
});
|
||
|
||
it('applies a per-customer multiplier override', async () => {
|
||
const { app } = buildApp([
|
||
{ id: 'cust_vip', name: 'Vip', tierId: 'free', apiKey: 'key-vip', multiplierOverride: 0.5 },
|
||
]);
|
||
const res = await request(app)
|
||
.post('/transform')
|
||
.set('x-api-key', 'key-vip')
|
||
.send({ text: 'hi' });
|
||
expect(res.body.quote.totalCents).toBe(2); // list 4 x override 0.5
|
||
});
|
||
|
||
it('returns 401 when no customer is attached', async () => {
|
||
const usage = new InMemoryUsageRepo();
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.post('/transform', meter('transform', usage, DEFAULT_PRICING), (req, res) =>
|
||
res.json({}),
|
||
);
|
||
const res = await request(app).post('/transform').send({ text: 'hi' });
|
||
expect(res.status).toBe(401);
|
||
expect(usage.listFor('cust_1')).toHaveLength(0);
|
||
});
|
||
|
||
it('returns 403 for an endpoint with no price rule on the caller tier', async () => {
|
||
const { app, usage } = buildApp();
|
||
const res = await request(app)
|
||
.post('/experimental')
|
||
.set('x-api-key', 'key-ada')
|
||
.send({});
|
||
expect(res.status).toBe(403);
|
||
expect(res.body.error).toMatch(/No price rule/);
|
||
expect(usage.listFor('cust_1')).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/meter.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/meter'`.
|
||
|
||
- [ ] **Step 3: Implement the middleware**
|
||
|
||
Create `src/meter.ts`:
|
||
|
||
```ts
|
||
import { RequestHandler } from 'express';
|
||
import { PricingContext, Quote, quoteCall } from './pricing';
|
||
import { UsageRepo } from './usage';
|
||
|
||
export function meter(
|
||
endpointId: string,
|
||
repo: UsageRepo,
|
||
pricing: PricingContext,
|
||
): RequestHandler {
|
||
return (req, res, next) => {
|
||
const customer = req.customer;
|
||
if (!customer) {
|
||
res.status(401).json({ error: 'unauthenticated' });
|
||
return;
|
||
}
|
||
const metadataBytes = Buffer.byteLength(
|
||
JSON.stringify(req.body?.metadata ?? {}),
|
||
'utf8',
|
||
);
|
||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||
const attachmentBytes = files.reduce((sum, f) => sum + f.size, 0);
|
||
|
||
let quote: Quote;
|
||
try {
|
||
quote = quoteCall(
|
||
pricing,
|
||
customer.tierId,
|
||
endpointId,
|
||
{ metadataBytes, attachmentBytes },
|
||
customer.multiplierOverride,
|
||
);
|
||
} catch (err) {
|
||
res.status(403).json({ error: (err as Error).message });
|
||
return;
|
||
}
|
||
|
||
repo.record({
|
||
customerId: customer.id,
|
||
endpointId,
|
||
cents: quote.totalCents,
|
||
metadataBytes,
|
||
attachmentBytes,
|
||
timestamp: new Date(),
|
||
});
|
||
res.locals.quote = quote;
|
||
next();
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- tests/meter.test.ts`
|
||
Expected: PASS — 5 tests.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/meter.ts tests/meter.test.ts
|
||
git commit -m "feat: add metering middleware with tier overrides and 403 for unpriced calls"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: OpenAPI-driven HTTP API + docs
|
||
|
||
**Files:**
|
||
- Create: `openapi.yaml`
|
||
- Create: `src/app.ts`
|
||
- Create: `src/index.ts`
|
||
- Test: `tests/app.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `apiKeyAuth`, `InMemoryCustomerRepo`, `CustomerRepo`, `Customer` (Task 3); `meter` (Task 4); `UsageRepo`, `InMemoryUsageRepo` (Task 2); `PricingContext`, `PricingStore`, `InMemoryPricingStore`, `ConfigTierCatalog` (Task 1).
|
||
- Produces:
|
||
- `openapi.yaml` — the API source of truth; `operationId`s: `status`, `transform`, `storage`, `storage-list`, `usage` (first four are rate-card keys).
|
||
- `export interface StoredItem { id: string; customerId: string; metadata: Record<string, unknown>; attachments: { filename: string; size: number }[]; createdAt: string }`
|
||
- `export const DEFAULT_CUSTOMERS: Customer[]` — `key-ada` (free), `key-grace` (pro), `key-linus` (business).
|
||
- `export interface AppDeps { usage?: UsageRepo; customers?: CustomerRepo; pricingStore?: PricingStore }`
|
||
- `export function buildApp(deps?: AppDeps): { app: Express; usage: UsageRepo; customers: CustomerRepo; pricing: PricingContext; pricingStore: PricingStore; items: StoredItem[] }` — `pricing` is a **live** context whose getters read the store on every call, so admin edits (Task 8) take effect immediately.
|
||
- `GET /docs` — Swagger UI rendering `openapi.yaml`.
|
||
|
||
- [ ] **Step 1: Write the OpenAPI spec**
|
||
|
||
Create `openapi.yaml`:
|
||
|
||
```yaml
|
||
openapi: 3.0.3
|
||
info:
|
||
title: Zappier API
|
||
version: 0.1.0
|
||
description: Metered data API. Every priced call returns its quote.
|
||
security:
|
||
- apiKey: []
|
||
paths:
|
||
/v1/status:
|
||
get:
|
||
operationId: status
|
||
summary: Service status (free)
|
||
responses:
|
||
'200':
|
||
description: OK
|
||
/v1/transform:
|
||
post:
|
||
operationId: transform
|
||
summary: Uppercase a string (fixed price)
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [text]
|
||
properties:
|
||
text:
|
||
type: string
|
||
responses:
|
||
'200':
|
||
description: OK
|
||
/v1/storage:
|
||
post:
|
||
operationId: storage
|
||
summary: Store metadata and file attachments (variable price by size)
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
multipart/form-data:
|
||
schema:
|
||
type: object
|
||
properties:
|
||
metadata:
|
||
type: string
|
||
description: JSON string of metadata
|
||
attachments:
|
||
type: array
|
||
items:
|
||
type: string
|
||
format: binary
|
||
responses:
|
||
'200':
|
||
description: OK
|
||
get:
|
||
operationId: storage-list
|
||
summary: List your stored items (free)
|
||
responses:
|
||
'200':
|
||
description: OK
|
||
/v1/usage:
|
||
get:
|
||
operationId: usage
|
||
summary: Your usage summary for the current period
|
||
responses:
|
||
'200':
|
||
description: OK
|
||
components:
|
||
securitySchemes:
|
||
apiKey:
|
||
type: apiKey
|
||
in: header
|
||
name: x-api-key
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Create `tests/app.test.ts`:
|
||
|
||
```ts
|
||
import request from 'supertest';
|
||
import { buildApp } from '../src/app';
|
||
|
||
const KEY = 'key-ada'; // seeded free-tier customer
|
||
|
||
describe('Zappier API', () => {
|
||
it('rejects calls without an API key', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app).get('/v1/status');
|
||
expect(res.status).toBe(401);
|
||
});
|
||
|
||
it('GET /v1/status is free', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app).get('/v1/status').set('x-api-key', KEY);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.status).toBe('ok');
|
||
expect(res.body.quote.totalCents).toBe(0);
|
||
});
|
||
|
||
it('POST /v1/transform uppercases text at the multiplied fixed price', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', KEY)
|
||
.send({ text: 'hello' });
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.output).toBe('HELLO');
|
||
expect(res.body.quote.totalCents).toBe(4); // list 4 x free-tier multiplier 1
|
||
});
|
||
|
||
it('rejects a request that violates the OpenAPI schema with 400', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', KEY)
|
||
.send({ wrong: 1 });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('POST /v1/storage stores metadata plus attachments and quotes by size', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app)
|
||
.post('/v1/storage')
|
||
.set('x-api-key', KEY)
|
||
.field('metadata', JSON.stringify({ title: 'report' }))
|
||
.attach('attachments', Buffer.alloc(1024 * 1024), 'one.bin')
|
||
.attach('attachments', Buffer.alloc(1024 * 1024), 'two.bin');
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.id).toBeTruthy();
|
||
// metadata string is 20 bytes -> 1 KB; 2 MB attachments
|
||
// list 10 + 1 * 1 + 2 * 50 = 111, free-tier multiplier 1
|
||
expect(res.body.quote.totalCents).toBe(111);
|
||
});
|
||
|
||
it('GET /v1/storage lists the caller items newest first', async () => {
|
||
const { app } = buildApp();
|
||
await request(app)
|
||
.post('/v1/storage')
|
||
.set('x-api-key', KEY)
|
||
.field('metadata', JSON.stringify({ title: 'a' }));
|
||
await request(app)
|
||
.post('/v1/storage')
|
||
.set('x-api-key', KEY)
|
||
.field('metadata', JSON.stringify({ title: 'b' }));
|
||
const res = await request(app).get('/v1/storage').set('x-api-key', KEY);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.items).toHaveLength(2);
|
||
expect(res.body.items[0].metadata.title).toBe('b');
|
||
});
|
||
|
||
it('GET /v1/usage returns the caller summary', async () => {
|
||
const { app } = buildApp();
|
||
await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', KEY)
|
||
.send({ text: 'x' });
|
||
const res = await request(app).get('/v1/usage').set('x-api-key', KEY);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.customerId).toBe('cust_1');
|
||
expect(res.body.calls).toBe(1);
|
||
expect(res.body.totalCents).toBe(4);
|
||
});
|
||
|
||
it('charges less for a business-tier customer', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', 'key-linus')
|
||
.send({ text: 'hello' });
|
||
expect(res.body.quote.totalCents).toBe(1); // list 4 x business multiplier 0.25
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/app.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/app'`.
|
||
|
||
- [ ] **Step 4: Implement the app and server**
|
||
|
||
Create `src/app.ts`:
|
||
|
||
```ts
|
||
import path from 'path';
|
||
import { randomUUID } from 'crypto';
|
||
import express, { Express, NextFunction, Request, Response } from 'express';
|
||
import * as OpenApiValidator from 'express-openapi-validator';
|
||
import swaggerUi from 'swagger-ui-express';
|
||
import YAML from 'yamljs';
|
||
import {
|
||
apiKeyAuth,
|
||
Customer,
|
||
CustomerRepo,
|
||
InMemoryCustomerRepo,
|
||
} from './auth';
|
||
import { meter } from './meter';
|
||
import {
|
||
ConfigTierCatalog,
|
||
InMemoryPricingStore,
|
||
PricingContext,
|
||
PricingStore,
|
||
} from './pricing';
|
||
import { InMemoryUsageRepo, UsageRepo } from './usage';
|
||
|
||
export interface StoredItem {
|
||
id: string;
|
||
customerId: string;
|
||
metadata: Record<string, unknown>;
|
||
attachments: { filename: string; size: number }[];
|
||
createdAt: string;
|
||
}
|
||
|
||
export const DEFAULT_CUSTOMERS: Customer[] = [
|
||
{ id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' },
|
||
{ id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' },
|
||
{ id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' },
|
||
];
|
||
|
||
export interface AppDeps {
|
||
usage?: UsageRepo;
|
||
customers?: CustomerRepo;
|
||
pricingStore?: PricingStore;
|
||
}
|
||
|
||
const SPEC_PATH = path.join(process.cwd(), 'openapi.yaml');
|
||
|
||
export function buildApp(deps: AppDeps = {}): {
|
||
app: Express;
|
||
usage: UsageRepo;
|
||
customers: CustomerRepo;
|
||
pricing: PricingContext;
|
||
pricingStore: PricingStore;
|
||
items: StoredItem[];
|
||
} {
|
||
const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS);
|
||
const usage = deps.usage ?? new InMemoryUsageRepo();
|
||
const pricingStore = deps.pricingStore ?? new InMemoryPricingStore();
|
||
// Live pricing context: every quote reads the store, so admin edits apply immediately.
|
||
const pricing: PricingContext = {
|
||
get rateCard() {
|
||
return pricingStore.getRateCard();
|
||
},
|
||
get tiers() {
|
||
return new ConfigTierCatalog(pricingStore.getTiers());
|
||
},
|
||
};
|
||
const items: StoredItem[] = [];
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
|
||
const spec = YAML.load(SPEC_PATH);
|
||
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
|
||
|
||
app.use('/v1', apiKeyAuth(customers));
|
||
app.use(
|
||
OpenApiValidator.middleware({
|
||
apiSpec: SPEC_PATH,
|
||
validateRequests: true,
|
||
validateResponses: false,
|
||
}),
|
||
);
|
||
|
||
app.get('/v1/status', meter('status', usage, pricing), (req, res) => {
|
||
res.json({ status: 'ok', quote: res.locals.quote });
|
||
});
|
||
|
||
app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => {
|
||
const text = String(req.body?.text ?? '');
|
||
res.json({ output: text.toUpperCase(), quote: res.locals.quote });
|
||
});
|
||
|
||
app.post('/v1/storage', meter('storage', usage, pricing), (req, res) => {
|
||
const raw = req.body?.metadata;
|
||
const metadata =
|
||
typeof raw === 'string' ? JSON.parse(raw) : ((raw ?? {}) as Record<string, unknown>);
|
||
const files = (req.files as Express.Multer.File[]) ?? [];
|
||
const item: StoredItem = {
|
||
id: randomUUID(),
|
||
customerId: req.customer!.id,
|
||
metadata,
|
||
attachments: files.map((f) => ({ filename: f.originalname, size: f.size })),
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
items.unshift(item);
|
||
res.json({ id: item.id, quote: res.locals.quote });
|
||
});
|
||
|
||
app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => {
|
||
res.json({ items: items.filter((i) => i.customerId === req.customer!.id) });
|
||
});
|
||
|
||
app.get('/v1/usage', (req, res) => {
|
||
res.json(usage.summaryFor(req.customer!.id));
|
||
});
|
||
|
||
app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => {
|
||
res.status(err.status ?? 500).json({ error: err.message });
|
||
});
|
||
|
||
return { app, usage, customers, pricing, pricingStore, items };
|
||
}
|
||
```
|
||
|
||
Create `src/index.ts` (in-memory for now; Task 7 swaps in SQLite):
|
||
|
||
```ts
|
||
import { buildApp } from './app';
|
||
|
||
const { app } = buildApp();
|
||
const port = Number(process.env.PORT ?? 3000);
|
||
app.listen(port, () => {
|
||
console.log(`Zappier API listening on http://localhost:${port}`);
|
||
console.log(`OpenAPI docs at http://localhost:${port}/docs`);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS — all suites, 35 tests total.
|
||
|
||
- [ ] **Step 6: Smoke-test the running server**
|
||
|
||
Run: `npm run dev` (in one terminal), then:
|
||
|
||
```bash
|
||
curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/status
|
||
curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \
|
||
-d '{"text":"hello"}' http://localhost:3000/v1/transform
|
||
curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \
|
||
-d '{"wrong":1}' http://localhost:3000/v1/transform
|
||
```
|
||
|
||
Expected: `{"status":"ok",...}`, then `{"output":"HELLO",...}`, then a 400 validation error. Open http://localhost:3000/docs in a browser — Swagger UI renders the spec. Stop the server (Ctrl+C).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add openapi.yaml src/app.ts src/index.ts tests/app.test.ts
|
||
git commit -m "feat: add OpenAPI-driven metered API with Swagger docs"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Monthly credit quotas
|
||
|
||
**Files:**
|
||
- Create: `src/billing/credit.ts`
|
||
- Test: `tests/credit.test.ts`
|
||
- Modify: `src/app.ts` (`GET /v1/usage` route)
|
||
- Modify: `tests/app.test.ts` (usage-summary test)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `UsageSummary` (Task 2); `TierConfig`, `PricingContext` (Task 1); `buildApp`'s `pricing` and `usage` (Task 5).
|
||
- Produces:
|
||
- `export interface BilledSummary extends UsageSummary { includedCents: number; billableCents: number }`
|
||
- `export function applyMonthlyCredit(summary: UsageSummary, tier: TierConfig): BilledSummary` — `includedCents = min(totalCents, tier.monthlyCreditCents)`, `billableCents = totalCents - includedCents`. Used by the `/v1/usage` route here and by the Stripe job in Task 9.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/credit.test.ts`:
|
||
|
||
```ts
|
||
import { applyMonthlyCredit } from '../src/billing/credit';
|
||
import { DEFAULT_TIERS } from '../src/pricing';
|
||
import { UsageSummary } from '../src/usage';
|
||
|
||
const freeTier = DEFAULT_TIERS.find((t) => t.id === 'free')!; // monthlyCreditCents: 100
|
||
|
||
const summary = (totalCents: number): UsageSummary => ({
|
||
customerId: 'cust_1',
|
||
totalCents,
|
||
calls: 1,
|
||
byEndpoint: {},
|
||
});
|
||
|
||
describe('applyMonthlyCredit', () => {
|
||
it('covers usage fully when under the monthly credit', () => {
|
||
const billed = applyMonthlyCredit(summary(60), freeTier);
|
||
expect(billed.includedCents).toBe(60);
|
||
expect(billed.billableCents).toBe(0);
|
||
expect(billed.totalCents).toBe(60);
|
||
});
|
||
|
||
it('bills only the overage when usage exceeds the credit', () => {
|
||
const billed = applyMonthlyCredit(summary(250), freeTier);
|
||
expect(billed.includedCents).toBe(100);
|
||
expect(billed.billableCents).toBe(150);
|
||
});
|
||
|
||
it('bills nothing when there is no usage', () => {
|
||
const billed = applyMonthlyCredit(summary(0), freeTier);
|
||
expect(billed.includedCents).toBe(0);
|
||
expect(billed.billableCents).toBe(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/credit.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/billing/credit'`.
|
||
|
||
- [ ] **Step 3: Implement the credit module**
|
||
|
||
Create `src/billing/credit.ts`:
|
||
|
||
```ts
|
||
import { TierConfig } from '../pricing';
|
||
import { UsageSummary } from '../usage';
|
||
|
||
export interface BilledSummary extends UsageSummary {
|
||
includedCents: number;
|
||
billableCents: number;
|
||
}
|
||
|
||
export function applyMonthlyCredit(
|
||
summary: UsageSummary,
|
||
tier: TierConfig,
|
||
): BilledSummary {
|
||
const includedCents = Math.min(summary.totalCents, tier.monthlyCreditCents);
|
||
return {
|
||
...summary,
|
||
includedCents,
|
||
billableCents: summary.totalCents - includedCents,
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npm test -- tests/credit.test.ts`
|
||
Expected: PASS — 3 tests.
|
||
|
||
- [ ] **Step 5: Wire the credit into `GET /v1/usage`**
|
||
|
||
In `src/app.ts`, add the import:
|
||
|
||
```ts
|
||
import { applyMonthlyCredit } from './billing/credit';
|
||
```
|
||
|
||
Replace the `/v1/usage` route with:
|
||
|
||
```ts
|
||
app.get('/v1/usage', (req, res) => {
|
||
const summary = usage.summaryFor(req.customer!.id);
|
||
const tier = pricing.tiers.find(req.customer!.tierId);
|
||
res.json(tier ? applyMonthlyCredit(summary, tier) : summary);
|
||
});
|
||
```
|
||
|
||
Replace the usage test in `tests/app.test.ts` with:
|
||
|
||
```ts
|
||
it('GET /v1/usage returns the caller summary with monthly credit applied', async () => {
|
||
const { app } = buildApp();
|
||
await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', KEY)
|
||
.send({ text: 'x' });
|
||
const res = await request(app).get('/v1/usage').set('x-api-key', KEY);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.customerId).toBe('cust_1');
|
||
expect(res.body.calls).toBe(1);
|
||
expect(res.body.totalCents).toBe(4);
|
||
expect(res.body.includedCents).toBe(4); // free-tier credit (100) covers it
|
||
expect(res.body.billableCents).toBe(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run the full suite**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS — 38 tests total.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/billing/credit.ts src/app.ts tests/credit.test.ts tests/app.test.ts
|
||
git commit -m "feat: apply monthly credit quotas to usage summaries"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: SQLite persistence (usage, customers, pricing)
|
||
|
||
**Files:**
|
||
- Modify: `package.json` (via npm install)
|
||
- Create: `src/db/usage-repo.ts`
|
||
- Create: `src/db/customer-repo.ts`
|
||
- Create: `src/db/pricing-store.ts`
|
||
- Modify: `src/index.ts` (inject SQLite repos + store)
|
||
- Test: `tests/db-usage.test.ts`
|
||
- Test: `tests/db-customer.test.ts`
|
||
- Test: `tests/db-pricing.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `UsageRepo`, `UsageEntry`, `UsageSummary`, `summarize` (Task 2); `CustomerRepo`, `Customer` (Task 3); `PricingStore`, `PriceRule`, `TierConfig`, `RateCard`, `DEFAULT_RATE_CARD`, `DEFAULT_TIERS` (Task 1); `AppDeps`, `DEFAULT_CUSTOMERS`, `buildApp` (Task 5).
|
||
- Produces:
|
||
- `export class SqliteUsageRepo implements UsageRepo` — `constructor(db: Database.Database)`; creates table `usage_entries` on first use.
|
||
- `export class SqliteCustomerRepo implements CustomerRepo` — `constructor(db: Database.Database, seed?: Customer[])`; creates table `customers` (with `multiplier_override`) and inserts `seed` only when the table is empty. Implements `save` as SQL upsert.
|
||
- `export class SqlitePricingStore implements PricingStore` — `constructor(db: Database.Database, seedCard?: RateCard, seedTiers?: TierConfig[])`; creates tables `price_endpoints` + `tiers` and seeds them only when empty.
|
||
- All three are injected into `buildApp({ usage, customers, pricingStore })` from `src/index.ts`; no route or middleware changes.
|
||
|
||
- [ ] **Step 1: Install better-sqlite3**
|
||
|
||
Run: `cd /Users/marchon/zappier && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3`
|
||
Expected: both added to `package.json`.
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Create `tests/db-usage.test.ts`:
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import { SqliteUsageRepo } from '../src/db/usage-repo';
|
||
import { UsageEntry } from '../src/usage';
|
||
|
||
const entry = (over: Partial<UsageEntry> = {}): UsageEntry => ({
|
||
customerId: 'cust_1',
|
||
endpointId: 'transform',
|
||
cents: 4,
|
||
metadataBytes: 0,
|
||
attachmentBytes: 0,
|
||
timestamp: new Date('2026-07-27T10:00:00Z'),
|
||
...over,
|
||
});
|
||
|
||
describe('SqliteUsageRepo', () => {
|
||
it('records and lists entries per customer', () => {
|
||
const repo = new SqliteUsageRepo(new Database(':memory:'));
|
||
repo.record(entry());
|
||
repo.record(entry({ customerId: 'cust_2' }));
|
||
expect(repo.listFor('cust_1')).toHaveLength(1);
|
||
expect(repo.listFor('cust_2')).toHaveLength(1);
|
||
});
|
||
|
||
it('filters entries by since date', () => {
|
||
const repo = new SqliteUsageRepo(new Database(':memory:'));
|
||
repo.record(entry({ timestamp: new Date('2026-07-01T00:00:00Z') }));
|
||
repo.record(entry({ timestamp: new Date('2026-07-27T00:00:00Z') }));
|
||
expect(repo.listFor('cust_1', new Date('2026-07-15T00:00:00Z'))).toHaveLength(1);
|
||
});
|
||
|
||
it('summarizes totals by endpoint', () => {
|
||
const repo = new SqliteUsageRepo(new Database(':memory:'));
|
||
repo.record(entry());
|
||
repo.record(entry({ endpointId: 'storage', cents: 112 }));
|
||
const s = repo.summaryFor('cust_1');
|
||
expect(s.calls).toBe(2);
|
||
expect(s.totalCents).toBe(116);
|
||
expect(s.byEndpoint.storage).toEqual({ calls: 1, cents: 112 });
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `tests/db-customer.test.ts`:
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import { Customer } from '../src/auth';
|
||
import { SqliteCustomerRepo } from '../src/db/customer-repo';
|
||
|
||
const customer = (apiKey: string): Customer => ({
|
||
id: 'cust_1',
|
||
name: 'Ada',
|
||
tierId: 'pro',
|
||
apiKey,
|
||
stripeCustomerId: 'cus_123',
|
||
});
|
||
|
||
describe('SqliteCustomerRepo', () => {
|
||
it('finds a customer by API key', () => {
|
||
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
|
||
expect(repo.findByApiKey('key-ada')?.tierId).toBe('pro');
|
||
});
|
||
|
||
it('returns undefined for an unknown key', () => {
|
||
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
|
||
expect(repo.findByApiKey('wrong')).toBeUndefined();
|
||
});
|
||
|
||
it('lists all customers with their Stripe ids', () => {
|
||
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
|
||
const all = repo.list();
|
||
expect(all).toHaveLength(1);
|
||
expect(all[0].stripeCustomerId).toBe('cus_123');
|
||
});
|
||
|
||
it('seeds only when the table is empty', () => {
|
||
const db = new Database(':memory:');
|
||
new SqliteCustomerRepo(db, [customer('key-ada')]);
|
||
const again = new SqliteCustomerRepo(db, [customer('key-other')]);
|
||
expect(again.list().map((c) => c.apiKey)).toEqual(['key-ada']);
|
||
});
|
||
|
||
it('save() upserts including the multiplier override', () => {
|
||
const repo = new SqliteCustomerRepo(new Database(':memory:'), [customer('key-ada')]);
|
||
repo.save({ ...customer('key-ada'), tierId: 'business', multiplierOverride: 0.4 });
|
||
const updated = repo.findByApiKey('key-ada');
|
||
expect(updated?.tierId).toBe('business');
|
||
expect(updated?.multiplierOverride).toBe(0.4);
|
||
expect(repo.list()).toHaveLength(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
Create `tests/db-pricing.test.ts`:
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import { SqlitePricingStore } from '../src/db/pricing-store';
|
||
|
||
describe('SqlitePricingStore', () => {
|
||
it('seeds the default rate card and tiers when empty', () => {
|
||
const store = new SqlitePricingStore(new Database(':memory:'));
|
||
expect(store.getRateCard().endpoints.transform).toEqual({ kind: 'fixed', fixedCents: 4 });
|
||
expect(store.getTiers().map((t) => t.id)).toEqual(['free', 'pro', 'business']);
|
||
});
|
||
|
||
it('seeds only once', () => {
|
||
const db = new Database(':memory:');
|
||
const first = new SqlitePricingStore(db);
|
||
first.deleteEndpoint('transform');
|
||
const second = new SqlitePricingStore(db);
|
||
expect(second.getRateCard().endpoints.transform).toBeUndefined();
|
||
});
|
||
|
||
it('upserts and deletes endpoints', () => {
|
||
const store = new SqlitePricingStore(new Database(':memory:'));
|
||
store.upsertEndpoint('experimental', { kind: 'variable', baseCents: 3, perKbCents: 2, perMbCents: 20 });
|
||
expect(store.getRateCard().endpoints.experimental).toEqual({
|
||
kind: 'variable',
|
||
baseCents: 3,
|
||
perKbCents: 2,
|
||
perMbCents: 20,
|
||
});
|
||
store.deleteEndpoint('experimental');
|
||
expect(store.getRateCard().endpoints.experimental).toBeUndefined();
|
||
});
|
||
|
||
it('upserts tiers including default rules, and deletes them', () => {
|
||
const store = new SqlitePricingStore(new Database(':memory:'));
|
||
store.upsertTier({
|
||
id: 'edu',
|
||
name: 'Education',
|
||
multiplier: 0.4,
|
||
monthlyCreditCents: 500,
|
||
defaultRule: { kind: 'fixed', fixedCents: 6 },
|
||
});
|
||
expect(store.getTiers().find((t) => t.id === 'edu')?.defaultRule).toEqual({
|
||
kind: 'fixed',
|
||
fixedCents: 6,
|
||
});
|
||
store.upsertTier({ id: 'edu', name: 'Education', multiplier: 0.3, monthlyCreditCents: 500 });
|
||
expect(store.getTiers().filter((t) => t.id === 'edu')).toHaveLength(1);
|
||
expect(store.getTiers().find((t) => t.id === 'edu')?.multiplier).toBe(0.3);
|
||
store.deleteTier('edu');
|
||
expect(store.getTiers().find((t) => t.id === 'edu')).toBeUndefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/db/...'`.
|
||
|
||
- [ ] **Step 4: Implement the SQLite repos and pricing store**
|
||
|
||
Create `src/db/usage-repo.ts`:
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import { summarize, UsageEntry, UsageRepo, UsageSummary } from '../usage';
|
||
|
||
export class SqliteUsageRepo implements UsageRepo {
|
||
constructor(private db: Database.Database) {
|
||
this.db.exec(`
|
||
CREATE TABLE IF NOT EXISTS usage_entries (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
customer_id TEXT NOT NULL,
|
||
endpoint_id TEXT NOT NULL,
|
||
cents INTEGER NOT NULL,
|
||
metadata_bytes INTEGER NOT NULL,
|
||
attachment_bytes INTEGER NOT NULL,
|
||
timestamp_ms INTEGER NOT NULL
|
||
)
|
||
`);
|
||
}
|
||
|
||
record(entry: UsageEntry): void {
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO usage_entries
|
||
(customer_id, endpoint_id, cents, metadata_bytes, attachment_bytes, timestamp_ms)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
)
|
||
.run(
|
||
entry.customerId,
|
||
entry.endpointId,
|
||
entry.cents,
|
||
entry.metadataBytes,
|
||
entry.attachmentBytes,
|
||
entry.timestamp.getTime(),
|
||
);
|
||
}
|
||
|
||
listFor(customerId: string, since?: Date): UsageEntry[] {
|
||
const rows = (
|
||
since
|
||
? this.db
|
||
.prepare(
|
||
'SELECT * FROM usage_entries WHERE customer_id = ? AND timestamp_ms >= ? ORDER BY timestamp_ms',
|
||
)
|
||
.all(customerId, since.getTime())
|
||
: this.db
|
||
.prepare('SELECT * FROM usage_entries WHERE customer_id = ? ORDER BY timestamp_ms')
|
||
.all(customerId)
|
||
) as Record<string, unknown>[];
|
||
return rows.map((r) => ({
|
||
customerId: r.customer_id as string,
|
||
endpointId: r.endpoint_id as string,
|
||
cents: r.cents as number,
|
||
metadataBytes: r.metadata_bytes as number,
|
||
attachmentBytes: r.attachment_bytes as number,
|
||
timestamp: new Date(r.timestamp_ms as number),
|
||
}));
|
||
}
|
||
|
||
summaryFor(customerId: string, since?: Date): UsageSummary {
|
||
return summarize(customerId, this.listFor(customerId, since));
|
||
}
|
||
}
|
||
```
|
||
|
||
Create `src/db/customer-repo.ts`:
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import { Customer, CustomerRepo } from '../auth';
|
||
|
||
export class SqliteCustomerRepo implements CustomerRepo {
|
||
constructor(private db: Database.Database, seed: Customer[] = []) {
|
||
this.db.exec(`
|
||
CREATE TABLE IF NOT EXISTS customers (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
tier_id TEXT NOT NULL,
|
||
api_key TEXT NOT NULL UNIQUE,
|
||
stripe_customer_id TEXT,
|
||
multiplier_override REAL
|
||
)
|
||
`);
|
||
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number };
|
||
if (n === 0) {
|
||
for (const c of seed) this.save(c);
|
||
}
|
||
}
|
||
|
||
findByApiKey(apiKey: string): Customer | undefined {
|
||
const r = this.db.prepare('SELECT * FROM customers WHERE api_key = ?').get(apiKey) as
|
||
| Record<string, unknown>
|
||
| undefined;
|
||
return r ? toCustomer(r) : undefined;
|
||
}
|
||
|
||
list(): Customer[] {
|
||
const rows = this.db.prepare('SELECT * FROM customers ORDER BY id').all() as Record<
|
||
string,
|
||
unknown
|
||
>[];
|
||
return rows.map(toCustomer);
|
||
}
|
||
|
||
save(customer: Customer): void {
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO customers (id, name, tier_id, api_key, stripe_customer_id, multiplier_override)
|
||
VALUES (@id, @name, @tierId, @apiKey, @stripeCustomerId, @multiplierOverride)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
name = @name,
|
||
tier_id = @tierId,
|
||
api_key = @apiKey,
|
||
stripe_customer_id = @stripeCustomerId,
|
||
multiplier_override = @multiplierOverride`,
|
||
)
|
||
.run({
|
||
id: customer.id,
|
||
name: customer.name,
|
||
tierId: customer.tierId,
|
||
apiKey: customer.apiKey,
|
||
stripeCustomerId: customer.stripeCustomerId ?? null,
|
||
multiplierOverride: customer.multiplierOverride ?? null,
|
||
});
|
||
}
|
||
}
|
||
|
||
function toCustomer(r: Record<string, unknown>): Customer {
|
||
return {
|
||
id: r.id as string,
|
||
name: r.name as string,
|
||
tierId: r.tier_id as string,
|
||
apiKey: r.api_key as string,
|
||
stripeCustomerId: (r.stripe_customer_id as string | null) ?? undefined,
|
||
multiplierOverride: (r.multiplier_override as number | null) ?? undefined,
|
||
};
|
||
}
|
||
```
|
||
|
||
Create `src/db/pricing-store.ts`:
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import {
|
||
DEFAULT_RATE_CARD,
|
||
DEFAULT_TIERS,
|
||
PriceRule,
|
||
PricingStore,
|
||
RateCard,
|
||
TierConfig,
|
||
} from '../pricing';
|
||
|
||
export class SqlitePricingStore implements PricingStore {
|
||
constructor(
|
||
private db: Database.Database,
|
||
seedCard: RateCard = DEFAULT_RATE_CARD,
|
||
seedTiers: TierConfig[] = DEFAULT_TIERS,
|
||
) {
|
||
this.db.exec(`
|
||
CREATE TABLE IF NOT EXISTS price_endpoints (
|
||
endpoint_id TEXT PRIMARY KEY,
|
||
rule_json TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS tiers (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
multiplier REAL NOT NULL,
|
||
monthly_credit_cents INTEGER NOT NULL,
|
||
default_rule_json TEXT
|
||
)
|
||
`);
|
||
const { n } = this.db.prepare('SELECT COUNT(*) AS n FROM price_endpoints').get() as { n: number };
|
||
if (n === 0) {
|
||
for (const [id, rule] of Object.entries(seedCard.endpoints)) {
|
||
this.upsertEndpoint(id, rule);
|
||
}
|
||
for (const tier of seedTiers) {
|
||
this.upsertTier(tier);
|
||
}
|
||
}
|
||
}
|
||
|
||
getRateCard(): RateCard {
|
||
const rows = this.db.prepare('SELECT * FROM price_endpoints').all() as Record<string, unknown>[];
|
||
const endpoints: Record<string, PriceRule> = {};
|
||
for (const r of rows) {
|
||
endpoints[r.endpoint_id as string] = JSON.parse(r.rule_json as string) as PriceRule;
|
||
}
|
||
return { endpoints };
|
||
}
|
||
|
||
getTiers(): TierConfig[] {
|
||
const rows = this.db.prepare('SELECT * FROM tiers ORDER BY rowid').all() as Record<string, unknown>[];
|
||
return rows.map((r) => ({
|
||
id: r.id as string,
|
||
name: r.name as string,
|
||
multiplier: r.multiplier as number,
|
||
monthlyCreditCents: r.monthly_credit_cents as number,
|
||
defaultRule: r.default_rule_json
|
||
? (JSON.parse(r.default_rule_json as string) as PriceRule)
|
||
: undefined,
|
||
}));
|
||
}
|
||
|
||
upsertEndpoint(endpointId: string, rule: PriceRule): void {
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO price_endpoints (endpoint_id, rule_json) VALUES (?, ?)
|
||
ON CONFLICT(endpoint_id) DO UPDATE SET rule_json = excluded.rule_json`,
|
||
)
|
||
.run(endpointId, JSON.stringify(rule));
|
||
}
|
||
|
||
deleteEndpoint(endpointId: string): void {
|
||
this.db.prepare('DELETE FROM price_endpoints WHERE endpoint_id = ?').run(endpointId);
|
||
}
|
||
|
||
upsertTier(tier: TierConfig): void {
|
||
this.db
|
||
.prepare(
|
||
`INSERT INTO tiers (id, name, multiplier, monthly_credit_cents, default_rule_json)
|
||
VALUES (@id, @name, @multiplier, @monthlyCreditCents, @defaultRuleJson)
|
||
ON CONFLICT(id) DO UPDATE SET
|
||
name = @name,
|
||
multiplier = @multiplier,
|
||
monthly_credit_cents = @monthlyCreditCents,
|
||
default_rule_json = @defaultRuleJson`,
|
||
)
|
||
.run({
|
||
id: tier.id,
|
||
name: tier.name,
|
||
multiplier: tier.multiplier,
|
||
monthlyCreditCents: tier.monthlyCreditCents,
|
||
defaultRuleJson: tier.defaultRule ? JSON.stringify(tier.defaultRule) : null,
|
||
});
|
||
}
|
||
|
||
deleteTier(tierId: string): void {
|
||
this.db.prepare('DELETE FROM tiers WHERE id = ?').run(tierId);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS — 50 tests total.
|
||
|
||
- [ ] **Step 6: Inject SQLite into the server**
|
||
|
||
Replace `src/index.ts` with:
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import { buildApp, DEFAULT_CUSTOMERS } from './app';
|
||
import { SqliteCustomerRepo } from './db/customer-repo';
|
||
import { SqlitePricingStore } from './db/pricing-store';
|
||
import { SqliteUsageRepo } from './db/usage-repo';
|
||
|
||
const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db');
|
||
const usage = new SqliteUsageRepo(db);
|
||
const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS);
|
||
const pricingStore = new SqlitePricingStore(db);
|
||
|
||
const { app } = buildApp({ usage, customers, pricingStore });
|
||
const port = Number(process.env.PORT ?? 3000);
|
||
app.listen(port, () => {
|
||
console.log(`Zappier API listening on http://localhost:${port}`);
|
||
console.log(`OpenAPI docs at http://localhost:${port}/docs`);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Verify persistence across restarts**
|
||
|
||
```bash
|
||
cd /Users/marchon/zappier
|
||
npm run dev &
|
||
sleep 2
|
||
curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' \
|
||
-d '{"text":"hello"}' http://localhost:3000/v1/transform
|
||
kill %1
|
||
npm run dev &
|
||
sleep 2
|
||
curl -s -H 'x-api-key: key-ada' http://localhost:3000/v1/usage
|
||
kill %1
|
||
```
|
||
|
||
Expected: the second `/v1/usage` response still shows `"calls":1,"totalCents":4` — usage survived the restart. Delete the smoke-test database afterwards: `rm -f zappier.db`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json src/db src/index.ts tests/db-usage.test.ts tests/db-customer.test.ts tests/db-pricing.test.ts
|
||
git commit -m "feat: persist usage, customers, and pricing in SQLite"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Admin API + pricing web UI
|
||
|
||
**Files:**
|
||
- Create: `src/admin.ts`
|
||
- Create: `admin/index.html`
|
||
- Create: `admin/app.js`
|
||
- Modify: `src/app.ts` (mount admin router + static UI)
|
||
- Test: `tests/admin.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PricingStore`, `PriceRule`, `TierConfig` (Task 1); `CustomerRepo`, `Customer` (Task 3); `buildApp` (Task 5).
|
||
- Produces:
|
||
- `export function adminAuth(): RequestHandler` — requires `x-admin-key` = `process.env.ADMIN_KEY ?? 'admin-dev-key'`, else 403.
|
||
- `export function adminRouter(store: PricingStore, customers: CustomerRepo): Router` with routes:
|
||
- `GET /pricing` → `{ rateCard, tiers }`
|
||
- `PUT /endpoints/:id` body `PriceRule` → upsert (400 on invalid rule)
|
||
- `DELETE /endpoints/:id`
|
||
- `PUT /tiers/:id` body `TierConfig` → upsert (400 on invalid config)
|
||
- `DELETE /tiers/:id`
|
||
- `GET /customers` → `{ customers }` (apiKey masked)
|
||
- `POST /customers` body `{ name, tierId }` → 201 with the created customer including its generated `apiKey` (shown once)
|
||
- `PUT /customers/:id` body partial `{ name?, tierId?, multiplierOverride?, stripeCustomerId? }` → update (404 on unknown id)
|
||
- `buildApp` mounts these at `/admin/api` (behind `adminAuth`) and serves the static UI at `/admin`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/admin.test.ts`:
|
||
|
||
```ts
|
||
import request from 'supertest';
|
||
import { buildApp } from '../src/app';
|
||
|
||
const ADMIN = { 'x-admin-key': 'admin-dev-key' };
|
||
const KEY = 'key-ada';
|
||
|
||
describe('admin API', () => {
|
||
it('rejects calls without an admin key', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app).get('/admin/api/pricing');
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
it('returns the current pricing', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app).get('/admin/api/pricing').set(ADMIN);
|
||
expect(res.status).toBe(200);
|
||
expect(res.body.rateCard.endpoints.status).toEqual({ kind: 'free' });
|
||
expect(res.body.tiers.map((t: { id: string }) => t.id)).toEqual(['free', 'pro', 'business']);
|
||
});
|
||
|
||
it('rejects an invalid price rule with 400', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app)
|
||
.put('/admin/api/endpoints/transform')
|
||
.set(ADMIN)
|
||
.send({ kind: 'sometimes' });
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it('reprices an endpoint live, without restart', async () => {
|
||
const { app } = buildApp();
|
||
await request(app)
|
||
.put('/admin/api/endpoints/transform')
|
||
.set(ADMIN)
|
||
.send({ kind: 'fixed', fixedCents: 10 });
|
||
const res = await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', KEY)
|
||
.send({ text: 'hi' });
|
||
expect(res.body.quote.totalCents).toBe(10); // was 4
|
||
});
|
||
|
||
it('creates a customer type live and prices calls for it', async () => {
|
||
const { app } = buildApp();
|
||
await request(app)
|
||
.put('/admin/api/tiers/edu')
|
||
.set(ADMIN)
|
||
.send({ id: 'edu', name: 'Education', multiplier: 0.5, monthlyCreditCents: 500 });
|
||
const created = await request(app)
|
||
.post('/admin/api/customers')
|
||
.set(ADMIN)
|
||
.send({ name: 'School', tierId: 'edu' });
|
||
expect(created.status).toBe(201);
|
||
expect(created.body.apiKey).toMatch(/^key-/);
|
||
const res = await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', created.body.apiKey)
|
||
.send({ text: 'hi' });
|
||
expect(res.body.quote.totalCents).toBe(2); // list 4 x 0.5
|
||
});
|
||
|
||
it('sets a per-customer multiplier override live', async () => {
|
||
const { app } = buildApp();
|
||
const res = await request(app)
|
||
.put('/admin/api/customers/cust_1')
|
||
.set(ADMIN)
|
||
.send({ multiplierOverride: 0.5 });
|
||
expect(res.status).toBe(200);
|
||
const call = await request(app)
|
||
.post('/v1/transform')
|
||
.set('x-api-key', KEY)
|
||
.send({ text: 'hi' });
|
||
expect(call.body.quote.totalCents).toBe(2); // was 4
|
||
});
|
||
|
||
it('masks API keys in the customer list and 404s unknown customers', async () => {
|
||
const { app } = buildApp();
|
||
const list = await request(app).get('/admin/api/customers').set(ADMIN);
|
||
expect(list.status).toBe(200);
|
||
expect(JSON.stringify(list.body)).not.toContain('key-ada');
|
||
const missing = await request(app)
|
||
.put('/admin/api/customers/cust_nope')
|
||
.set(ADMIN)
|
||
.send({ tierId: 'pro' });
|
||
expect(missing.status).toBe(404);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/admin.test.ts`
|
||
Expected: FAIL — admin routes 404 (and `src/admin.ts` missing).
|
||
|
||
- [ ] **Step 3: Implement the admin router**
|
||
|
||
Create `src/admin.ts`:
|
||
|
||
```ts
|
||
import { randomBytes, randomUUID } from 'crypto';
|
||
import { RequestHandler, Router } from 'express';
|
||
import { CustomerRepo } from './auth';
|
||
import { PriceRule, PricingStore, TierConfig } from './pricing';
|
||
|
||
export function adminAuth(): RequestHandler {
|
||
return (req, res, next) => {
|
||
const expected = process.env.ADMIN_KEY ?? 'admin-dev-key';
|
||
if (req.header('x-admin-key') !== expected) {
|
||
res.status(403).json({ error: 'invalid or missing admin key' });
|
||
return;
|
||
}
|
||
next();
|
||
};
|
||
}
|
||
|
||
function isValidRule(rule: unknown): rule is PriceRule {
|
||
if (!rule || typeof rule !== 'object') return false;
|
||
const r = rule as Record<string, unknown>;
|
||
if (r.kind === 'free') return true;
|
||
if (r.kind === 'fixed') return typeof r.fixedCents === 'number';
|
||
if (r.kind === 'variable') {
|
||
return ['baseCents', 'perKbCents', 'perMbCents'].every((k) => typeof r[k] === 'number');
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isValidTier(tier: unknown): tier is TierConfig {
|
||
if (!tier || typeof tier !== 'object') return false;
|
||
const t = tier as Record<string, unknown>;
|
||
return (
|
||
typeof t.name === 'string' &&
|
||
typeof t.multiplier === 'number' &&
|
||
typeof t.monthlyCreditCents === 'number' &&
|
||
(t.defaultRule === undefined || isValidRule(t.defaultRule))
|
||
);
|
||
}
|
||
|
||
export function adminRouter(store: PricingStore, customers: CustomerRepo): Router {
|
||
const router = Router();
|
||
|
||
router.get('/pricing', (req, res) => {
|
||
res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() });
|
||
});
|
||
|
||
router.put('/endpoints/:id', (req, res) => {
|
||
if (!isValidRule(req.body)) {
|
||
res.status(400).json({ error: 'invalid price rule' });
|
||
return;
|
||
}
|
||
store.upsertEndpoint(req.params.id, req.body);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
router.delete('/endpoints/:id', (req, res) => {
|
||
store.deleteEndpoint(req.params.id);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
router.put('/tiers/:id', (req, res) => {
|
||
if (!isValidTier(req.body)) {
|
||
res.status(400).json({ error: 'invalid tier config' });
|
||
return;
|
||
}
|
||
store.upsertTier({ ...req.body, id: req.params.id });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
router.delete('/tiers/:id', (req, res) => {
|
||
store.deleteTier(req.params.id);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
router.get('/customers', (req, res) => {
|
||
res.json({ customers: customers.list().map((c) => ({ ...c, apiKey: undefined })) });
|
||
});
|
||
|
||
router.post('/customers', (req, res) => {
|
||
const { name, tierId } = req.body ?? {};
|
||
if (typeof name !== 'string' || typeof tierId !== 'string') {
|
||
res.status(400).json({ error: 'name and tierId required' });
|
||
return;
|
||
}
|
||
const customer = {
|
||
id: `cust_${randomUUID().slice(0, 8)}`,
|
||
name,
|
||
tierId,
|
||
apiKey: `key-${randomBytes(12).toString('hex')}`,
|
||
};
|
||
customers.save(customer);
|
||
res.status(201).json(customer);
|
||
});
|
||
|
||
router.put('/customers/:id', (req, res) => {
|
||
const existing = customers.list().find((c) => c.id === req.params.id);
|
||
if (!existing) {
|
||
res.status(404).json({ error: 'customer not found' });
|
||
return;
|
||
}
|
||
const { name, tierId, multiplierOverride, stripeCustomerId } = req.body ?? {};
|
||
customers.save({
|
||
...existing,
|
||
...(name !== undefined ? { name } : {}),
|
||
...(tierId !== undefined ? { tierId } : {}),
|
||
...(multiplierOverride !== undefined ? { multiplierOverride } : {}),
|
||
...(stripeCustomerId !== undefined ? { stripeCustomerId } : {}),
|
||
});
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
return router;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Mount the admin API and static UI in the app**
|
||
|
||
In `src/app.ts`, add the import:
|
||
|
||
```ts
|
||
import { adminAuth, adminRouter } from './admin';
|
||
```
|
||
|
||
Add these two lines immediately after `app.use('/docs', ...)` (before the `/v1` auth middleware):
|
||
|
||
```ts
|
||
app.use('/admin/api', adminAuth(), adminRouter(pricingStore, customers));
|
||
app.use('/admin', express.static(path.join(process.cwd(), 'admin')));
|
||
```
|
||
|
||
- [ ] **Step 5: Build the admin web UI**
|
||
|
||
Create `admin/index.html`:
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Zappier Pricing Admin</title>
|
||
<style>
|
||
body { font-family: -apple-system, "PingFang SC", sans-serif; margin: 2rem; color: #222; }
|
||
nav button { margin-right: 0.5rem; padding: 0.4rem 0.9rem; }
|
||
nav button.active { font-weight: 700; text-decoration: underline; }
|
||
table { border-collapse: collapse; margin-top: 1rem; }
|
||
th, td { border: 1px solid #ccc; padding: 0.35rem 0.6rem; text-align: left; }
|
||
input, select { padding: 0.25rem; }
|
||
.row-actions button { margin-right: 0.25rem; }
|
||
#status { margin-top: 1rem; color: #060; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Zappier Pricing Admin</h1>
|
||
<nav>
|
||
<button data-tab="endpoints" class="active">Rate card</button>
|
||
<button data-tab="tiers">Customer types</button>
|
||
<button data-tab="customers">Customers</button>
|
||
</nav>
|
||
<section id="endpoints"></section>
|
||
<section id="tiers" hidden></section>
|
||
<section id="customers" hidden></section>
|
||
<p id="status"></p>
|
||
<script src="app.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
Create `admin/app.js`:
|
||
|
||
```js
|
||
const state = { pricing: null, customers: [] };
|
||
|
||
function adminKey() {
|
||
let key = localStorage.getItem('zappier-admin-key');
|
||
if (!key) {
|
||
key = prompt('Admin key:');
|
||
localStorage.setItem('zappier-admin-key', key);
|
||
}
|
||
return key;
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const res = await fetch(`/admin/api${path}`, {
|
||
...options,
|
||
headers: { 'content-type': 'application/json', 'x-admin-key': adminKey() },
|
||
});
|
||
if (res.status === 403) {
|
||
localStorage.removeItem('zappier-admin-key');
|
||
throw new Error('Admin key rejected — reload to re-enter.');
|
||
}
|
||
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
|
||
return res.json();
|
||
}
|
||
|
||
function say(msg) {
|
||
document.getElementById('status').textContent = msg;
|
||
}
|
||
|
||
async function load() {
|
||
state.pricing = await api('/pricing');
|
||
state.customers = (await api('/customers')).customers;
|
||
renderEndpoints();
|
||
renderTiers();
|
||
renderCustomers();
|
||
}
|
||
|
||
function ruleInputs(id, rule) {
|
||
const fields =
|
||
rule.kind === 'fixed'
|
||
? { fixedCents: rule.fixedCents }
|
||
: rule.kind === 'variable'
|
||
? { baseCents: rule.baseCents, perKbCents: rule.perKbCents, perMbCents: rule.perMbCents }
|
||
: {};
|
||
return Object.entries(fields)
|
||
.map(
|
||
([k, v]) =>
|
||
`${k} <input data-endpoint="${id}" data-field="${k}" type="number" step="any" value="${v}" size="6">`,
|
||
)
|
||
.join(' ');
|
||
}
|
||
|
||
function renderEndpoints() {
|
||
const rows = Object.entries(state.pricing.rateCard.endpoints)
|
||
.map(
|
||
([id, rule]) => `<tr>
|
||
<td>${id}</td>
|
||
<td><select data-endpoint-kind="${id}">
|
||
${['free', 'fixed', 'variable'].map((k) => `<option ${k === rule.kind ? 'selected' : ''}>${k}</option>`).join('')}
|
||
</select></td>
|
||
<td>${ruleInputs(id, rule)}</td>
|
||
<td class="row-actions">
|
||
<button onclick="saveEndpoint('${id}')">Save</button>
|
||
<button onclick="deleteEndpoint('${id}')">Delete</button>
|
||
</td>
|
||
</tr>`,
|
||
)
|
||
.join('');
|
||
document.getElementById('endpoints').innerHTML = `
|
||
<h2>Rate card</h2>
|
||
<table><tr><th>Endpoint (operationId)</th><th>Kind</th><th>Prices (cents)</th><th></th></tr>${rows}</table>
|
||
<h3>Add endpoint</h3>
|
||
<input id="new-endpoint-id" placeholder="operationId">
|
||
<select id="new-endpoint-kind"><option>free</option><option selected>fixed</option><option>variable</option></select>
|
||
<button onclick="addEndpoint()">Add</button>`;
|
||
}
|
||
|
||
async function saveEndpoint(id) {
|
||
const kind = document.querySelector(`[data-endpoint-kind="${id}"]`).value;
|
||
const rule = { kind };
|
||
document.querySelectorAll(`input[data-endpoint="${id}"]`).forEach((el) => {
|
||
rule[el.dataset.field] = Number(el.value);
|
||
});
|
||
if (kind === 'fixed' && rule.fixedCents === undefined) rule.fixedCents = 0;
|
||
if (kind === 'variable') {
|
||
rule.baseCents = rule.baseCents ?? 0;
|
||
rule.perKbCents = rule.perKbCents ?? 0;
|
||
rule.perMbCents = rule.perMbCents ?? 0;
|
||
}
|
||
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
|
||
say(`Saved ${id}.`);
|
||
await load();
|
||
}
|
||
|
||
async function deleteEndpoint(id) {
|
||
await api(`/endpoints/${id}`, { method: 'DELETE' });
|
||
say(`Deleted ${id}.`);
|
||
await load();
|
||
}
|
||
|
||
async function addEndpoint() {
|
||
const id = document.getElementById('new-endpoint-id').value.trim();
|
||
const kind = document.getElementById('new-endpoint-kind').value;
|
||
if (!id) return say('Endpoint id required.');
|
||
const rule =
|
||
kind === 'free'
|
||
? { kind }
|
||
: kind === 'fixed'
|
||
? { kind, fixedCents: 0 }
|
||
: { kind, baseCents: 0, perKbCents: 0, perMbCents: 0 };
|
||
await api(`/endpoints/${id}`, { method: 'PUT', body: JSON.stringify(rule) });
|
||
say(`Added ${id}.`);
|
||
await load();
|
||
}
|
||
|
||
function renderTiers() {
|
||
const rows = state.pricing.tiers
|
||
.map(
|
||
(t) => `<tr>
|
||
<td>${t.id}</td>
|
||
<td><input data-tier="${t.id}" data-field="name" value="${t.name}"></td>
|
||
<td><input data-tier="${t.id}" data-field="multiplier" type="number" step="any" value="${t.multiplier}" size="5"></td>
|
||
<td><input data-tier="${t.id}" data-field="monthlyCreditCents" type="number" value="${t.monthlyCreditCents}" size="8"></td>
|
||
<td class="row-actions">
|
||
<button onclick="saveTier('${t.id}')">Save</button>
|
||
<button onclick="deleteTier('${t.id}')">Delete</button>
|
||
</td>
|
||
</tr>`,
|
||
)
|
||
.join('');
|
||
document.getElementById('tiers').innerHTML = `
|
||
<h2>Customer types</h2>
|
||
<table><tr><th>Id</th><th>Name</th><th>Multiplier</th><th>Monthly credit (cents)</th><th></th></tr>${rows}</table>
|
||
<h3>Add customer type</h3>
|
||
<input id="new-tier-id" placeholder="id">
|
||
<input id="new-tier-name" placeholder="name">
|
||
<input id="new-tier-multiplier" type="number" step="any" value="1" size="5"> multiplier
|
||
<button onclick="addTier()">Add</button>`;
|
||
}
|
||
|
||
async function saveTier(id) {
|
||
const body = { id };
|
||
document.querySelectorAll(`[data-tier="${id}"]`).forEach((el) => {
|
||
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
|
||
});
|
||
const existing = state.pricing.tiers.find((t) => t.id === id);
|
||
if (existing?.defaultRule) body.defaultRule = existing.defaultRule;
|
||
await api(`/tiers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||
say(`Saved tier ${id}.`);
|
||
await load();
|
||
}
|
||
|
||
async function deleteTier(id) {
|
||
await api(`/tiers/${id}`, { method: 'DELETE' });
|
||
say(`Deleted tier ${id}.`);
|
||
await load();
|
||
}
|
||
|
||
async function addTier() {
|
||
const id = document.getElementById('new-tier-id').value.trim();
|
||
const name = document.getElementById('new-tier-name').value.trim();
|
||
const multiplier = Number(document.getElementById('new-tier-multiplier').value);
|
||
if (!id || !name) return say('Tier id and name required.');
|
||
await api(`/tiers/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ id, name, multiplier, monthlyCreditCents: 0 }),
|
||
});
|
||
say(`Added tier ${id}.`);
|
||
await load();
|
||
}
|
||
|
||
function renderCustomers() {
|
||
const tierOptions = (selected) =>
|
||
state.pricing.tiers
|
||
.map((t) => `<option ${t.id === selected ? 'selected' : ''}>${t.id}</option>`)
|
||
.join('');
|
||
const rows = state.customers
|
||
.map(
|
||
(c) => `<tr>
|
||
<td>${c.id}</td>
|
||
<td>${c.name}</td>
|
||
<td><select data-customer="${c.id}" data-field="tierId">${tierOptions(c.tierId)}</select></td>
|
||
<td><input data-customer="${c.id}" data-field="multiplierOverride" type="number" step="any" size="5" value="${c.multiplierOverride ?? ''}" placeholder="—"></td>
|
||
<td><button onclick="saveCustomer('${c.id}')">Save</button></td>
|
||
</tr>`,
|
||
)
|
||
.join('');
|
||
document.getElementById('customers').innerHTML = `
|
||
<h2>Customers</h2>
|
||
<table><tr><th>Id</th><th>Name</th><th>Type</th><th>Multiplier override</th><th></th></tr>${rows}</table>
|
||
<h3>Add customer</h3>
|
||
<input id="new-customer-name" placeholder="name">
|
||
<select id="new-customer-tier">${tierOptions(state.pricing.tiers[0]?.id)}</select>
|
||
<button onclick="addCustomer()">Create</button>
|
||
<p>A new customer's API key is shown once in the status line below.</p>`;
|
||
}
|
||
|
||
async function saveCustomer(id) {
|
||
const body = {};
|
||
document.querySelectorAll(`[data-customer="${id}"]`).forEach((el) => {
|
||
if (el.value === '') return;
|
||
body[el.dataset.field] = el.type === 'number' ? Number(el.value) : el.value;
|
||
});
|
||
await api(`/customers/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||
say(`Saved customer ${id}.`);
|
||
await load();
|
||
}
|
||
|
||
async function addCustomer() {
|
||
const name = document.getElementById('new-customer-name').value.trim();
|
||
const tierId = document.getElementById('new-customer-tier').value;
|
||
if (!name) return say('Customer name required.');
|
||
const created = await api('/customers', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, tierId }),
|
||
});
|
||
say(`Created ${created.id} — API key: ${created.apiKey}`);
|
||
await load();
|
||
}
|
||
|
||
document.querySelectorAll('nav button').forEach((btn) =>
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
document.querySelectorAll('section').forEach((s) => (s.hidden = true));
|
||
document.getElementById(btn.dataset.tab).hidden = false;
|
||
}),
|
||
);
|
||
|
||
load().catch((err) => say(err.message));
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS — 57 tests total.
|
||
|
||
- [ ] **Step 7: Verify the UI end-to-end (manual)**
|
||
|
||
1. `npm run dev`, open http://localhost:3000/admin, enter `admin-dev-key`.
|
||
2. In **Rate card**, change `transform` to `fixedCents: 10`, Save.
|
||
3. `curl -s -H 'x-api-key: key-ada' -H 'content-type: application/json' -d '{"text":"hi"}' http://localhost:3000/v1/transform` → quote shows `totalCents: 10` (no restart).
|
||
4. In **Customer types**, add `edu` at 0.5×; in **Customers**, create a customer on `edu` and call the API with its new key.
|
||
5. Stop the server. Delete the smoke-test database: `rm -f zappier.db`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/admin.ts src/app.ts admin tests/admin.test.ts
|
||
git commit -m "feat: add admin API and web UI for live pricing management"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Stripe metered billing
|
||
|
||
**Files:**
|
||
- Create: `src/billing/stripe.ts`
|
||
- Create: `src/jobs/report-usage.ts`
|
||
- Test: `tests/stripe.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `UsageEntry` (Task 2); `PricingStore` (Task 1) / `SqlitePricingStore` (Task 7) for tier credits; `SqliteUsageRepo`, `SqliteCustomerRepo` (Task 7); `DEFAULT_CUSTOMERS` (Task 5); `Customer.stripeCustomerId` / `tierId` (Task 3); the credit semantics from Task 6.
|
||
- Produces:
|
||
- `export const METER_EVENT_NAME = 'zappier.api_cents'`
|
||
- `export interface MeterEventClient { createMeterEvent(params: { eventName: string; customerId: string; value: string }): Promise<void> }`
|
||
- `export async function reportUsage(client: MeterEventClient, stripeCustomerId: string, entries: UsageEntry[], monthlyCreditCents: number): Promise<number>` — sums entry cents, subtracts the monthly credit (floor 0), sends one meter event when the billable amount is > 0, returns the reported cents.
|
||
|
||
- [ ] **Step 1: Create the Stripe account and billing meter (manual)**
|
||
|
||
1. Sign up / log in at https://dashboard.stripe.com (test mode is fine).
|
||
2. Create a product per customer type: `Zappier Free`, `Zappier Pro`, `Zappier Business`.
|
||
3. Under **Billing → Meters**, create a meter named `zappier.api_cents`, aggregation = **Sum** of `value`.
|
||
4. Add a metered price to each tier's subscription using that meter.
|
||
5. Copy the test secret key (`sk_test_...`) for the job below.
|
||
|
||
Expected: meter visible in the dashboard with event name `zappier.api_cents`.
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Create `tests/stripe.test.ts`:
|
||
|
||
```ts
|
||
import { MeterEventClient, reportUsage, METER_EVENT_NAME } from '../src/billing/stripe';
|
||
import { UsageEntry } from '../src/usage';
|
||
|
||
const entry = (cents: number): UsageEntry => ({
|
||
customerId: 'cust_1',
|
||
endpointId: 'storage',
|
||
cents,
|
||
metadataBytes: 0,
|
||
attachmentBytes: 0,
|
||
timestamp: new Date(),
|
||
});
|
||
|
||
const fakeClient = () => {
|
||
const calls: { eventName: string; customerId: string; value: string }[] = [];
|
||
const client: MeterEventClient = {
|
||
createMeterEvent: async (params) => {
|
||
calls.push(params);
|
||
},
|
||
};
|
||
return { client, calls };
|
||
};
|
||
|
||
describe('reportUsage', () => {
|
||
it('reports usage minus the monthly credit as one meter event', async () => {
|
||
const { client, calls } = fakeClient();
|
||
const reported = await reportUsage(client, 'cus_123', [entry(112), entry(4)], 100);
|
||
expect(reported).toBe(16); // 116 - 100 credit
|
||
expect(calls).toEqual([
|
||
{ eventName: METER_EVENT_NAME, customerId: 'cus_123', value: '16' },
|
||
]);
|
||
});
|
||
|
||
it('reports nothing when the credit covers all usage', async () => {
|
||
const { client, calls } = fakeClient();
|
||
const reported = await reportUsage(client, 'cus_123', [entry(4)], 100);
|
||
expect(reported).toBe(0);
|
||
expect(calls).toHaveLength(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
Run: `npm test -- tests/stripe.test.ts`
|
||
Expected: FAIL — `Cannot find module '../src/billing/stripe'`.
|
||
|
||
- [ ] **Step 4: Implement the billing module and job**
|
||
|
||
Create `src/billing/stripe.ts`:
|
||
|
||
```ts
|
||
import { UsageEntry } from '../usage';
|
||
|
||
export const METER_EVENT_NAME = 'zappier.api_cents';
|
||
|
||
export interface MeterEventClient {
|
||
createMeterEvent(params: {
|
||
eventName: string;
|
||
customerId: string;
|
||
value: string;
|
||
}): Promise<void>;
|
||
}
|
||
|
||
export async function reportUsage(
|
||
client: MeterEventClient,
|
||
stripeCustomerId: string,
|
||
entries: UsageEntry[],
|
||
monthlyCreditCents: number,
|
||
): Promise<number> {
|
||
const totalCents = entries.reduce((sum, e) => sum + e.cents, 0);
|
||
const billable = Math.max(0, totalCents - monthlyCreditCents);
|
||
if (billable <= 0) return 0;
|
||
await client.createMeterEvent({
|
||
eventName: METER_EVENT_NAME,
|
||
customerId: stripeCustomerId,
|
||
value: String(billable),
|
||
});
|
||
return billable;
|
||
}
|
||
```
|
||
|
||
Create `src/jobs/report-usage.ts` (run monthly, e.g. `STRIPE_SECRET_KEY=sk_test_... npx ts-node src/jobs/report-usage.ts`):
|
||
|
||
```ts
|
||
import Database from 'better-sqlite3';
|
||
import Stripe from 'stripe';
|
||
import { DEFAULT_CUSTOMERS } from '../app';
|
||
import { METER_EVENT_NAME, reportUsage } from '../billing/stripe';
|
||
import { SqliteCustomerRepo } from '../db/customer-repo';
|
||
import { SqlitePricingStore } from '../db/pricing-store';
|
||
import { SqliteUsageRepo } from '../db/usage-repo';
|
||
|
||
async function main(): Promise<void> {
|
||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||
const client = {
|
||
createMeterEvent: async (p: { eventName: string; customerId: string; value: string }) => {
|
||
await stripe.billing.meterEvents.create({
|
||
event_name: METER_EVENT_NAME,
|
||
payload: { stripe_customer_id: p.customerId, value: p.value },
|
||
});
|
||
},
|
||
};
|
||
|
||
const db = new Database(process.env.ZAPPIER_DB ?? 'zappier.db');
|
||
const usage = new SqliteUsageRepo(db);
|
||
const customers = new SqliteCustomerRepo(db, DEFAULT_CUSTOMERS);
|
||
const pricingStore = new SqlitePricingStore(db);
|
||
const tiers = pricingStore.getTiers();
|
||
|
||
const since = new Date();
|
||
since.setUTCDate(1);
|
||
since.setUTCHours(0, 0, 0, 0);
|
||
|
||
for (const customer of customers.list()) {
|
||
if (!customer.stripeCustomerId) continue;
|
||
const tier = tiers.find((t) => t.id === customer.tierId);
|
||
if (!tier) {
|
||
console.warn(`${customer.id}: unknown tier ${customer.tierId}, skipped`);
|
||
continue;
|
||
}
|
||
const entries = usage.listFor(customer.id, since);
|
||
const reported = await reportUsage(
|
||
client,
|
||
customer.stripeCustomerId,
|
||
entries,
|
||
tier.monthlyCreditCents,
|
||
);
|
||
console.log(`${customer.id}: reported ${reported} billable cents to Stripe`);
|
||
}
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `npm test`
|
||
Expected: PASS — 59 tests total.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/billing/stripe.ts src/jobs/report-usage.ts tests/stripe.test.ts
|
||
git commit -m "feat: report credit-adjusted monthly usage to Stripe billing meters"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Zapier app — account, CLI, auth, trigger, action, publish
|
||
|
||
**Files:**
|
||
- Create: `zapier-app/package.json`
|
||
- Create: `zapier-app/index.js`
|
||
- Create: `zapier-app/authentication.js`
|
||
- Create: `zapier-app/triggers/new_item.js`
|
||
- Create: `zapier-app/creates/store_data.js`
|
||
- Test: `zapier-app/test/app.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the live API from Task 5 (`GET /v1/status`, `GET /v1/storage`, `POST /v1/storage`) with `x-api-key` auth. Pricing changes from Tasks 1–9 require **no** changes here — the Zapier app only calls endpoints; the server prices them.
|
||
- Produces: a Zapier integration named `zappier` with:
|
||
- Custom auth fields: `baseUrl` (default `http://localhost:3000`), `apiKey` (password).
|
||
- Trigger `new_item` (polling): returns `GET {baseUrl}/v1/storage` → `items` array.
|
||
- Action `store_data`: POSTs multipart `metadata` (JSON string of `{ title, note }`) + optional `attachments` file to `{baseUrl}/v1/storage`.
|
||
- `bundle.authData.apiKey` is injected as the `x-api-key` header on every request via a `beforeRequest`.
|
||
|
||
- [ ] **Step 1: Create the Zapier account (manual)**
|
||
|
||
1. Sign up at https://zapier.com (free plan is enough to build).
|
||
2. Open https://developer.zapier.com and accept the developer terms.
|
||
3. Install the CLI and log in:
|
||
|
||
```bash
|
||
npm install -g zapier-platform-cli
|
||
zapier login
|
||
```
|
||
|
||
Expected: browser OAuth completes; `zapier apps` runs without auth errors.
|
||
|
||
- [ ] **Step 2: Scaffold the app package**
|
||
|
||
```bash
|
||
mkdir -p /Users/marchon/zappier/zapier-app/triggers /Users/marchon/zappier/zapier-app/creates /Users/marchon/zappier/zapier-app/test
|
||
```
|
||
|
||
Create `zapier-app/package.json`:
|
||
|
||
```json
|
||
{
|
||
"name": "zappier",
|
||
"version": "1.0.0",
|
||
"description": "Store data and files through the metered Zappier API.",
|
||
"main": "index.js",
|
||
"scripts": {
|
||
"test": "mocha --recursive --timeout 10000"
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `cd /Users/marchon/zappier/zapier-app && npm install zapier-platform-core form-data && npm install --save-dev mocha`
|
||
Expected: dependencies written into `package.json`.
|
||
|
||
- [ ] **Step 3: Write the failing tests**
|
||
|
||
Create `zapier-app/test/app.test.js`:
|
||
|
||
```js
|
||
const assert = require('assert');
|
||
const App = require('../index');
|
||
const storeData = require('../creates/store_data');
|
||
|
||
describe('Zapier app definition', () => {
|
||
it('exposes custom auth, one trigger, and one action', () => {
|
||
assert.equal(App.authentication.type, 'custom');
|
||
assert.ok(App.triggers.new_item);
|
||
assert.ok(App.creates.store_data);
|
||
assert.equal(App.beforeRequest.length, 1);
|
||
});
|
||
});
|
||
|
||
describe('store_data perform', () => {
|
||
it('posts metadata JSON to /v1/storage', async () => {
|
||
const requests = [];
|
||
const z = {
|
||
request: async (opts) => {
|
||
requests.push(opts);
|
||
return { data: { id: 'item_1' } };
|
||
},
|
||
};
|
||
const bundle = {
|
||
authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' },
|
||
inputData: { title: 'report', note: 'hello' },
|
||
};
|
||
const result = await storeData.operation.perform(z, bundle);
|
||
assert.equal(result.id, 'item_1');
|
||
assert.equal(requests.length, 1);
|
||
assert.equal(requests[0].method, 'POST');
|
||
assert.equal(requests[0].url, 'http://localhost:3000/v1/storage');
|
||
});
|
||
|
||
it('downloads the mapped file first, then posts it as an attachment', async () => {
|
||
const requests = [];
|
||
const z = {
|
||
request: async (opts) => {
|
||
requests.push(opts);
|
||
return opts.raw ? { body: Buffer.from('x') } : { data: { id: 'item_2' } };
|
||
},
|
||
};
|
||
const bundle = {
|
||
authData: { baseUrl: 'http://localhost:3000', apiKey: 'key-ada' },
|
||
inputData: { title: 'with file', file: 'https://example.com/f.pdf', filename: 'f.pdf' },
|
||
};
|
||
await storeData.operation.perform(z, bundle);
|
||
assert.equal(requests.length, 2);
|
||
assert.equal(requests[0].url, 'https://example.com/f.pdf');
|
||
assert.equal(requests[0].raw, true);
|
||
assert.equal(requests[1].url, 'http://localhost:3000/v1/storage');
|
||
assert.equal(requests[1].method, 'POST');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they fail**
|
||
|
||
Run: `cd /Users/marchon/zappier/zapier-app && npm test`
|
||
Expected: FAIL — `Cannot find module '../index'`.
|
||
|
||
- [ ] **Step 5: Implement the Zapier app**
|
||
|
||
Create `zapier-app/authentication.js`:
|
||
|
||
```js
|
||
module.exports = {
|
||
type: 'custom',
|
||
test: (z, bundle) =>
|
||
z.request({ url: `${bundle.authData.baseUrl}/v1/status` }).then((r) => r.data),
|
||
fields: [
|
||
{
|
||
key: 'baseUrl',
|
||
label: 'API Base URL',
|
||
type: 'string',
|
||
required: true,
|
||
default: 'http://localhost:3000',
|
||
helpText: 'Where your Zappier API is running.',
|
||
},
|
||
{
|
||
key: 'apiKey',
|
||
label: 'API Key',
|
||
type: 'password',
|
||
required: true,
|
||
helpText: 'Your Zappier customer API key.',
|
||
},
|
||
],
|
||
connectionLabel: '{{bundle.authData.baseUrl}}',
|
||
};
|
||
```
|
||
|
||
Create `zapier-app/triggers/new_item.js`:
|
||
|
||
```js
|
||
const perform = async (z, bundle) => {
|
||
const response = await z.request({ url: `${bundle.authData.baseUrl}/v1/storage` });
|
||
return response.data.items;
|
||
};
|
||
|
||
module.exports = {
|
||
key: 'new_item',
|
||
noun: 'Stored Item',
|
||
display: {
|
||
label: 'New Stored Item',
|
||
description: 'Triggers when a new item is stored through the Zappier API.',
|
||
},
|
||
operation: {
|
||
type: 'polling',
|
||
perform,
|
||
sample: {
|
||
id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||
customerId: 'cust_1',
|
||
metadata: { title: 'example' },
|
||
attachments: [],
|
||
createdAt: '2026-07-27T10:00:00.000Z',
|
||
},
|
||
},
|
||
};
|
||
```
|
||
|
||
Create `zapier-app/creates/store_data.js`:
|
||
|
||
```js
|
||
const FormData = require('form-data');
|
||
|
||
const perform = async (z, bundle) => {
|
||
const form = new FormData();
|
||
form.append(
|
||
'metadata',
|
||
JSON.stringify({ title: bundle.inputData.title, note: bundle.inputData.note }),
|
||
);
|
||
|
||
if (bundle.inputData.file) {
|
||
const fileResponse = await z.request({
|
||
url: bundle.inputData.file,
|
||
raw: true,
|
||
redirect: 'follow',
|
||
});
|
||
form.append('attachments', fileResponse.body, {
|
||
filename: bundle.inputData.filename || 'attachment.bin',
|
||
});
|
||
}
|
||
|
||
const response = await z.request({
|
||
url: `${bundle.authData.baseUrl}/v1/storage`,
|
||
method: 'POST',
|
||
body: form,
|
||
headers: form.getHeaders(),
|
||
});
|
||
return response.data;
|
||
};
|
||
|
||
module.exports = {
|
||
key: 'store_data',
|
||
noun: 'Stored Item',
|
||
display: {
|
||
label: 'Store Data',
|
||
description:
|
||
'Stores metadata and an optional file attachment. Priced per call plus metadata KB and attachment MB on your plan.',
|
||
},
|
||
operation: {
|
||
inputFields: [
|
||
{ key: 'title', label: 'Title', type: 'string', required: true },
|
||
{ key: 'note', label: 'Note', type: 'text', required: false },
|
||
{
|
||
key: 'file',
|
||
label: 'Attachment',
|
||
type: 'file',
|
||
required: false,
|
||
helpText: 'Optional file. Attachment size is billed per MB on your plan.',
|
||
},
|
||
{ key: 'filename', label: 'Filename', type: 'string', required: false },
|
||
],
|
||
perform,
|
||
sample: { id: '3fa85f64-5717-4562-b3fc-2c963f66afa6' },
|
||
},
|
||
};
|
||
```
|
||
|
||
Create `zapier-app/index.js`:
|
||
|
||
```js
|
||
const authentication = require('./authentication');
|
||
const newItem = require('./triggers/new_item');
|
||
const storeData = require('./creates/store_data');
|
||
|
||
const addApiKeyHeader = (request, z, bundle) => {
|
||
request.headers = request.headers || {};
|
||
request.headers['x-api-key'] = bundle.authData.apiKey;
|
||
return request;
|
||
};
|
||
|
||
module.exports = {
|
||
version: require('./package.json').version,
|
||
platformVersion: require('zapier-platform-core').version,
|
||
authentication,
|
||
beforeRequest: [addApiKeyHeader],
|
||
triggers: { [newItem.key]: newItem },
|
||
creates: { [storeData.key]: storeData },
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
Run: `cd /Users/marchon/zappier/zapier-app && npm test`
|
||
Expected: PASS — 3 tests.
|
||
|
||
- [ ] **Step 7: End-to-end check against the live API**
|
||
|
||
1. Start the API: `cd /Users/marchon/zappier && npm run dev`.
|
||
2. In `zapier-app/`, run `zapier test` (validates against the platform core too).
|
||
3. Manually verify with curl that `POST /v1/storage` accepts a multipart request matching the action's shape.
|
||
|
||
Expected: tests pass; a stored item appears in `GET /v1/storage`. Stop the server afterwards.
|
||
|
||
- [ ] **Step 8: Push to Zapier and build a test Zap**
|
||
|
||
```bash
|
||
cd /Users/marchon/zappier/zapier-app
|
||
zapier push
|
||
```
|
||
|
||
Expected: `Push successful`; the app appears at https://developer.zapier.com.
|
||
|
||
Then in https://zapier.com create a private Zap: trigger **New Stored Item**, connect with `baseUrl` + `key-ada`, and add the **Store Data** action in a second Zap. Turn them on and confirm runs in the Zap history.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
cd /Users/marchon/zappier
|
||
git add zapier-app
|
||
git commit -m "feat: add Zapier integration with auth, polling trigger, and store action"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review notes
|
||
|
||
- **Spec coverage:** Swagger/OpenAPI API definitions → Task 5 (`openapi.yaml` source of truth, request validation, Swagger UI at `/docs`; rate card keys = spec `operationId`s). Web-UI pricing per plan/customer type → Task 8 (rate card, tier, and customer editors) backed by Task 7's `SqlitePricingStore`; live (no-restart) repricing proven by admin tests. Many API calls → rate card keyed by `operationId` + per-tier default rule for unpriced endpoints + 403 for tiers without a default. Many customer types → tier CRUD in admin UI (config-only). Per-customer pricing → `multiplierOverride` (Tasks 1, 3, 4, 8). Free calls → `status` / `storage-list` free rules at 0 on all tiers. Fixed-price calls → `transform`. Variable size-based pricing for metadata + file attachments → `storage` rule and Tasks 4–5 size metering. Free quotas → Task 6 monthly credits. Billing collection → Task 9 Stripe meters (credit-adjusted). Zapier availability + account → Task 10. Persistence → Task 7 SQLite.
|
||
- **Placeholder scan:** no TBDs; every code step has full code, including the entire admin UI; manual steps (Stripe/Zapier account creation) have exact URLs and expected outcomes.
|
||
- **Type consistency:** `quoteCall(pricing, tierId, endpointId, usage, multiplierOverride?)` / `Quote` / `PricingContext` / `PricingStore` / `InMemoryPricingStore` (Task 1) used unchanged in Tasks 4–9; `UsageEntry` / `UsageRepo` / `summarize` (Task 2) used unchanged in Tasks 4, 7, 9; `Customer` (with `tierId`, `multiplierOverride`) / `CustomerRepo` (with `save`) (Task 3) used in Tasks 4, 5, 7, 8, 9; `meter(endpointId, repo, pricing)` (Task 4) used unchanged in Task 5; `AppDeps` / `DEFAULT_CUSTOMERS` / `buildApp(deps)` / live `pricing` context (Task 5) consumed by Tasks 6–9; `applyMonthlyCredit` / `BilledSummary` (Task 6) matches the credit semantics inside `reportUsage` (Task 9); `SqliteUsageRepo` / `SqliteCustomerRepo` / `SqlitePricingStore` (Task 7) are exactly the `UsageRepo` / `CustomerRepo` / `PricingStore` implementations `buildApp` and the Stripe job expect; `adminAuth` / `adminRouter` (Task 8) mounted in Task 5's `buildApp`; endpoint ids identical in `openapi.yaml` `operationId`s, rate card, routes, and Zapier app URLs; seeded test math consistent across suites (transform = 4/2/1 cents by tier; storage example = 112 list cents; admin reprice 4 → 10).
|