@misar/billing-guards (0.1.0)
Installation
@misar:registry=npm install @misar/billing-guards@0.1.0"@misar/billing-guards": "0.1.0"About this package
@misar/billing-guards
Zero-dependency CI guards that statically validate Stripe payment +
subscription lifecycle correctness across the Misar AI monorepo. Advisory-first:
findings emit ::warning and exit 0 until a repo opts into blocking.
Sibling of @misar/guards — same architecture (config schema, guard
shape, CLI flags, Forgejo annotations), scoped to billing. Node built-ins only,
plain ESM .mjs.
pnpm dlx @misar/billing-guards # advisory (default): warnings, exit 0
pnpm dlx @misar/billing-guards --blocking # error findings fail the build
pnpm dlx @misar/billing-guards --list # list guard ids
pnpm dlx @misar/billing-guards --json # machine-readable { ran, counts, findings }
CLI contract
misar-billing-guards [options]
--advisory Emit ::warning and exit 0 (this is the DEFAULT).
--blocking Make error findings fail the build (overrides advisory default).
--only=<ids> Run only these guard ids (comma-separated). Bypasses config.
--json JSON output ({ ran, counts, advisory, findings }). Still sets exit.
--config=<path> Config file (default: ./billing-guards.config.json).
--list List guard ids + default-on/opt-in and exit.
-h, --help Help.
Exit: 0 no findings (or advisory) 1 error findings (blocking) 2 usage/config error
Each finding is { guard, file, line, message, severity } (severity is
"error" or "warning"), rendered as
::error file=<f>,line=<n>,title=misar-billing-guards/<id>::<message>.
The nine guards
| id | asserts |
|---|---|
webhook-handler-coverage |
every event in the route's required-event manifest has a handler in the dispatch (eventHandlers map / switch (event.type)); recommended events warn (e.g. invoice.upcoming) |
webhook-idempotency |
the dedup gate (check_and_record_webhook_event / insert into *webhook_events*) runs before handler dispatch; missing gate is an error |
webhook-secret-assert |
the route reads its STRIPE_WEBHOOK_SECRET (+ Connect secret), returns non-2xx when absent, and declares the env name in .env.example |
webhook-middleware-exemption |
webhook paths aren't SSO-redirected — misar-io /io/wallet/webhook stays OUTSIDE PROTECTED_PREFIXES; matcher-style middleware exempts the webhook namespace |
stripe-apiversion-pinning |
a single pinned apiVersion across every new Stripe(...) site; flags drift and unpinned clients |
plan-price-integrity |
every non-free plan slug declares a stripe_price_id (+ yearly when a yearly price exists) |
email-per-event-coverage |
handled notifying events (trial-ending, payment-failed, canceled) have a reachable send*Email call — flags a webhook that sends none |
no-direct-billing-table-writes |
user_subscriptions / subscription_plans / user_wallets / wallet_transactions are mutated only from an allowlisted surface |
idempotency-fn-boolean-guard |
the idempotency SQL fn captures ROW_COUNT into an INTEGER variable (guards the assisters migration-257 boolean>integer regression) |
All nine are default-on; each no-ops gracefully when the manifest it needs is
absent (e.g. no billing.webhookRoutes ⇒ the coverage/idempotency/secret/email
guards find nothing). stripe-apiversion-pinning and
no-direct-billing-table-writes work with no manifest at all.
Config — billing-guards.config.json
{
"advisory": true, // default true; set false to block
"ignore": ["packages/billing-guards"],
"billing": { /* per-repo manifest, see below */ },
"guards": {
"stripe-apiversion-pinning": { "expectedVersion": "2025-12-15.clover", "allowUnpinned": false },
"no-direct-billing-table-writes": true
// a guard listed true/{opts} is on; false is off; unlisted ⇒ default-on
}
}
The billing block is the per-repo manifest shared across guards. Full,
copy-pasteable per-repo variants (assisters / misar-io / MisarReach / MisarMail)
live in billing-guards.config.example.json.
Field reference:
"billing": {
"envManifest": ".env.example", // where secret env names must be declared
"webhookRoutes": [
{
"path": "apps/web/src/app/api/webhooks/stripe/route.ts", // repo-relative route
"secretEnv": "STRIPE_WEBHOOK_SECRET",
"connectSecretEnv": "STRIPE_CONNECT_WEBHOOK_SECRET", // optional (Connect)
"dedupFn": "check_and_record_webhook_event", // dedup gate name
"requiredEvents": ["customer.subscription.updated", "..."], // error if unhandled
"recommendedEvents": ["invoice.upcoming"], // warn if unhandled
"notifyEvents": ["customer.subscription.trial_will_end", "invoice.payment_failed", "customer.subscription.deleted"],
"handlerDir": "apps/web/src/app/api/webhooks/stripe/handlers", // extra dir scanned for send*Email
"emailFnPattern": "send\\w*Email\\s*\\(", // optional override
"requireIdempotency": true, // set false for a pure verify-only proxy
"noEmail": true // waive email-per-event-coverage for this route
}
],
"middleware": {
"path": "apps/web/src/middleware.ts",
"protectedArrayName": "PROTECTED_PREFIXES", // shape A (misar-io)
"mustStayUnprotected": ["/io/wallet/webhook"],
"matcherMustExempt": ["api", "webhooks", "unsubscribe"] // shape B (matcher tokens)
},
"plansFiles": [
{ "path": "src/lib/billing/reach-plans.ts", "priceField": "stripe_price_id",
"yearlyPriceField": "stripe_price_id_yearly", "yearlyMarkerField": "price_yearly",
"freeSlugs": ["free", "enterprise"] }
],
"billingTables": ["user_subscriptions", "subscription_plans", "user_wallets", "wallet_transactions"],
"billingWriteAllowlist": ["webhook", "checkout", "start-trial", "upsert-subscription", "supabase/migrations", "scripts/seed"],
"idempotencyFnNames": ["check_and_record_webhook_event"]
}
Waivers & false positives
- Per-route email waiver:
"noEmail": trueon the route, or a// billing-guards:no-emailcomment in the route file. - Repo-wide skips: add path prefixes to the top-level
ignorearray. stripe-apiversion-pinningtreats a client pinned via an identifier ({ apiVersion: STRIPE_API_VERSION }) as pinned and consistent.
CI
Reusable + drop-in Forgejo workflow lives at
.forgejo/workflows/billing-guards.yml (advisory pull_request + workflow_call).
The nightly Stripe test-clock lifecycle suite is scaffolded at
.forgejo/workflows/billing-e2e.yml. See
docs/DEVOPS/billing-validation.md for
the per-PR-vs-nightly split, honest limits, and the per-product event manifest.
Test
node tests/test.mjs # fixture-based, temp-dir, 28 assertions, zero deps