Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ send a test webhook. Then:
**Ends <date>** and returns to Free when the period ends (advance a test clock on Paygate's
**Testing** page to see it).
5. A declined card (`4000 0000 0000 0002`) sends `payment.failed`: the owner gets an email.
6. Paygate's **Webhooks to apps** shows every delivery and lets you resend one; a resend is
6. Discount code: make a test coupon with a code in Paygate (Apps → floodwall → Discounts), open
`/pricing?code=<CODE>` signed in on Free: the code shows as applied under the plans, and Stripe
Checkout opens with it taken off. A wrong code says it isn't valid.
7. Credit: as an admin, give an account $10 at `/app/admin`; its `/account` shows **Credit $10.00**,
and its next invoice is $10 less.
8. Paygate's **Webhooks to apps** shows every delivery and lets you resend one; a resend is
answered `duplicate: true` and changes nothing.

## 3. Site
Expand Down Expand Up @@ -88,7 +93,10 @@ Smoke test, in a private window:
5. Pay with the 4242 test card (the service holds a `pg_sk_test_…` key, so nothing is charged),
come back to `/account`, see **Team**.
6. Open the billing page, cancel, check the account shows the end date.
7. Sign out; `/account` sends you to `/login`.
7. `/terms`, `/privacy` and `/refunds` open (also linked from the footer and the sign-up page).
8. Sign out; `/account` sends you to `/login`.

Going live is swapping the Worker's Paygate key and webhook secret for live ones
(`npx wrangler secret put …`), after the Terms, Privacy and Refund pages are up (FW-605).
Going live is swapping the Worker's Paygate key and webhook secret for the live ones
(`npx wrangler secret put …`; both are in Box, `erp-ai/floodwall/paygate/secret-keys.env`), once
testing on the sandbox is done. Paygate's live mode already has the ERP.AI MA LLC Stripe account,
the four live prices and the webhook URL, and the Terms, Privacy and Refund pages are up (FW-605).
34 changes: 25 additions & 9 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,13 @@ which the site shows as it is.
| `GET /admin/users` | | `200 { "signup": "waitlist" \| "open", "users": [...] }`, the waiting ones first; `401` signed out; `403` not an admin |
| `POST /admin/approve` | `{ "email": "..." }` | `200 { "approved": true, "emailed": true \| false }`; `404` no such account; `401`, `403` as above |
| `POST /admin/remove` | `{ "email": "..." }` | `200 { "removed": true }`; `400` an admin; `404` no such account; `401`, `403` as above |
| `POST /admin/credit` | `{ "email": "...", "dollars": 50, "note"?: "...", "requestId": "<uuid>" }` | `200 { "granted": 5000, "credit": 7500, "currency": "usd" }` (cents); `400` not 1 to 10,000 whole dollars, or no `requestId`; `404` no such account; `401`, `403` as above. A retry with the same `requestId` grants once |

| `GET /billing` | | `200 <plan>`; `?refresh=1` asks Paygate first; `401` signed out; `403` waiting |
| `POST /billing/checkout` | `{ "plan": "team" \| "business", "interval": "month" \| "year" }` | `200 { "url": "<Stripe Checkout>" }`; `400` unknown plan; `409` already on a plan; `502` Paygate failed; `503` no key |
| `POST /billing/checkout` | `{ "plan": "team" \| "business", "interval": "month" \| "year", "code"?: "LAUNCH20" }` | `200 { "url": "<Stripe Checkout>" }`; `400` unknown plan, or a code that isn't valid; `409` already on a plan; `502` Paygate failed; `503` no key |
| `POST /billing/manage` | `{}` | `200 { "url": "<Paygate billing page>" }`; `404` never checked out |
| `GET /billing/promo?code=…` | | `200 { "code": "LAUNCH20", "summary": "20% off for 3 months" }`; `400` unknown, expired or used up |
| `GET /billing/credits` | | `200 { "credit": 2500, "currency": "usd", "history": [{ "amount", "description", "created" }] }` (cents; `credit: 0` before Paygate knows the account) |
| `POST /billing/webhook` | Paygate's event, signed | `200 { "received": true }`; `400` bad or stale signature. Called by Paygate, not the site: no origin check, no session |

`<plan>` is `{ "plan": "free" | "team" | "business", "status", "features", "periodEnd", "cancelAtPeriodEnd" }`.
Expand Down Expand Up @@ -147,24 +150,37 @@ Plans are sold through [Paygate](https://pay.erpai.studio), our Stripe hub
3. **Manage.** `POST /billing/manage` opens Paygate's hosted billing page:
invoices, card, cancel and resume.

**Discount codes** are made in Paygate (Apps → `floodwall` → Discounts: a
coupon with a promotion code, per mode). The site checks one with
`GET /billing/promo` and sends it with the checkout, which applies it up front;
without one, Stripe Checkout still offers its own box for a code. A link like
`/pricing?code=LAUNCH20` keeps the code for the browser tab through sign-up.

**Credit** is Stripe's customer balance: an admin gives it at `/app/admin`
(`POST /admin/credit`, whole dollars, up to $10,000 a grant) and Stripe spends
it on the account's next invoices before charging the card. The account page
shows it (`GET /billing/credits`).

Every account starts on `free`. Plan changes between Team and Business, and
usage-based overage (metered `decisions`, FW-704), come later.

**Paygate setup** (dashboard → Apps → `floodwall`, test mode first): plans
`team` ($49/mo, $490/yr) and `business` ($399/mo, $3,990/yr) with these
features JSON, success and cancel URLs as above, webhook URL
`https://api.floodwall.ai/billing/webhook`, and allowed origins
`https://floodwall.ai`, `https://preview.floodwall.ai`.
**Paygate setup** (done 2026-11-14): app `floodwall`, test mode on the
paygate-sandbox Stripe account and live mode on ERP.AI MA LLC's. Plans
`team` ($49/mo, $490/yr) and `business` ($399/mo, $3,990/yr) with prices in
both modes and these features, webhook URL
`https://api.floodwall.ai/billing/webhook` in both modes, and default return
pages on preview.floodwall.ai (test) and floodwall.ai (live).

```json
{ "planes": 5, "decisions_included": 250000, "retention_days": 90, "approvers": null, "slack": true, "sso": false }
{ "planes": null, "decisions_included": 2500000, "retention_days": 365, "approvers": null, "slack": true, "sso": true, "sla": "99.9" }
{ "planes": 5, "decisions_included": 250000, "overage_per_1k_usd": 0.3, "retention_days": 90, "approvers": "unlimited", "slack": true, "sso": false, "sla": false }
{ "planes": "unlimited", "decisions_included": 2500000, "overage_per_1k_usd": 0.2, "retention_days": 365, "approvers": "unlimited", "slack": true, "sso": true, "sla": true }
```

**Secrets:** `npx wrangler secret put PAYGATE_SECRET_KEY` and
`npx wrangler secret put PAYGATE_WEBHOOK_SECRET`. A `pg_sk_test_…` key keeps
everything in Stripe test mode; going live is swapping in the live key and
live webhook secret, no code change. Run `npm run migrate:remote` for
live webhook secret, no code change (both are in Box,
`erp-ai/floodwall/paygate/secret-keys.env`). Run `npm run migrate:remote` for
`0003_billing.sql` before deploying.

## Local development
Expand Down
44 changes: 44 additions & 0 deletions api/src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
// GET /admin/users every account, the waiting ones first
// POST /admin/approve { email } lets a waiting account in and emails it
// POST /admin/remove { email } deletes an account and its sessions
// POST /admin/credit { email, dollars, note?, requestId } gives an account credit through Paygate

import { adminEmails, isOpen, normalizeEmail, sessionUser } from "./auth";
import type { Env } from "./env";
import { paygate, paygateFailed } from "./billing";
import { HttpError, json, readJson } from "./http";
import { approvedEmail, sendEmail } from "./mail";
import { MESSAGES } from "./rules";
Expand Down Expand Up @@ -107,3 +109,45 @@ export async function removeUser(request: Request, env: Env, now: number) {
if (!removed.meta.changes) throw new HttpError(404, MESSAGES.noSuchUser);
return json({ removed: true });
}

/** The most credit one grant gives, in dollars. */
const MAX_CREDIT_DOLLARS = 10_000;

/**
* Gives an account credit, which Stripe spends on its next invoices before charging its card. The
* page sends a `requestId` per grant and repeats it on a retry, so a grant whose answer was lost
* isn't given twice.
*/
export async function grantCredit(request: Request, env: Env, now: number) {
const admin = await requireAdmin(request, env, now);
const body = await readJson(request);
const email = normalizeEmail(body.email);
if (!email) throw new HttpError(400, MESSAGES.badEmail);
const dollars = body.dollars;
if (typeof dollars !== "number" || !Number.isInteger(dollars) || dollars < 1 || dollars > MAX_CREDIT_DOLLARS) {
throw new HttpError(400, MESSAGES.badCredit);
}
const note = typeof body.note === "string" ? body.note.trim().slice(0, 200) : "";
const requestId = typeof body.requestId === "string" && /^[\w-]{8,64}$/.test(body.requestId) ? body.requestId : null;
if (!requestId) throw new HttpError(400, MESSAGES.badRequest);

const user = await env.DB.prepare("SELECT id FROM users WHERE email = ?").bind(email).first<{ id: string }>();
if (!user) throw new HttpError(404, MESSAGES.noSuchUser);
const path = `/customers/${encodeURIComponent(user.id)}`;

// The customer first, with its email, so the Stripe customer the credit sits on carries it.
const customer = await paygate(env, "PUT", path, { email }, `credit-customer:${requestId}`);
if (customer.status !== 200) paygateFailed("customer", customer);
const answer = await paygate(
env,
"POST",
`${path}/credits`,
{ amount: dollars * 100, currency: "usd", description: note || `Credit from Floodwall (${admin.email})` },
`credit:${requestId}`,
);
if (answer.status === 400 && answer.body?.error?.code === "currency_mismatch") {
throw new HttpError(400, answer.body.error.message ?? MESSAGES.badCredit);
}
if (answer.status !== 200 && answer.status !== 201) paygateFailed("credit", answer);
return json({ granted: dollars * 100, credit: Number(answer.body.credit) || 0, currency: answer.body.currency ?? "usd" });
}
68 changes: 62 additions & 6 deletions api/src/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
// GET /billing the signed-in account's plan (?refresh=1 asks Paygate first)
// POST /billing/checkout { plan, interval } a Stripe Checkout page for Team or Business
// POST /billing/manage Paygate's hosted billing page: invoices, card, cancel
// GET /billing/promo?code=… what a discount code takes off, before checkout
// GET /billing/credits the account's credit, spent on its next invoices
// POST /billing/webhook Paygate's webhooks (signed; no session, no site origin)
//
// Discount codes are made in Paygate (the app's Discounts tab); credit is granted by an admin
// (POST /admin/credit in admin.ts) and is Stripe's customer balance.

import { sessionAccount } from "./auth";
import type { Env } from "./env";
Expand Down Expand Up @@ -60,14 +65,29 @@ function secretKey(env: Env) {
return env.PAYGATE_SECRET_KEY;
}

/** One call to Paygate's app API. Returns the status and parsed body; a network failure is a 502. */
async function paygate(env: Env, method: string, path: string, body?: unknown) {
/** A discount code as the site may send it: letters, digits, dashes and underscores. */
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;

/** The code in `value`, trimmed, or null when there is none; a malformed one is a 400. */
export function discountCode(value: unknown) {
if (value === undefined || value === null || value === "") return null;
const code = typeof value === "string" ? value.trim() : "";
if (!CODE_PATTERN.test(code)) throw new HttpError(400, MESSAGES.badCode);
return code;
}

/**
* One call to Paygate's app API. Returns the status and parsed body; a network failure is a 502.
* A call with a body carries an Idempotency-Key: `idempotencyKey` when the caller has one to
* repeat on a retry, otherwise a new one.
*/
export async function paygate(env: Env, method: string, path: string, body?: unknown, idempotencyKey?: string) {
const key = secretKey(env);
const headers: Record<string, string> = { Authorization: `Bearer ${key}` };
if (body !== undefined) {
headers["Content-Type"] = "application/json";
// Paygate replays the first answer to a repeated key; each call here is a new intent.
headers["Idempotency-Key"] = crypto.randomUUID();
// Paygate replays the first answer to a repeated key.
headers["Idempotency-Key"] = idempotencyKey ?? crypto.randomUUID();
}
let response: Response;
try {
Expand All @@ -90,7 +110,7 @@ async function paygate(env: Env, method: string, path: string, body?: unknown) {
return { status: response.status, body: parsed };
}

function paygateFailed(what: string, answer: { status: number; body: any }): never {
export function paygateFailed(what: string, answer: { status: number; body: any }): never {
console.error(`paygate: ${what} refused`, answer.status, JSON.stringify(answer.body?.error ?? null).slice(0, 300));
throw new HttpError(502, MESSAGES.billingUnavailable);
}
Expand Down Expand Up @@ -155,12 +175,14 @@ export async function startCheckout(request: Request, env: Env, now: number, ori
const plan = PAID_PLANS.find((p) => p === body.plan);
const interval = INTERVALS.find((i) => i === (body.interval ?? "month"));
if (!plan || !interval) throw new HttpError(400, MESSAGES.badPlan);
const code = discountCode(body.code);

const answer = await paygate(env, "POST", "/checkout", {
customer: { external_id: id, email: user.email },
plan,
interval,
allow_promotion_codes: true,
// A code chosen on the site is applied up front; without one, Checkout offers a box for it.
...(code ? { promotion_code: code } : { allow_promotion_codes: true }),
// Back to the site the reader came from: one of ALLOWED_ORIGINS.
success_url: `${origin}/account?billing=success`,
cancel_url: `${origin}/pricing`,
Expand All @@ -169,6 +191,7 @@ export async function startCheckout(request: Request, env: Env, now: number, ori
if (answer.status === 409 && answer.body?.error?.code === "already_subscribed") {
throw new HttpError(409, MESSAGES.alreadySubscribed);
}
if (refusedCode(answer)) throw new HttpError(400, MESSAGES.badCode);
if (answer.status !== 200 && answer.status !== 201) paygateFailed("checkout", answer);
return json({ url: answer.body.url });
}
Expand All @@ -181,6 +204,39 @@ export async function manageBilling(request: Request, env: Env, now: number) {
return json({ url: answer.body.url });
}

const refusedCode = (answer: { status: number; body: any }) =>
answer.status === 400 && answer.body?.error?.code === "invalid_promotion_code";

/** What a discount code takes off ("20% off for 3 months"), so the site can show it before checkout. */
export async function lookupCode(request: Request, env: Env, now: number) {
await requireMember(request, env, now);
const code = discountCode(new URL(request.url).searchParams.get("code"));
if (!code) throw new HttpError(400, MESSAGES.badCode);
const answer = await paygate(env, "GET", `/promotion-codes/${encodeURIComponent(code)}`);
if (refusedCode(answer) || answer.status === 404) throw new HttpError(400, MESSAGES.badCode);
if (answer.status !== 200) paygateFailed("promotion code", answer);
return json({ code: answer.body.code as string, summary: (answer.body.coupon?.summary ?? null) as string | null });
}

/** One line of the account's credit history: what was granted or spent, in cents. */
type CreditLine = { amount: number; description: string | null; created: string | null };

/** The account's credit: Stripe spends it on the next invoices before charging the card. */
export async function currentCredits(request: Request, env: Env, now: number) {
const { id } = await requireMember(request, env, now);
const answer = await paygate(env, "GET", `/customers/${encodeURIComponent(id)}/credits?limit=10`);
// No Paygate customer yet: never bought or been given anything.
if (answer.status === 404) return json({ credit: 0, currency: null, history: [] });
if (answer.status !== 200) paygateFailed("credits", answer);
const history: CreditLine[] = (answer.body.transactions ?? []).map((t: any) => ({
// Stripe counts credit as a negative balance; the site shows what was added as positive.
amount: -Number(t.amount),
description: t.description ?? null,
created: t.created ?? null,
}));
return json({ credit: Math.max(0, Number(answer.body.credit) || 0), currency: answer.body.currency ?? null, history });
}

/** Checks `Paygate-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>`. */
export async function verifySignature(secret: string, header: string | null, body: string, now: number) {
if (!header) return false;
Expand Down
10 changes: 8 additions & 2 deletions api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// The Worker: routes each request, lets the allowed sites read the answer,
// and sweeps expired rows on its daily schedule.

import { approveUser, listUsers, removeUser } from "./admin";
import { currentPlan, manageBilling, receiveWebhook, startCheckout } from "./billing";
import { approveUser, grantCredit, listUsers, removeUser } from "./admin";
import { currentCredits, currentPlan, lookupCode, manageBilling, receiveWebhook, startCheckout } from "./billing";
import { currentSession, sendCode, signOut, sweep, updateProfile, verifyCode } from "./auth";
import type { Env } from "./env";
import { allowedOrigin, HttpError, json, preflight, withCors } from "./http";
Expand Down Expand Up @@ -34,12 +34,18 @@ async function route(request: Request, env: Env, origin: string | null) {
return approveUser(request, env, now, origin!);
case "POST /admin/remove":
return removeUser(request, env, now);
case "POST /admin/credit":
return grantCredit(request, env, now);
case "GET /billing":
return currentPlan(request, env, now);
case "POST /billing/checkout":
return startCheckout(request, env, now, origin!);
case "POST /billing/manage":
return manageBilling(request, env, now);
case "GET /billing/promo":
return lookupCode(request, env, now);
case "GET /billing/credits":
return currentCredits(request, env, now);
}
throw new HttpError(404, MESSAGES.notFound);
}
Expand Down
2 changes: 2 additions & 0 deletions api/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,7 @@ export const MESSAGES = {
billingUnavailable: "Billing is not available right now. Please try again later.",
alreadySubscribed: "You already have a plan. Manage it from Billing on your account page.",
noBilling: "You have no billing history yet. Choose a plan first.",
badCode: "That code isn't valid. Check it for typos, or ask us for a new one.",
badCredit: "Give credit as a whole number of dollars, from $1 to $10,000.",
broken: "Something went wrong. Please try again.",
};
Loading
Loading