Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 644bd3989d | |||
| 040fe0abe5 | |||
| 0516b1e438 |
@@ -43,6 +43,22 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
|
||||
49. **D-052 tooling conclusions** — coverage: pytest-cov (Python) + c8 wrapped around existing `tsx --test` (no runner swap, no vitest); markers `fast`/`real_server`/`userns` under `--strict-markers`, unmarked defaults to fast, fast run = `-m "not real_server and not userns"`; pytest-timeout `signal` method, 60s global / 180s real-server+userns via collection hook (D-054); CLI fresh-clone E2E env-gated (`CLI_E2E=1`) into `cli:test:e2e` with a 900s budget (D-055); `filterwarnings = ["error", <narrow module+message-scoped upstream ignores>]` (D-056); mypy relaxed-first baseline (`check_untyped_defs`, `warn_unused_ignores`, no full strict) (D-058); recharts 2→3 verified by tsc + static build + existing tests (D-059); shared Python helpers in `tests/helpers/` package, fixtures stay in conftest (D-060); pyproject.toml is the single version source, `FastAPI(version=)` reads package metadata (D-061).
|
||||
50. **D-062 v0.7 wave order = dependency-ordered: wiring before composition before elimination before hardening** — P1 harness wiring (coverage/timeout/markers/turbo tasks/leak fixes) MUST precede P2 suite composition because the cut/consolidate decisions are evidence-based (P1's coverage data tells P2 what to cut); P3 warning elimination is independent of P1/P2 but ordered after them to avoid rebasing `session.exec()` conversions over moved test files; P4 lint hygiene (ESLint + noqa RUF100) after P3 so the eslint-disable resolutions happen under a warning-clean tree; P5 type+dependency hardening last (mypy adoption and recharts 3 are the riskiest changes — they run against a fully-stabilized harness, and recharts gets the fresh coverage/ESLint net as its safety net); P6 final review. ESLint adoption (D-048) rides P4 because the orphaned eslint-disable comments only become meaningful once a config exists.
|
||||
|
||||
### v0.9 Payment Engine Architecture (Phase 0 RESEARCH — Payment Engine, D-075..D-084 binding)
|
||||
|
||||
**Module:** `ai_service/billing/` (7th domain module, D-031 pattern — extend ai-service, no new apps). Boundary rule (family discipline): `billing/` never imports `agents/` or `api/`; it imports `config` + its own `base.py` only. Files: `base.py` (PaymentProvider protocol + pydantic contracts + normalized event names), `store.py` (7th D-027 SQLite store), `mock.py` (deterministic provider, Stripe-shaped ids/signatures per R-36/R-48), `stripe.py` (Stripe-compatible REST impl — form-encoded bodies, Basic auth, Idempotency-Key per R-33/R-49), `factory.py` (env-gated selection, UnknownPaymentProviderError → G-11 loud mock fallback), `catalog.py` (seeded catalog constant + idempotent seed).
|
||||
|
||||
**Provider protocol (D-075):** `create_checkout_session(spec) -> CheckoutSession`, `retrieve_session(id)`, `cancel_subscription(id, *, at_period_end)`, `update_subscription(id, new_price_ref, ...)` (resolves subscription_item id inside the provider — R-47 dual-charge guard), `verify_webhook(raw_body: bytes, signature_header: str, *, now=None) -> VerifiedEvent` (HMAC-SHA256 over `"{t}.{raw_body}"`, compare_digest, all-v1 iteration for secret rolls, ±300s replay window, ignore non-v1 schemes — R-38..R-40), `normalize_event(provider_event) -> CanonicalBillingEvent` (R-43 mapping).
|
||||
|
||||
**Store (D-080, 7 tables):** `packages` (one-time: posts_limit, price_cents, duration_days), `plans` (recurring: interval, price_cents), `purchases` (posts_limit/posts_used/expires_at snapshot + price snapshot — D-078), `subscriptions` (state machine: trialing/active/past_due/cancelled/expired/upgraded, expires_at, provider ref), `subscription_events` (provider event log, verbatim ids, UniqueConstraint event_id — dedupe), `checkout_sessions` (client_reference_id reconciliation), `billing_events` (full audit: provider events + manual admin actions with provenance). Same SQLite file (`Settings().db_path`), WAL pragmas, Protocol + SQLiteBillingStore pair, main.py lifespan wiring with state-injection override.
|
||||
|
||||
**Routes:** `api/billing.py` (`/v1/billing/catalog` public GET; `/v1/billing/webhook` public POST — raw-body-first HMAC verify, then parse, then idempotent process, 200-fast) + `api/billing_admin.py` (`/v1/billing/admin/*` — require_admin dep verbatim R-51: checkout on behalf of employer (D-076), entitlement view, manual grant/revoke, event log query) + mock-only confirm route (D-082, mounted only when provider=mock). Entitlement enforcement composes into `create_posting` AFTER require_admin: billing store's `consume_posting(employer_id)` → domain error → route translates to `HTTPException(402, detail={reason, posts_used, posts_limit, upgrade_cta})` (dict-detail precedent = require_verified_age).
|
||||
|
||||
**Settings (R-53):** `payment_provider: str = "mock"`, `payment_api_key: SecretStr`, `webhook_secret: str = ""` (unset → per-boot random → webhooks fail closed — correct for a payment surface). Env: `AI_PAYMENT_PROVIDER`, `AI_PAYMENT_API_KEY`, `AI_WEBHOOK_SECRET`.
|
||||
|
||||
**Web (REQ-9-002):** pricing page → server shell (metadata retained, A-411/pins) + `PricingClient` component fetching `/v1/billing/catalog` (A-406 board-client pattern, AbortController + honest loading/error/empty states); `engine-client.ts` gains `listBillingCatalog()` + `PaymentRequiredError` (402 dict-detail discrimination); types in `packages/types/billing.ts` dual-schema-pinned in pins.test.ts. Admin entitlement view: moderation surface gains per-employer entitlement state (client fetch, same pattern).
|
||||
|
||||
**Money (D-081):** integer cents end-to-end; pydantic `int ge=0`; TS `number` documented as cents; currency `usd` fixed.
|
||||
|
||||
### v0.8 Distribution & Release Architecture (Phase 0 RESEARCH — Ship Anywhere, D-063..D-072 binding)
|
||||
|
||||
**Runner fleet topology (CT 113, 10.0.0.113):** three org-level gitea-runner containers (official image `docker.io/gitea/runner:3` — the Docker Hub `gitea/act_runner` repo is deprecated, renamed `gitea/runner`) in **sibling-container mode**: each mounts the host `/var/run/docker.sock`, has its OWN `/data` volume holding the `.runner` registration file (identity + credentials; shared state = re-registration), registers once against `https://git.coreci.dev` (Gitea 1.26.1, probe-verified) with the founder-provided org-level registration token (chat-only, reset after fleet green). Hybrid labels via `GITEA_RUNNER_LABELS`: `node` → `docker://node:24-bookworm`, `python` → `docker://python:3.11-bookworm`, `release` → `docker://node:24-bookworm`, `ubuntu-latest` → `docker://docker.gitea.com/runner-images:ubuntu-latest` (label format `name[:schema[:args]]`; a job's `runs-on` matches label names, the `docker://` image selects the job container). Per-runner `runner.capacity: 1` (default; also caps per-job docker subnet consumption) → fleet concurrency 3. Cache: each runner's built-in cache server must be pinned to a job-reachable `cache.host` + fixed published port (dockerized runners otherwise create per-job networks the cache address is unreachable in — `actions/cache` fails ETIMEDOUT); 32 GB disk guarded by `cache.repo_size_limit`, `health_check.min_free_disk_space_mb`, and a host-side `docker system prune` cron. Workspace-path alignment inside/outside the runner container is NOT needed for our jobs (no `container.bind_workdir`; `docker build` TARS the context over the API) — documented as the known sibling-mode gotcha if a future workflow bind-mounts.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"stage": "execute",
|
||||
"milestone": "v0.9",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-29T19:20:00Z"
|
||||
}
|
||||
@@ -1,3 +1,48 @@
|
||||
# Nextcraft v0.9 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS (REVISE → record G-301..G-310, then SHIP) · **Confidence:** 0.85 · **Date:** 2026-09-29
|
||||
|
||||
## Summary
|
||||
|
||||
The v0.9 Payment Engine plan (5 requirement phases + final, D-074 locked) is feasible, correctly scoped, and hermetically testable — the operator-mediated checkout model is coherent end-to-end and entitlement needs no employer auth. But red-teaming against the working tree (not the plan's description of it) found 10 binding defects: a mock-complete route mounted on every default-config production box (D-082's "never on a real deployment" rationale is **false** — mock IS the default), webhook handlers that would trust forgeable event payloads (leaked secret = free entitlements), a check-then-increment race in consume_posting, event-id-only dedupe defeated by Stripe's dual invoice events, a phantom `upgraded` state with no transition rows, an un-pinned ingest bypass that would 402 the seeded corpus, a checkout route naming contradiction between REQUIREMENTS/ROADMAP and binding D-076, an admin panel requiring a browser token entry that exists nowhere in apps/web, and a stripe checkout that cannot create sessions (success_url never populated — Happy Path 5's "zero code changes" was false). Two plan citations proved wrong in-tree (the "sitemap env-gated routes" precedent does not exist; admin surface has zero admin-token plumbing). Two Gs bite inside P1's Task 1-1-02 — recording them now prevents store-schema retrofit churn after P1 ships. Nothing re-architects: every G amends an existing task, must-have, or requirement sentence; the phase/wave structure is untouched.
|
||||
|
||||
## Per-Axis Findings
|
||||
|
||||
| # | Axis | Verdict | Reason |
|
||||
|---|------|---------|--------|
|
||||
| 1 | Feasibility | PASS | Operator-mediated checkout coherent end-to-end; entitlement composes after require_admin with employer_id as body field; 6-phase shape matches the proven v0.6 cadence |
|
||||
| 2 | Scope creep | CONCERN | REQ-9-003/ROADMAP name a public POST /v1/billing/checkout contradicting D-076 (G-307); admin panel hides a browser-token requirement (G-308); "stripe switch = zero code changes" false without success/cancel URLs (G-310) |
|
||||
| 3 | Security | CONCERN | Mock-complete route unauthenticated on default deployments (G-301); webhook state resolution unspecified — payload trust = forged entitlements (G-302) |
|
||||
| 4 | Correctness | CONCERN | consume_posting check-then-increment race (G-304); dual invoice events defeat event-id dedupe (G-303); `upgraded` state hole (G-305); out-of-order events against missing targets + grace timing unspecified (G-306) |
|
||||
| 5 | Testability | PASS | Hermetic by construction: MockTransport byte-contracts, now-injection, direct-handler-call mock-complete — provided G-306 keeps grace lazy |
|
||||
| 6 | Backward compat | PASS | Fixture strategy workable (~9 test sites grant unlimited to legacy seeded employers); pricing metadata pin survives conversion; ingest bypass unpinned → G-309 |
|
||||
| 7 | Cost/benefit | PASS | D-074 stands: payments unblocks employer self-serve; no new deps (hand-rolled REST, no Stripe SDK); challenge declined |
|
||||
| 8 | Plan-vs-reality | PASS (with corrections) | All cited patterns verified in-tree; two rationale citations false (G-301/G-308); suite count 674/625-fast drift noted |
|
||||
|
||||
## Binding Decisions (G-301..G-310 — applied to PLAN/REQUIREMENTS/ROADMAP before execute)
|
||||
|
||||
**G-301 — mock-complete route: admin-gate it; D-082's "never on a real deployment" rationale is false.** The default provider is mock (D-075) and the pilot IS a real deployment — `POST /v1/billing/mock/complete` mounted "only when provider=mock" is mounted on every default-config production box. An unauthenticated completion route on a payment surface is a hole no matter how unguessable `cs_mock_…` ids are. Decision: keep the provider=mock conditional mount AND compose `require_admin` on the route — the operator completing a session after out-of-band payment is exactly the honest pilot flow D-076 describes. Amends PLAN Task 3-2-03, MH-3c; D-082's rationale corrected (ship-notes record: the sitemap "env-gated routes" precedent does not exist — this is the first factory-conditional mount in-tree). Tests: no-token → 403 under mock; route 404s under stripe; E2E green with admin-gated complete. Confidence 0.85.
|
||||
|
||||
**G-302 — webhook events are untrusted ID references; state resolves from local rows.** If checkout.completed reads session status/amount from the event payload, a leaked AI_WEBHOOK_SECRET = free entitlements (forge status=complete, payment_status=paid for any session id). Decision: handlers treat event payloads as ID references only — the session resolves from the local checkout_sessions row (creatable only via the admin route); unknown session id → recorded as an `unmatched` billing_event, NO entitlement, 200. Purchase price/name from the stored snapshot (D-078), never the event body; renewal amounts from the local plan row. Amends Task 3-2-02. Tests: signed checkout.completed with no local row → 200, zero purchases, one unmatched event; inflated payload amount → purchase price = snapshot. Confidence 0.85.
|
||||
|
||||
**G-303 — grants are idempotent per session, not per event id.** R-43/D-083 accept invoice.paid AND invoice.payment_succeeded and Stripe's async path fires distinct completed events — different event ids for the same logical completion sail past event-id dedupe and double-grant. Decision: `purchases.checkout_session_id` gets a UniqueConstraint; subscription upsert keys on provider subscription id; entitlement creation is exactly-once per session regardless of event count. Amends Task 1-1-02 (schema), Task 3-2-02; extends MH-3b. Test: two signed checkout.completed events, different event ids, same session → exactly one purchase row (IntegrityError → created=False), one subscription. Confidence 0.85.
|
||||
|
||||
**G-304 — consume_posting is a single conditional UPDATE, not check-then-increment.** insert_application survives concurrency via UniqueConstraint; posts_used has no such backstop — two concurrent create_posting calls both read posts_used=9 and both increment. Decision: consume_posting = one statement `UPDATE purchases SET posts_used = posts_used + 1 WHERE id = <newest non-expired with remaining> AND posts_used < posts_limit` (rowcount-checked; 0 rows → PackageExhausted/NoActivePackage). Amends Task 1-1-02. Test: 1 post remaining, two concurrent consumes on separate connections (WAL) → exactly one succeeds. Confidence 0.80.
|
||||
|
||||
**G-305 — `upgraded` is not a status state.** D-083's "(+ upgraded)" has no transition-matrix rows and REQ-9-005's canonical states don't include it. Decision: upgrade = plan_id transition within `active` (provider update per R-47 with items[0][id] — pinned by MH-3d), recorded as a billing_event with provenance; `upgraded` removed from the status enum — D-083's "(+ upgraded)" reinterpreted as the auditable upgrade ACTION, not a state. Amends Task 5-1-01, MH-5a. Tests: upgrade on active → status stays active, plan_id changes, billing_event exists; stored-status set contains no `upgraded`. Confidence 0.75.
|
||||
|
||||
**G-306 — out-of-order tolerance includes missing targets; grace expiry is lazy, not a timer.** (a) invoice.payment_failed/invoice.paid can arrive before checkout.completed creates the local subscription — handlers never 500 on unknown targets: record as unmatched (G-302 marker), return 200; recovery is the grant or admin manual complement. (b) past_due → expired at 7 days is computed lazily from past_due_at + 7d at read/entitlement time — no background timer, keeping tests hermetic. Amends Task 3-2-02 (tolerance rule), Task 5-1-01 (grace semantics); risks 3-4. Tests: signed invoice.paid for unknown subscription → 200 recorded no crash; entitlement check with injected now at past_due_at+6d → granted, +8d → 402/expired; no sleeps. Confidence 0.80.
|
||||
|
||||
**G-307 — align REQ-9-003/ROADMAP route naming to D-076.** REQUIREMENTS REQ-9-003 and ROADMAP phase-3 say `POST /v1/billing/checkout`; binding D-076, PLAN, and ARCHITECTURE say `/v1/billing/admin/checkout` behind require_admin. Decision: amend the wording at this gate (the requirement text must not name a public un-gated checkout route that will not exist). Test: route-inventory pin — no /v1/billing/checkout route exists; /v1/billing/admin/checkout 403s without token. Confidence 0.90.
|
||||
|
||||
**G-308 — define the browser-side admin-token entry; the admin panel is otherwise unreachable.** The admin surface is mock-data driven; no X-Admin-Token exists in apps/web. REQ-9-004's "admin surface shows per-employer entitlement state" hides a new requirement: an operator token entry in the browser — Task 4-2-01's A-406 citation is wrong (A-406 is for public routes). Decision: a small token input in the entitlement panel persisted to sessionStorage, sent by a new adminFetch wrapper in engine-client (X-Admin-Token header); 403 renders an honest locked state with the token prompt; the token is never logged. Amends Task 4-2-01. Tests: stubbed 403 → locked-state render; fetch stub asserts the header; no admin-token string in non-test web source beyond the wrapper. Confidence 0.75.
|
||||
|
||||
**G-309 — pin the ingest bypass: seed ingestion is never entitlement-gated.** Enforcement composes into the admin create_posting route ONLY, but the seeded-corpus ingest upserts ~20 postings directly through the store and must never hit the 402 path. Decision: explicit rule + test — run_ingest (and any future adapter) bypasses consume_posting by design; enforcement exists only in the admin route. Amends Task 4-1-01 (test list), risk 5 companion. Test: seeded ingest on a store with zero purchases → 20 postings upserted, zero 402s, zero consumption rows. Confidence 0.90.
|
||||
|
||||
**G-310 — default success/cancel URLs for the stripe checkout, or delete the "env-only switch" claim.** R-34: success_url is required for hosted checkout; the admin checkout body is {item_id, employer_id} and no task populates it — the stripe path cannot create sessions as planned. Decision: the checkout route defaults success_url = {public_base_url}/marketplace/pricing?checkout=success and cancel_url = …?checkout=cancelled (public_base_url exists at config.py:170); loud G-11 boot warning when stripe is selected and public_base_url is unset. Amends Task 3-2-01 (defaults) + Task 3-1-01 (byte-contract asserts both fields). Tests: MockTransport byte-contract shows success_url/cancel_url present; factory-selection test shows the loud warning when stripe + empty origin. Confidence 0.80.
|
||||
|
||||
---
|
||||
|
||||
# Nextcraft v0.6 — GRILL.md (Adversarial Review Verdict)
|
||||
|
||||
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** GO-WITH-BINDINGS · **Confidence:** 0.72 · **Date:** 2026-09-21
|
||||
|
||||
+44
-42
@@ -2,18 +2,17 @@
|
||||
|
||||
## Persona Roster
|
||||
|
||||
> **v0.8 update (RESEARCH, lead-developer assessment):** milestone = Ship Anywhere (Feature — release/distribution rails: runner fleet, docker image, deb/arm64 packaging, CLI onboarding glue, admin guides). **Activated:** devops-engineer (NEW custom persona — the runner fleet on CT 113, docker-compose + labels + cache hygiene, .gitea/workflows push + tag lanes, Dockerfile + registry push, pre-commit config; the whole release-consistency surface REQ-8-001/002), cli-engineer (re-activated from its v0.4/v0.4.6 territory — build-binary.mjs arch parameter + arm64 node base, install.sh arch-resolve, deb packaging scripts, doctor container/deb-channel awareness, `guide` embedded text, bootstrap/deploy/dev container refusal — REQ-8-003/004), sandbox-engineer (re-activated for the D-068 capability probe promotion into sandbox/capability.py + the 503 degrade arm in api/sandboxes.py — its unshare_backend/manager/api-surface territory), lead-developer (coordination + wave order + the P5 admin-guide assembly), security-auditor (phase-specific: release secrets handling REGISTRY_TOKEN/GITEA_TOKEN/registration token, image non-root + healthcheck, sibling-mode blast radius, deb postinst). **Deactivated:** backend-engineer (no config/api surface work beyond what sandbox-engineer owns — the D-068 wiring lives in sandbox+main.py boot notice, owned by sandbox-engineer this milestone), frontend-engineer (no web-app changes; apps/web is only a BUILD INPUT to the image), ai-engineer, data-engineer, identity-engineer, voice-engineer, design-system-engineer.
|
||||
> **v0.9 update (RESEARCH, lead-developer assessment):** milestone = Payment Engine (Feature — ai_service/billing/ module: provider protocol + mock + stripe impl, 7th D-027 store, /v1/billing routes with HMAC webhooks, posting entitlement enforcement, pricing-page + admin-surface runtime wiring). **Activated:** backend-engineer (owns ai_service/billing/* + api/billing*.py + api/deps.py + main.py router/lifespan wiring — REQ-9-001/003/004/005 core), data-engineer (owns the 7-table billing store schema: idempotency UniqueConstraints, purchase price snapshots, subscription state machine, event dedupe — REQ-9-001/005), security-auditor (phase-specific: webhook HMAC verify + replay window + raw-body discipline, fail-closed secrets, cents money handling, 402 CTA payloads — REQ-9-003), frontend-engineer (owns the pricing-page client conversion + engine-client listBillingCatalog/PaymentRequiredError + packages/types/billing.ts + admin entitlement view — REQ-9-002/004), lead-developer (coordination + wave order + dual-schema pin discipline + cross-boundary wire review). **Deactivated:** cli-engineer (no CLI changes — billing is server+web only), sandbox-engineer, devops-engineer (no infra changes; the existing release lane carries the new code automatically), ai-engineer, identity-engineer, voice-engineer, design-system-engineer.
|
||||
|
||||
### lead-developer
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: Coordinates the v0.8 Ship Anywhere milestone (fleet → container → debian → onboarding → guides waves); owns the admin-guide assembly (P5) as the single cross-channel runbook; owns wave-order + the release-asset matrix consistency check (every release: x64/arm64 binaries + debs + image tag per D-064/D-065)
|
||||
reason: Coordinates the v0.9 Payment Engine milestone (foundation → catalog → checkout → enforcement → lifecycle waves); owns wave order + the cross-boundary wire review (billing.ts ↔ base.py dual-schema pins, 402 CTA payload shape) + provider-boundary event normalization consistency (R-43)
|
||||
domain: coordination
|
||||
frameworks:
|
||||
- turborepo
|
||||
- pnpm
|
||||
- gitea-actions
|
||||
- docker
|
||||
constraints:
|
||||
- pragmatic
|
||||
- battle-tested defaults
|
||||
@@ -21,15 +20,14 @@ constraints:
|
||||
territory:
|
||||
- "**/package.json"
|
||||
- "**/turbo.json"
|
||||
- "deploy/README.md"
|
||||
- "README.md"
|
||||
- "apps/web/tests/pins.test.ts"
|
||||
```
|
||||
|
||||
### devops-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: NEW v0.8 custom persona — owns the entire release-consistency surface: 3 org-level gitea/runner:3 containers on CT 113 (sibling mode, host docker.sock, per-runner /data, GITEA_RUNNER_LABELS, capacity 1, cache.host pinning + 32 GB prune cron — REQ-8-001), .gitea/workflows push lane (node/python lanes with the repo's own scripts, D-063 fast-only test lanes) + tag lane (exclusive release owner D-064), multi-stage Dockerfile + git.coreci.dev/coreci/nextcraft push (semver + latest per D-065 — REQ-8-002), root .pre-commit-config.yaml (D-070)
|
||||
reason: v0.9 DEACTIVATED — no infra changes; billing rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
|
||||
domain: devops
|
||||
frameworks:
|
||||
- docker
|
||||
@@ -39,23 +37,23 @@ frameworks:
|
||||
- bash
|
||||
constraints:
|
||||
- sibling-mode-socket-trust (org-scoped private repos only; accepted-risk documented)
|
||||
- secrets-via-actions-secrets-or-founder (registration token never in files; REGISTRY_TOKEN self-serve PUT first, founder UI fallback)
|
||||
- job-token-cannot-publish-packages (upstream-confirmed; registry push needs the PAT secret)
|
||||
- cache-host-pinning (dockerized runner cache server must be job-reachable or actions/cache ETIMEDOUTs)
|
||||
- secrets-via-actions-secrets-or-founder
|
||||
- job-token-cannot-publish-packages
|
||||
- cache-host-pinning
|
||||
- fast-lanes-only-in-ci (real_server/userns never in job containers, D-063)
|
||||
territory:
|
||||
- ".gitea/workflows/**"
|
||||
- "Dockerfile"
|
||||
- ".dockerignore"
|
||||
- ".pre-commit-config.yaml"
|
||||
- "deploy/runner/**" # compose + config for CT 113 (admin-guide artifacts, not CT state)
|
||||
- "deploy/runner/**"
|
||||
```
|
||||
|
||||
### cli-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 re-activation — owns the distribution artifacts of its own binary: build-binary.mjs arch parameter (--arch x64|arm64; arm64 node-base download replacing process.execPath copy, postject unchanged — D-069 QEMU-free cross-build), scripts/build-deb.sh + postinst advisory doctor (D-067), install.sh arch-gate→arch-resolve (x86_64/aarch64, sha256+version gates byte-identical), doctor container/deb-channel awareness (real userns probe + NC_CONTAINER//.dockerenv//usr/bin/nextcraft detection — REQ-8-004/D-066), `nextcraft guide` offline-first embedded text (D-072), bootstrap/deploy/dev in-container refusal (D-066/D-071), verify container-mode branch
|
||||
reason: v0.9 DEACTIVATED — no CLI changes; billing is server+web only. Roster + v0.8 territory retained.
|
||||
domain: cli
|
||||
frameworks:
|
||||
- node
|
||||
@@ -82,9 +80,9 @@ territory:
|
||||
|
||||
### sandbox-engineer
|
||||
```yaml
|
||||
active: true
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 re-activation — owns the D-068 degrade detection in its sandbox fabric: promote tests/helpers/userns.py's probe into ai_service/sandbox/capability.py (verbatim semantics), the boot notice in main.py lifespan (the G-11 mount-less-warning pattern), the per-create 503 + remediation arm (SandboxUnavailableError mapping beside PoolFullError in api/sandboxes.py) — the honest container-degrade path for REQ-8-002; real_server/userns test tiers stay OUT of CI lanes per D-063
|
||||
reason: v0.9 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
|
||||
domain: infra
|
||||
frameworks:
|
||||
- python
|
||||
@@ -108,34 +106,33 @@ territory:
|
||||
```yaml
|
||||
active: true
|
||||
phase_specific: true
|
||||
reason: v0.8 re-activated (phase-specific) — P1 release secrets review (registration token flow, REGISTRY_TOKEN/GITEA_TOKEN Actions secrets, the .env-only resolution of release-assets.sh preserved as manual fallback), P2 image review (non-root user, healthcheck without curl, state volume ownership, sibling-mode socket blast-radius row), P3 deb review (postinst never fails dpkg, empty Depends + honest Recommends), P6 final review (channel consistency: every recipe backed by a test or live-box evidence)
|
||||
reason: v0.9 re-activated (phase-specific) — P3 webhook security review: HMAC-SHA256 verify discipline (raw-body-first, compare_digest, all-v1 iteration for secret rolls, ±300s replay window, non-v1 schemes ignored — R-38..R-40), fail-closed webhook secret (unset → per-boot random → 401s, correct for a payment surface), event dedupe integrity, cents money handling (no floats — D-081), 402 CTA payload honesty, stripe provider key redaction in errors (the embedding test precedent); P6 final review (no secrets in billing code/logs/releases)
|
||||
domain: security
|
||||
frameworks:
|
||||
- pytest
|
||||
- httpx
|
||||
constraints:
|
||||
- STRIDE-classified
|
||||
- secrets-never-in-workflow-env (Actions secrets only; never files)
|
||||
- secrets-via-env-only (D-014; AI_PAYMENT_API_KEY/AI_WEBHOOK_SECRET never logged)
|
||||
- secrets-never-logged
|
||||
- registration-token-reset-after-fleet-green (founder directive)
|
||||
- fail-closed-degrades (unprivileged default; privileged variant documented opt-in)
|
||||
- webhooks-fail-closed-when-unconfigured
|
||||
- raw-body-before-parse (signature verification sees exact bytes)
|
||||
territory:
|
||||
- ".gitea/workflows/**"
|
||||
- "Dockerfile"
|
||||
- "scripts/build-deb.sh"
|
||||
- "scripts/release-assets.sh"
|
||||
- "apps/cli/src/commands/deploy.ts"
|
||||
- "apps/ai-service/ai_service/billing/**"
|
||||
- "apps/ai-service/ai_service/api/billing*.py"
|
||||
- "apps/ai-service/tests/billing/**"
|
||||
```
|
||||
|
||||
### backend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — the only backend-adjacent work (D-068 probe wiring + boot notice) is owned by sandbox-engineer whose fabric it extends; config.py/pyproject need zero changes (NC_CONTAINER is a CLI-layer marker; the AI_* env surface is untouched). Roster retained; the v0.7 pytest/mypy territory is stable.
|
||||
reason: v0.9 ACTIVATED — owns the billing module core: ai_service/billing/base.py (PaymentProvider protocol + pydantic contracts + canonical event names), mock.py (deterministic Stripe-shaped provider), stripe.py (form-encoded REST via shared httpx pool, Idempotency-Key per R-49), factory.py (G-11 loud fallback), catalog.py (seeded idempotent), api/billing.py + api/billing_admin.py (require_admin dep verbatim R-51, webhook route raw-body-first, entitlement composition into create_posting AFTER the admin gate), config.py trio (payment_provider/payment_api_key/webhook_secret — R-53), main.py router mounts + lifespan store wiring
|
||||
domain: backend
|
||||
frameworks:
|
||||
- fastapi
|
||||
- pydantic-settings
|
||||
- httpx
|
||||
- bash
|
||||
- turborepo
|
||||
- pnpm
|
||||
@@ -143,22 +140,23 @@ constraints:
|
||||
- secrets-via-env-only (D-014)
|
||||
- idempotent-scripts
|
||||
- fail-closed-admin
|
||||
- never-call-cloud-in-tests (MockTransport byte-contract only, D-075)
|
||||
- provider-boundary-normalization (R-43: store provider ids verbatim)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/billing/**"
|
||||
- "apps/ai-service/ai_service/config.py"
|
||||
- "apps/ai-service/ai_service/main.py"
|
||||
- "apps/ai-service/ai_service/api/**"
|
||||
- "apps/ai-service/scripts/**"
|
||||
- "apps/ai-service/tests/billing/**"
|
||||
- "apps/ai-service/.env.example"
|
||||
- "apps/ai-service/pyproject.toml"
|
||||
- "package.json"
|
||||
- "turbo.json"
|
||||
```
|
||||
|
||||
### frontend-engineer
|
||||
```yaml
|
||||
active: false
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — no apps/web changes; the web app is only a BUILD INPUT to the docker image (NEXT_PUBLIC_AI_SERVICE_URL=self export, unchanged since D-039). ESLint/c8/pins territory stable from v0.7.
|
||||
reason: v0.9 ACTIVATED — owns the pricing-page conversion (server shell retained per A-411/pins + PricingClient component with AbortController + honest loading/error/empty states, the A-406 board-client pattern), engine-client listBillingCatalog() + PaymentRequiredError (402 dict-detail discrimination — parseError precedent), packages/types/billing.ts (dual-schema pinned), admin entitlement view in the moderation surface (per-employer active package/posts used/limit/expiry, client fetch)
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- react
|
||||
@@ -168,16 +166,19 @@ constraints:
|
||||
- component-first
|
||||
- server-components-default
|
||||
- no-build-time-db-reads (D-039 export build stays repo-only)
|
||||
- honest-states (loading/error/empty never mocked)
|
||||
- cents-in-types (number documented as cents, D-081)
|
||||
territory:
|
||||
- "apps/web/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/types/billing.ts"
|
||||
```
|
||||
|
||||
### ai-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — no LLM-facing work; the provider layers ship unchanged inside the docker image (G-11 fallbacks are the only runtime-config behavior).
|
||||
reason: v0.9 DEACTIVATED — no LLM-facing work; all provider layers ship unchanged.
|
||||
domain: ai
|
||||
frameworks:
|
||||
- pydantic
|
||||
@@ -196,9 +197,9 @@ territory:
|
||||
|
||||
### data-engineer
|
||||
```yaml
|
||||
active: false
|
||||
active: true
|
||||
phase_specific: false
|
||||
reason: v0.8 DEACTIVATED — zero schema/store changes; the ~/.nextcraft/data/nextcraft.db path rides into the docker volume via config.py's existing home-dir default (no code change).
|
||||
reason: v0.9 ACTIVATED — owns the 7th D-027 store: billing/store.py with the 7-table schema (packages, plans, purchases with price snapshots — D-078, subscriptions state machine, subscription_events with verbatim event_id UniqueConstraint dedupe, checkout_sessions, billing_events audit trail), InvalidTransition domain error, transition matrix tests, purchase consumption query (newest non-expired with remaining posts), idempotent catalog seeding
|
||||
domain: data
|
||||
frameworks:
|
||||
- sqlmodel
|
||||
@@ -208,19 +209,20 @@ constraints:
|
||||
- schema-first
|
||||
- type-safe
|
||||
- dual-schema-sync
|
||||
- idempotency-by-constraint (UniqueConstraint + check-first, the insert_application pattern)
|
||||
- snapshots-immutable (catalog edits never rewrite purchase history, D-078)
|
||||
territory:
|
||||
- "apps/ai-service/ai_service/marketplace/base.py"
|
||||
- "apps/ai-service/ai_service/marketplace/store.py"
|
||||
- "apps/ai-service/ai_service/marketplace/ingest.py"
|
||||
- "apps/ai-service/ai_service/billing/store.py"
|
||||
- "apps/ai-service/ai_service/billing/catalog.py"
|
||||
- "apps/ai-service/tests/billing/test_store.py"
|
||||
- "packages/types/**"
|
||||
- "packages/mock-data/**"
|
||||
```
|
||||
|
||||
### identity-engineer
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 custom persona — module shipped complete; no v0.8 identity-domain work.
|
||||
reason: v0.5 custom persona — module shipped complete; no v0.9 identity-domain work.
|
||||
domain: identity
|
||||
frameworks:
|
||||
- fastapi
|
||||
@@ -239,7 +241,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: v0.5 territory — voice shipped complete; no v0.8 voice work.
|
||||
reason: v0.5 territory — voice shipped complete; no v0.9 voice work.
|
||||
domain: ai-media
|
||||
frameworks:
|
||||
- httpx
|
||||
@@ -260,7 +262,7 @@ territory:
|
||||
```yaml
|
||||
active: false
|
||||
phase_specific: false
|
||||
reason: No design-token or primitive work in v0.8; roster retained.
|
||||
reason: No design-token or primitive work in v0.9; roster retained.
|
||||
domain: frontend
|
||||
frameworks:
|
||||
- tailwindcss
|
||||
|
||||
+165
-1
@@ -1,4 +1,168 @@
|
||||
# Nextcraft — PLAN.md (v0.8 Ship Anywhere)
|
||||
# Nextcraft — PLAN.md (v0.9 Payment Engine)
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone:** v0.9 — Payment Engine (Feature, D-074): payment processing and subscription management — posting packages (single/bundle/enterprise), talent-access plans, subscription tiers with real checkout, provider webhooks (HMAC-verified, idempotent), posting-creation entitlement enforcement, and a full subscription lifecycle — behind a provider-agnostic `PaymentProvider` protocol, mock-first (D-014 pattern; tests never call Stripe). The pricing page (REQ-017's surface) gains its real backend.
|
||||
**Tag line:** phase tags advance the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release), continuing from the v0.7.6 milestone release
|
||||
**Branch:** milestone/v0.9-payment-engine
|
||||
|
||||
**Phase list (requirement mapping):**
|
||||
|
||||
| Phase | Requirement | Focus |
|
||||
|-------|-------------|-------|
|
||||
| P1 | REQ-9-001 | Billing foundation: billing/ module (protocol + mock + store + HMAC + factory + settings), no routes yet |
|
||||
| P2 | REQ-9-002 | Catalog + pricing surface: seeded catalog, /v1/billing/catalog, pricing-page client conversion, types + pins |
|
||||
| P3 | REQ-9-003 | Checkout + webhook flow: admin checkout route, stripe provider, public webhook (raw-body HMAC, dedupe, idempotent processing), mock-complete E2E path |
|
||||
| P4 | REQ-9-004 | Entitlement enforcement: consume_posting in create_posting, 402 CTA payload, admin entitlement view |
|
||||
| P5 | REQ-9-005 | Subscription lifecycle: state machine via webhooks, manual grant/revoke, billing event log, past_due grace |
|
||||
| P6 | — | Final review + audit + milestone ship (v0.8.6 = milestone release) |
|
||||
|
||||
**Wave order (binding): foundation before surface before flow before enforcement before lifecycle.** P1 builds the module everything else composes (store + protocol + mock — P2's catalog route reads the store, P3's checkout writes it). P2 precedes P3 because checkout references catalog items by id (the catalog must exist + be surfaced before sessions can be created against it). P3 precedes P4 because enforcement consumes entitlements that only the webhook flow grants. P5 needs the full event set the flow produces. Each phase is a vertical slice: store rows → routes → web surface → tests green → ship.
|
||||
|
||||
**Binding contracts:** clarify decisions D-075..D-084 (PROJECT.md) and research R-33..R-54 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates.
|
||||
|
||||
**Environment facts (probe-verified, R-51..R-54):** require_admin dep pattern at api/marketplace_admin.py:46-85; D-027 store family replication (marketplace/store.py the template); Settings env trio precedent (config.py: provider selector + SecretStr key + per-boot-random secret); HMAC precedent telemetry/tokens.py; at-least-once dedupe precedent insert_application; pricing page inline constants at page.tsx:41-111 + metadata pinned by pins.test.ts:189-190 + sitemap STATIC_PAGES; engine-client jsonFetch/parseError at lines 90-140; main.py inline router mounts + lifespan state-injection + G-11 fallback pattern.
|
||||
|
||||
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to billing + the pricing/admin surfaces.
|
||||
|
||||
**Secrets policy (binding):** AI_PAYMENT_API_KEY / AI_WEBHOOK_SECRET env-only (D-014), SecretStr in settings, never logged, never in .env.example with real values; tests use fixed test secrets; the stripe provider redacts keys from exceptions (embedding test precedent).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Billing Foundation (REQ-9-001)
|
||||
|
||||
**Goal:** The billing module exists and is proven: protocol + mock provider + 7-table store + HMAC webhook verification + factory + settings — all tested, zero routes mounted, zero web changes.
|
||||
|
||||
### Wave 1-1: Module core (backend-engineer + data-engineer, sequential)
|
||||
|
||||
- **Task 1-1-01** (backend-engineer): `ai_service/billing/__init__.py` + `base.py` — pydantic contracts: `Package` (id, name, price_cents, posts_limit, duration_days, features), `Plan` (id, name, price_cents, interval, features), `CheckoutSessionSpec` (mode, line item ref, success/cancel refs, client_reference_id, metadata), `CheckoutSession` (id, url, status, payment_status, mode, expires_at), `SubscriptionRef`, `CanonicalBillingEvent` (name enum: checkout.completed, invoice.paid, invoice.payment_failed, subscription.cancelled, subscription.renewed + payload dict). Money = int cents ge=0 (D-081). `PaymentProvider` Protocol: create_checkout_session, retrieve_session, cancel_subscription, update_subscription, verify_webhook, normalize_event.
|
||||
- **Task 1-1-02** (data-engineer): `billing/store.py` — 7th D-027 store (R-52): 7 tables per D-080; UniqueConstraints (subscription_events.event_id, checkout_sessions.provider_session_id, **purchases.checkout_session_id — G-303: grants idempotent per session, not per event id**); purchases carry price/name snapshot columns; `_validate`/`_normalize`/`_as_utc`/`_sqlite_connect` (WAL/NORMAL/busy_timeout/foreign_keys); Protocol + SQLiteBillingStore pair; `InvalidTransition(ValueError)`; store methods: seed_catalog (idempotent upsert), list_catalog, get_purchase/consume_posting(employer_id) (**G-304: single conditional UPDATE `SET posts_used = posts_used + 1 WHERE id = <newest non-expired with remaining> AND posts_used < posts_limit`, rowcount-checked — never check-then-increment; 0 rows → PackageExhausted/NoActivePackage**), subscription get/upsert/transition (state machine per D-083; **upsert keyed on provider subscription id — G-303**), insert_provider_event (check-first dedupe → created flag), insert_billing_event (audit, manual provenance, **unmatched marker per G-302**), close. Tests: `tests/billing/test_store.py` — tmp_path db, table-existence via sqlite_master, pragma pins, IntegrityError surfacing, dedupe idempotency double-insert (**G-303: same session, different event ids → one purchase**), transition matrix (valid + InvalidTransition; **G-305: no `upgraded` status value**), consume_posting exhaustion + expiry + multi-purchase ordering (**G-304: concurrency test — 1 remaining, two connections → exactly one succeeds**).
|
||||
- **Task 1-1-03** (backend-engineer): `billing/mock.py` — deterministic mock provider (D-075): Stripe-shaped ids (`cs_mock_...` counter+randbits — the identity-mock unique-mint lesson), create/retrieve session (unpaid → paid on confirm), signature mint/verify identical scheme to real provider (whsec-shaped), normalize_event passthrough (mock emits canonical names directly). Tests: `tests/billing/test_mock.py`.
|
||||
- **Task 1-1-04** (backend-engineer): `billing/webhooks.py` — verification core shared by providers: parse `Stripe-Signature` (t=,v1= pairs, multi-v1, ignore v0/unknown — R-38), HMAC-SHA256 over `{t}.{raw_body}` bytes, hmac.compare_digest, ±300s window with `now` injection (R-40, risk-4). Tests: `tests/billing/test_webhooks.py` — good/bad signature, stale timestamp (outside window), missing header, multi-v1 (secret roll), v0-scheme rejected, replay-same-event accepted (idempotent at the verify layer).
|
||||
- **Task 1-1-05** (backend-engineer): `billing/factory.py` + config.py trio (R-53): `payment_provider="mock"`, `payment_api_key: SecretStr`, `webhook_secret=""` (per-boot random); UnknownPaymentProviderError; .env.example additions (commented, no real values). Tests: factory selection + unknown error + secret redaction (repr must not leak).
|
||||
|
||||
### Wave 1-2: Foundation must-haves
|
||||
|
||||
- P1 runs the full suite (fast lane) green: new tests + existing 670 unaffected (billing module is additive; main.py untouched this phase).
|
||||
- mypy + ruff green (billing files join [tool.mypy] files set implicitly — same package).
|
||||
- **Must-have:** mock provider + store + webhooks.py are usable WITHOUT any route — proven by direct-instantiation tests only.
|
||||
|
||||
## Phase 2: Catalog + Pricing Surface (REQ-9-002)
|
||||
|
||||
**Goal:** The catalog is real and surfaced: seeded packages/plans in the DB, `/v1/billing/catalog` public GET, the pricing page reads it at runtime, wire types pinned both sides.
|
||||
|
||||
### Wave 2-1: Backend catalog route (backend-engineer)
|
||||
|
||||
- **Task 2-1-01** (backend-engineer): `billing/catalog.py` — the seed constant (single $49/1 post/30d, bundle $399/10 posts/90d, enterprise custom; starter $99/mo, pro $299/mo, enterprise-talent custom — REQ-017's tier structure) + idempotent seed into store. Mounted via `api/billing.py`: `GET /v1/billing/catalog` → `{packages: [...], plans: [...]}` (public — catalog is advertising, not secret; CORS-existing policy applies). Deps: `get_billing_store` (R-51/R-52 wiring: deps.py + main.py lifespan + close in finally). Tests: `tests/billing/test_api.py` — catalog returns the 6 seeded items, shape pinned, unauthenticated 200.
|
||||
|
||||
### Wave 2-2: Web pricing conversion (frontend-engineer)
|
||||
|
||||
- **Task 2-2-01** (frontend-engineer): `packages/types/billing.ts` — Package/Plan/CatalogResponse types (cents documented, D-081); dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline: field-for-field vs base.py).
|
||||
- **Task 2-2-02** (frontend-engineer): `engine-client.ts` — `listBillingCatalog()` via jsonFetch; page stays server shell (metadata + pins untouched — A-411); new `components/marketplace/pricing-client.tsx` ('use client', AbortController, loading/error/empty states — board-client pattern); CTA copy per D-079 (contact path + operator-mediated note; no fake purchase buttons). Tests: fetch-stub catalog render, error state render, empty catalog render, cents formatting.
|
||||
- **Task 2-2-03** (frontend-engineer): pins updated: pricing-page metadata pin retained; new catalog wire pins added.
|
||||
|
||||
## Phase 3: Checkout + Webhook Flow (REQ-9-003)
|
||||
|
||||
**Goal:** The full payment flow: admin creates a checkout session (mock by default, stripe via env), the provider completes it, the webhook (HMAC-verified, deduped, idempotent) grants the entitlement — proven E2E in tests with zero cloud calls.
|
||||
|
||||
### Wave 3-1: Stripe provider (backend-engineer)
|
||||
|
||||
- **Task 3-1-01** (backend-engineer): `billing/stripe.py` — form-encoded REST via the shared httpx pool: create session (mode, line_items[0][price_data] from catalog item snapshot, client_reference_id, Idempotency-Key uuid4 — R-33/R-35/R-49), retrieve session, cancel_subscription (DELETE or cancel_at_period_end — R-46), update_subscription (items[0][id] resolution inside provider — R-47 dual-charge guard), verify_webhook (shared webhooks.py), normalize_event (R-43 mapping). Tests: `tests/billing/test_stripe_provider.py` — MockTransport byte-contract: exact form-encoded body, Basic auth header, Idempotency-Key present, item-id in update body, key redaction in exceptions, normalize_event mapping table (Stripe → canonical).
|
||||
- **Task 3-1-02** (backend-engineer): main.py factory wiring: provider from settings (mock default), G-11 loud fallback on factory error (voice precedent), provider on app.state + deps.
|
||||
|
||||
### Wave 3-2: Checkout + webhook routes (backend-engineer)
|
||||
|
||||
- **Task 3-2-01** (backend-engineer): `api/billing_admin.py` — `POST /v1/billing/admin/checkout` (require_admin verbatim — R-51): body {item_id, employer_id} → catalog item lookup → create session (operator_ref + employer_id recorded, D-076; **success_url/cancel_url default from public_base_url per G-310: success = {public_base_url}/marketplace/pricing?checkout=success, cancel = …?checkout=cancelled; loud G-11 boot warning when stripe + public_base_url unset**) → persist checkout_sessions row → return {session_id, url}. Tests: fail-closed 403 (no/missing token), 404 unknown item, 201 with persisted row, **byte-contract: success_url + cancel_url present in the form body**.
|
||||
- **Task 3-2-02** (backend-engineer): `api/billing.py` webhook — `POST /v1/billing/webhook`: `await request.body()` FIRST (risk-1 raw-body discipline), verify HMAC (401 on bad/missing signature), parse event, insert_provider_event (dedupe: existing → 200 already-processed), process by canonical name — **G-302: event payloads are untrusted ID references — the session/subscription resolves from LOCAL rows (creatable only via the admin route); unknown target id → recorded as an unmatched billing_event, NO entitlement, 200; purchase price/name from the stored snapshot, never the event body** — checkout.completed → verify predicate status=complete AND payment_status=paid (R-37) against the local session row → create purchase/subscription from session snapshot; invoice.paid → extend/renew; invoice.payment_failed → past_due; subscription.cancelled → cancelled. All transitions via store (InvalidTransition tolerated-as-duplicate, recorded; **G-306: unknown-target events never 500 — recorded unmatched, 200**). 200 fast. Tests: `tests/billing/test_webhook_route.py` — unsigned 401, bad sig 401, valid + duplicate (idempotent 200, single entitlement), out-of-order tolerated, grace window pinned, mock flow E2E, **G-302: signed event for unknown session → 200 + zero purchases + one unmatched row; inflated payload amount → price = snapshot**, **G-303: distinct event ids same session → one purchase**.
|
||||
- **Task 3-2-03** (backend-engineer): mock-complete path (D-082, **amended by G-301**): `POST /v1/billing/mock/complete` mounted ONLY under provider=mock **AND composed with require_admin — the operator completing a session after out-of-band payment is the honest pilot flow (G-301: mock is the default provider, so this route exists on every default-config deployment; an unauthenticated completion route on a payment surface is a hole regardless of id unguessability)** — mints a signed event through the REAL webhook processing (direct handler call, same code path) — E2E proof: admin checkout → admin-gated mock complete → webhook → entitlement exists. Route 404s under stripe (factory-conditional mount, test-pinned). Tests: **no token → 403 under mock (G-301)**; 404 under stripe; E2E green.
|
||||
|
||||
## Phase 4: Entitlement Enforcement (REQ-9-004)
|
||||
|
||||
**Goal:** Posting creation enforces packages honestly; the admin surface shows entitlement state.
|
||||
|
||||
### Wave 4-1: Enforcement composition (backend-engineer)
|
||||
|
||||
- **Task 4-1-01** (backend-engineer): `api/marketplace_admin.py` create_posting composes billing AFTER require_admin: consume_posting(employer_id) → success: proceed (posts_used incremented); `PackageExhausted`/`NoActivePackage` (billing domain errors) → `HTTPException(402, detail={reason, posts_used, posts_limit, upgrade_cta: {catalog_hint}})` (dict-detail precedent — require_verified_age). Backward-compat: employers with UNLIMITED manual grants bypass (enterprise — D-080). **G-309: the enforcement exists ONLY in the admin route — run_ingest and any future adapter bypass consume_posting by design (seed ingestion is never entitlement-gated)**. Tests: 402 on no package, 402 on exhausted, 201 + consumption on valid, unlimited bypass, expired package degrades to 402, **seeded ingest on a zero-purchase store → all postings upserted, zero 402s (G-309)**.
|
||||
- **Task 4-1-02** (backend-engineer): `GET /v1/billing/admin/entitlements/{employer_id}` — active package, posts used/limit, expiry, subscription state (require_admin). Tests: shape + fail-closed.
|
||||
|
||||
### Wave 4-2: Admin surface (frontend-engineer)
|
||||
|
||||
- **Task 4-2-01** (frontend-engineer): admin moderation surface gains per-employer entitlement panel (**G-308 design, replacing the A-406 citation — the entitlements route is admin-gated, A-406 is the public-route pattern**): a small operator-token input persisted to sessionStorage + a new `adminFetch` wrapper in engine-client (X-Admin-Token header, token never logged); 403 renders an honest locked state with the token prompt; panel shows active package, posts used/limit bar, expiry, subscription state badge, manual-grant pointer. Tests: fetch-stub render, 402-CTA payload rendering, **stubbed 403 → locked-state render with prompt (G-308)**, **fetch stub asserts the X-Admin-Token header**, no admin-token string in non-test web source beyond the wrapper.
|
||||
|
||||
## Phase 5: Subscription Lifecycle (REQ-9-005)
|
||||
|
||||
**Goal:** Full lifecycle: state machine driven by webhooks + manual admin actions, audit log complete, past_due grace honest.
|
||||
|
||||
### Wave 5-1: Lifecycle + admin routes (backend-engineer + data-engineer)
|
||||
|
||||
- **Task 5-1-01** (data-engineer): store transition matrix complete: trialing→active, active→past_due, past_due→active (recovery), past_due→expired (**G-306: lazily computed from past_due_at + 7-day constant at read/entitlement time — no background timer; test-pinned D-083**), active→cancelled, cancelled terminal; **G-305: no `upgraded` status — upgrade is a plan_id transition within `active`, recorded as a billing_event with provenance**; expires_at extension idempotent on invoice.paid; manual grant/revoke/complement methods (source="manual" + operator provenance in billing_events). Tests: full matrix + grace-elapsed expiry (**injected now: past_due_at+6d → granted, +8d → expired — no sleeps**) + manual-action audit rows + **upgrade keeps status=active, changes plan_id, writes billing_event (G-305)**.
|
||||
- **Task 5-1-02** (backend-engineer): `api/billing_admin.py` lifecycle routes: POST admin/subscriptions/{id}/cancel, /resume, /upgrade (provider calls for stripe; state transitions for mock), POST admin/employers/{id}/grant (manual unlimited/package), /revoke, GET /v1/billing/admin/events (queryable audit log: by employer, by type, by time). Tests: each route happy path + fail-closed 403 + InvalidTransition 409.
|
||||
- **Task 5-1-03** (backend-engineer): webhook subscription.renewed handling: renewal invoice.paid extends expires_at idempotently (event dedupe makes double-fire safe — R-45); corroborating customer.subscription.updated recorded. Tests: double-renew single-extension.
|
||||
|
||||
## Phase 6: Final Review + Ship (milestone release, v0.8.x line)
|
||||
|
||||
**Goal:** Multi-persona review of all v0.9 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.8.6 (final v0.8.x patch = milestone release), full asset matrix + registry image via the release lane (D-064), milestone completion commits, branch cleanup.
|
||||
|
||||
- Review: P0 fixes in-phase; P1+ fixed here, never loop back to EXECUTE.
|
||||
- Audit: git log ↔ .ciagent/ reconstruction; file discipline; branch hygiene; commit discipline (feat: scoped to billing + pricing/admin surfaces).
|
||||
- Ship: phase/(N+1) branch → milestone → main; delete all milestone branches (tags preserve history).
|
||||
- Complete: REQUIREMENTS.md (REQ-9-001..005 complete), ROADMAP.md milestone entry, checkpoint cleared.
|
||||
|
||||
---
|
||||
|
||||
## Must-Haves (per phase — concrete, falsifiable, test-linked)
|
||||
|
||||
- **P1 (REQ-9-001):** billing module importable + tested standalone; store tests green (7 tables, dedupe, transitions); webhooks.py verify matrix green; factory + settings trio live; fast suite green; NO routes mounted, NO web changes. **MH-1a** sqlite_master shows 7 billing tables. **MH-1b** duplicate insert_provider_event returns created=False. **MH-1c** bad-signature verify raises. **MH-1d** factory: mock default, unknown raises, key redacted.
|
||||
- **P2 (REQ-9-002):** catalog route serves 6 seeded items; pricing page renders from runtime fetch with honest states; dual-schema pins green; metadata pin retained. **MH-2a** GET /v1/billing/catalog unauth 200 with exactly the seeded items. **MH-2b** pins.test.ts billing section green. **MH-2c** pricing page: no inline JOB_PLANS/TALENT_PLANS constants remain (grep-clean).
|
||||
- **P3 (REQ-9-003):** stripe provider byte-contract green under MockTransport; webhook 401/200/idempotent matrix green; mock E2E green (checkout → complete → entitlement). **MH-3a** unsigned webhook → 401, never processed. **MH-3b** duplicate event → 200, single entitlement; **distinct event ids, same session → one purchase (G-303)**. **MH-3c** E2E: purchase exists after admin-gated mock complete with predicate honored; **no-token complete → 403 (G-301)**. **MH-3d** update body contains items[0][id]. **MH-3e** no test touches the network (MockTransport-only, grep-pin). **MH-3f** signed event for unknown session → 200, zero entitlement, unmatched row (G-302).
|
||||
- **P4 (REQ-9-004):** create_posting enforces; 402 payload shape pinned; admin entitlement view renders. **MH-4a** no-package posting → 402 with upgrade_cta. **MH-4b** exhausted → 402; valid → 201 + posts_used incremented. **MH-4c** unlimited bypass. **MH-4d** expired degrades to 402.
|
||||
- **P5 (REQ-9-005):** transition matrix complete + grace pinned; manual actions audited; renewal idempotent; event log queryable. **MH-5a** grace-elapsed past_due → expired (7-day constant pinned, lazy evaluation per G-306 — no sleeps); **no `upgraded` status value exists (G-305)**. **MH-5b** double-renew single extension. **MH-5c** manual grant → billing_events row with source=manual. **MH-5d** GET admin/events filters by employer.
|
||||
|
||||
## Milestone Must-Haves
|
||||
|
||||
- REQ-9-001..005 all complete; the mock payment loop is the machine-checked E2E proof (checkout → webhook → entitlement → consumption → renewal/cancel).
|
||||
- The stripe provider is byte-contract-proven without ever leaving MockTransport.
|
||||
- Zero secrets in code/tests/examples; AI_WEBHOOK_SECRET unset → webhooks fail closed.
|
||||
- Suite counts: pytest + web + cli all green, zero warnings (v0.7 regime holds).
|
||||
- Docs: .env.example billing section; deploy/README.md unaffected (billing needs no admin-box changes — record this explicitly); REQUIREMENTS/ROADMAP updated.
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Raw-body discipline** (risk-1): any body pre-parse breaks HMAC — pinned by MH-3a + route-order test.
|
||||
2. **Dual-charge** (R-47): pinned by MH-3d.
|
||||
3. **Out-of-order events** (R-45): transitions tolerate duplicates; InvalidTransition recorded not raised for already-reflected state — pinned in webhook tests.
|
||||
4. **Test clock**: now-injection in verify + store (no sleeps — deterministic).
|
||||
5. **Backward compat at P4**: existing marketplace admin tests create postings for seeded employers — seed catalog + grant manual unlimited to test employers OR mark route change explicit in fixtures (decided: tests grant unlimited to legacy seeded employers — honest, explicit).
|
||||
6. **Pins churn**: pins.test.ts grows — keep the G-108 discipline (regression/boundary pins only, no local-grep copy pins).
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
1. **Pricing page** — real catalog data at runtime (packages + plans with cents-true prices), honest loading/error states, contact-path CTAs (operator-mediated checkout note).
|
||||
2. **Admin billing surface** — per-employer entitlement panel (package, posts used/limit, expiry, subscription badge) in the moderation view; operator checkout trigger.
|
||||
3. **Public API** — GET /v1/billing/catalog (advertises the catalog); POST /v1/billing/webhook (provider-facing).
|
||||
4. **Operator API** — /v1/billing/admin/* (checkout, entitlements, lifecycle, grants, event log) behind the admin token.
|
||||
5. **Release notes** — v0.8.x line ships the billing surface through the existing automated lane (no admin-box changes required).
|
||||
|
||||
## Happy Path (end-to-end, written BEFORE execute)
|
||||
|
||||
1. **Operator buys for an employer (mock pilot):** admin token → POST /v1/billing/admin/checkout {item: bundle, employer: E1} → session created → mock complete → webhook (signed, deduped) → purchase row: 10 posts, $3.99 snapshot → E1's admin panel shows 10/10 posts available.
|
||||
2. **Employer posts jobs:** create_posting E1 → 201, posts_used 1..N; at 10/10 the 11th → 402 with upgrade_cta → operator buys enterprise grant → unlimited.
|
||||
3. **Subscription month rolls:** pro plan renews → invoice.paid webhook → expires_at extended exactly once (double-delivery safe) → talent-access stays active.
|
||||
4. **Payment fails:** card declines → invoice.payment_failed → past_due → 7-day grace → still unpaid → expired (honest downgrade) — OR recovery payment inside grace → active again.
|
||||
5. **Stripe switch (env-only):** AI_PAYMENT_PROVIDER=stripe + keys → same routes, real hosted checkout, real webhooks — zero code changes.
|
||||
6. **Visitor checks pricing:** /marketplace/pricing loads → catalog fetched → prices + tiers match the DB exactly.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
1. No dishonest buttons: every CTA states the real pilot path (operator-mediated); no fake "buy now" without a real flow behind it.
|
||||
2. Prices are always cents-true and consistent: pricing page = catalog API = purchase snapshots; no drift.
|
||||
3. Failures are loud and actionable: 402 payloads carry upgrade_cta; webhook failures fail closed (401) rather than processing unsigned; past_due is visible in the admin panel before downgrade.
|
||||
4. The admin never needs to guess: entitlement panel answers "can this employer post?" in one glance; the event log answers "what happened to this subscription?" with full provenance.
|
||||
5. Existing surfaces unchanged: job board, defense, sandboxes, deploy — billing is additive; any regression there is a P0.
|
||||
|
||||
---
|
||||
|
||||
# v0.8 PLAN (Ship Anywhere — prior milestone, retained below)
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
+25
-1
@@ -16,7 +16,30 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
|
||||
|
||||
---
|
||||
|
||||
## Milestone v0.8 — Ship Anywhere (ACTIVE, Feature, started 2026-09-27)
|
||||
## Milestone v0.9 — Payment Engine (ACTIVE, Feature, started 2026-09-29)
|
||||
|
||||
**Scope (D-074, auto-decided at SPECIFY under full autonomy D-007; founder scope question left open post-v0.8):** Activates **REQ-F-018 — payment processing and subscription management** as REQ-9-001..005. The pricing page (REQ-017, critical since v0.1) gains its real backend: posting packages (single, bundle, enterprise), talent-access plans for employers, and subscription tiers — with checkout, provider webhooks, entitlement enforcement on posting creation, and a subscription lifecycle (renew/cancel/upgrade/downgrade) under a provider-agnostic protocol (D-014 mock-first pattern). Rationale: the guiding outcome (≥1,000 placements via the marketplace) requires a monetized, self-serve employer side; the marketplace engine (v0.6) built the supply side, ship-anywhere (v0.8) made the stack installable anywhere — payments is the only high-priority requirement deferred since v0.3 that directly unblocks employer self-serve (A-407: "real employer auth lands post-payments with the B2B employer network") and CIRR-style placement tracking needs paid postings to track. Confidence 0.80 ≥ threshold 0.60 (D-007).
|
||||
|
||||
**Out of scope:** real employer auth/accounts (B2B employer network — deferred, lands post-payments per A-407), multi-currency, tax/VAT handling beyond passthrough, invoice PDF generation, Stripe-specific UI components, CIRR tracking (REQ-F-020, deferred).
|
||||
|
||||
**Success (milestone-level):** an employer (via admin-mediated flow at pilot) can purchase a posting package or talent-access subscription through a real Stripe-compatible checkout; webhooks update entitlements automatically; posting creation enforces package limits honestly; subscription lifecycle transitions (renew/cancel/upgrade) work end-to-end; the pricing page reflects real catalog data; all provider integrations are mock-first (tests never call Stripe).
|
||||
|
||||
## Clarify decisions (D-075+, CLARIFY stage full autonomy, 2026-09-29)
|
||||
|
||||
| ID | Decision | Rationale | Confidence |
|
||||
|----|----------|-----------|------------|
|
||||
| D-075 | Both providers ship in-milestone: `mock` (deterministic, default) and `stripe` (real Stripe-compatible REST via the shared httpx pool — checkout session create/retrieve, subscription cancel/upgrade, event signature verify). Tests exercise the stripe provider ONLY through `httpx.MockTransport` (the openai-audio/embedding precedent — byte-contract pinned, cloud never called); `AI_PAYMENT_PROVIDER=stripe` is env-gated and needs `AI_PAYMENT_API_KEY` — unset key falls back to mock with a loud G-11 boot log (voice/embedding factory pattern, `billing/factory.py` + main.py lifespan catch). | The D-030 openai-audio precedent: a real provider shipped alongside mock with MockTransport byte-contract tests. A protocol without a real implementation is unproven; a real implementation without mock is untestable. Pilot runs mock; the real path is config-away. | 0.80 |
|
||||
| D-076 | Buyer model at pilot = **operator-mediated** (extends A-401): checkout sessions are created via admin-token-gated routes (`/v1/billing/admin/checkout`) on behalf of an employer ref in the request body; no employer principals exist (A-407). Every purchase records `operator_ref` (admin token identity = "operator") + `employer_id`. Webhook route is public (server-to-server) but HMAC-verified (D-077). | Employers are data, not principals (A-407); the admin token dep (`require_admin` returning Settings) is the only operator identity mechanism in the codebase. Self-serve checkout requires employer auth — explicitly post-payments scope. | 0.85 |
|
||||
| D-077 | Webhook signature = **HMAC-SHA256 over `"{timestamp}.{raw_body}"`** (Stripe's own scheme shape), verified with `hmac.compare_digest`; secret from `AI_WEBHOOK_SECRET` env, **unset → per-boot random** (the `telemetry_ingest_secret` pattern) — meaning webhooks fail closed when unconfigured, which is correct for a payment surface. Missing/bad signature → 401, event_id dedupe via UniqueConstraint + check-first (the `insert_application` at-least-once pattern). Raw body read via `await request.body()` (the resume route is the nearest raw-request precedent). | The v0.4.7 ingest-token pattern (per-boot secret, compare_digest, length-prefixed binding) is the proven in-tree HMAC precedent; Stripe's timestamp.body scheme is the industry shape and tolerates clock skew via a replay window (±5 min, pinned by test). | 0.80 |
|
||||
| D-078 | Catalog = **seeded DB tables, not a code constant**: `packages` (one-time: single $49/1 post/30d, bundle $399/10 posts/90d each, enterprise) + `plans` (recurring: starter $99/mo, pro $299/mo, enterprise-talent custom) seeded idempotently at store init from a versioned code constant (the SeededCorpusSource `seed_corpus.json` pattern). Prices = integer cents, currency `usd` fixed. Purchases **snapshot** price/name at buy time (catalog edits never rewrite history). | The pricing page and admin checkout must read ONE source (data-driven tier copy, REQ-9-002); DB tables allow future admin price edits without code changes while snapshots keep financial history immutable. Code-constant-only would make REQ-9-002's "no hardcoded tier copy drifting from backend" harder to prove. | 0.80 |
|
||||
| D-079 | Pricing page = **server shell retained** (static-export metadata stays — A-411) + new client component fetching `/v1/billing/catalog` (the A-406 board-client pattern: AbortController, honest loading/error/empty states). CTA buttons at pilot = **contact path** (mailto + "operator-mediated checkout" copy) — NOT self-serve purchase (no employer auth, D-076). The real checkout trigger lives in the admin surface (operator buys on behalf of an employer). | Self-serve CTAs without employer auth would be a dishonest button; the admin-mediated purchase is the honest pilot flow. The pricing page's job in v0.9 is truth: real catalog data, real states. | 0.80 |
|
||||
| D-080 | Entitlement model: `purchases` table (one-time packages: `posts_limit`/`posts_used`/`expires_at` snapshot) + `subscriptions` (recurring plans). Posting creation consumes 1 post from the **newest non-expired purchase with remaining posts**, else 402 with upgrade-CTA dict payload (the `require_verified_age` dict-detail precedent); enterprise = manual unlimited grant (admin route, provenance-recorded). Store tables (7th D-027 family): `packages`, `plans`, `purchases`, `subscriptions`, `subscription_events`, `checkout_sessions`, `billing_events` — `purchases` + `billing_events` added to the REQ-9-001 list (one-time packages + full audit trail need their own tables; a one-time purchase is NOT a degenerate subscription). | Mixing one-time packs into subscriptions overloads the state machine (REQ-9-005's states don't apply to a 10-post bundle); the identity/store.py + marketplace/store.py replication pattern makes an 7-table store cheap. | 0.75 |
|
||||
| D-081 | Money = **integer cents** end-to-end (pydantic `int` with ge=0, TS `number` documented as cents); no floats anywhere in billing; currency `usd` only (multi-currency out of scope). | Float money is the classic billing bug; Stripe itself is cents-native. The dual-schema pins (pins.test.ts) enforce the int wire type both sides. | 0.90 |
|
||||
| D-082 | Mock checkout completion = **a mock-provider-only confirm route** (`POST /v1/billing/mock/complete`, mounted ONLY when provider=mock): it mints a properly-signed webhook event and POSTs it through the REAL webhook processing path (fetch to self or direct handler call — same code path, proven by test). This is the E2E payment proof without cloud. The route 404s when provider=stripe. | The pipeline must be provable end-to-end in tests (webhook → dedupe → entitlement); a confirm route mounted only in mock mode is the same shape as the sitemap's env-gated routes and can never be hit on a real deployment (factory mounts it conditionally). | 0.70 |
|
||||
| D-083 | Subscription lifecycle: states `trialing → active → past_due → cancelled|expired` (+ `upgraded`), transitions driven ONLY by webhook events or admin manual actions. **Event names are OUR canonical names, normalized at the provider boundary** (R-43: Stripe has no `subscription.renewed`/`checkout.completed`): `checkout.completed` ← `checkout.session.completed`; `invoice.paid` ← `invoice.paid` (also accept `invoice.payment_succeeded`); `subscription.cancelled` ← `customer.subscription.deleted`; `subscription.renewed` ← `invoice.paid` on a renewal invoice (corroborated by `customer.subscription.updated`); provider event ids stored + deduped verbatim (never normalized). Completion predicate `status=complete AND payment_status=paid` (R-37). `past_due` grace = 7 days (module constant, test-pinned) before expiry downgrade; renewals extend `expires_at` idempotently (event_id dedupe makes double-fire safe); manual grant/revoke records `source: "manual"` + operator provenance in `billing_events`. Invalid transitions → `InvalidTransition` → 409 (store precedent). | At-least-once webhooks + idempotent state transitions is the A-309 seq-lease lesson applied to billing; manual remediation must be audit-visible (REQ-9-005's "recorded as manual events with provenance"). Research R-41..R-45 verified the state/event mapping against Stripe's actual enums. | 0.80 |
|
||||
| D-084 | Wire discipline: billing types in `packages/types/billing.ts` + pydantic contracts in `ai_service/billing/base.py`, dual-schema pinned field-for-field in `apps/web/tests/pins.test.ts` (G-108 discipline); `engine-client.ts` gains `listBillingCatalog()` + a `PaymentRequiredError` (402, dict-detail discrimination — the existing `parseError` VerifyRequiredError precedent). | Every prior cross-boundary wire (PostingSummary, SearchResult, facets) is pinned this way; the 402 CTA payload must be machine-readable for the admin surface. | 0.85 |
|
||||
|
||||
## Milestone v0.8 — Ship Anywhere (COMPLETE, Feature, 2026-09-27 → 2026-09-29)
|
||||
|
||||
**Scope (founder directive 2026-09-26, options 1–4 confirmed + scope decisions answered 2026-09-26/27):** Release and deployment guides for admins. nextcraft must be installable on any Debian-based Linux server, via every mainstream channel, with release consistency enforced by automation:
|
||||
|
||||
@@ -298,6 +321,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
|
||||
| D-014 | Provider-agnostic LLM layer; ollama-cloud as initial provider | OpenAI-compatible client abstraction with pluggable providers: ollama-cloud (https://ollama.com/v1, default), local OpenAI-compatible endpoint, deterministic mock (tests/CI). Keys in gitignored .ciagent/.env.secrets, never in code or commits. | apps/ai-service llm package with 3 providers; default=ollama-cloud |
|
||||
| D-015 | All six agents implemented as real LLM services; engines mocked | Coach/Tutor/Mentor fully real. Lab/Assessor/Proctor are real LLM logic over mock inputs (simulated telemetry, pre-baked artifacts) since sandbox fabric, assessment engine, and identity verification are v0.3+. Consistent with v0.1's mock-data approach. | REQ-F-001..006 complete in v0.2; real engines deferred to v0.3+ |
|
||||
| D-016 | v0.4 = Distribution & Bootstrap CLI (founder directive supersedes previously-named v0.4 seams) | Founder directive 2026-09-12: focus this milestone on streamlining install, a bootstrap CLI with a one-liner install script, ongoing release binaries. Real server STT/TTS, KYC, design/simulation envs, seq-lease move to v0.5. | Milestone scope locked at SPECIFY; binary = CLI-only, linux x64 |
|
||||
| D-074 | v0.9 = Payment Engine (auto-decided at SPECIFY, full autonomy; founder scope question unanswered post-v0.8) | Activates REQ-F-018 as REQ-9-001..005: posting packages (single/bundle/enterprise), talent-access plans, subscription tiers — checkout, provider webhooks, entitlement enforcement on posting creation, subscription lifecycle. Provider-agnostic protocol, mock-first (D-014 pattern). Rationale: ≥1,000 placements requires a monetized self-serve employer side; payments is the only high-priority REQ deferred since v0.3 that unblocks employer self-serve (A-407) and paid postings precede CIRR tracking. Confidence 0.80 ≥ threshold 0.60 (D-007). | Milestone scope locked at SPECIFY; tags on v0.8.x line; feature milestone; branch milestone/v0.9-payment-engine |
|
||||
| D-047 | v0.6 = Marketplace Engine (auto-decided at SPECIFY, full autonomy; founder scope question left unanswered 2026-09-21) | Activates REQ-F-011..016 as REQ-6-001..006: real marketplace backend (aggregation, tagging, vector search, enrichment, resume parsing, SEO pages). Rationale: the guiding outcome (≥1,000 placements via the marketplace) runs through the only surface with zero backend; every prior milestone fed the credential side, placement side is the bottleneck. Confidence 0.80 ≥ threshold 0.60 (D-007). External 3M+ job corpus scaled to pilot (adapter contract preserves the full-scale path). | Milestone scope locked at SPECIFY; tags on v0.5.x line; feature milestone |
|
||||
| D-039 | Single-port deploy + unattended dev (v0.3.6 hotfix, founder directive) | Only :8420 reachable behind HAProxy; site was down (domain root hit the API 404). Web app = static export served same-origin by the ai-service (`AI_WEB_STATIC_DIR`); `dev -d`/`stop`/`log` daemonize ops; durable state (DB, sandboxes, pid/log) moves to `~/.nextcraft/` out of the repo. | One port serves UI+API; unattended deploy recipe in deploy/README.md |
|
||||
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.8 Requirements (ACTIVE — Ship Anywhere, Feature)
|
||||
## v0.9 Requirements (ACTIVE — Payment Engine, Feature)
|
||||
|
||||
**Milestone type: Feature** — payment processing and subscription management (REQ-F-018 activated per D-074). Tags on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`. Provider-agnostic protocol, mock-first (D-014 pattern): tests never call Stripe; the real provider drops in by env config without API changes.
|
||||
|
||||
### Billing Foundation
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-001 | Billing foundation: `ai_service/billing/` module (D-031 pattern — extend ai-service, no new apps): `PaymentProvider` protocol (create checkout session, retrieve session, cancel subscription, upgrade/downgrade subscription, list subscription events) + deterministic mock provider (approves-on-policy, synchronous state machine); SQLite billing store (7th D-027 family — `packages`, `plans`, `subscriptions`, `subscription_events`, `checkout_sessions` tables) with SQLModel typed access; webhook signature verification (HMAC, per-boot secret default — the v0.4.7 ingest-token pattern); provider selection via `AI_PAYMENT_PROVIDER=mock\|stripe` env (keys env-only, never committed); test-only mode is the default (mock) so the stack runs with zero keys | critical | 1 | open |
|
||||
|
||||
### Catalog + Pricing Surface
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-002 | Package catalog + real pricing surface: seeded package catalog (single posting, bundle ×5, enterprise-unlimited tiers + talent-access plans: basic/pro/enterprise per REQ-017's existing pricing-page tiers) exposed via `/v1/billing/catalog`; the pricing page switches from mock imports to runtime client fetch (A-406 pattern) with honest loading/error states; package features/limits data-driven from the catalog (no hardcoded tier copy drifting from backend); build-time static metadata retained (A-411) | critical | 2 | open |
|
||||
|
||||
### Checkout Flow
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-003 | Checkout + webhook flow: `POST /v1/billing/admin/checkout` (admin token-gated per D-076 — operator buys on an employer's behalf; buyer recorded as operator ref + employer ref) creates a provider checkout session for a catalog item; mock provider renders an in-app confirmation path; stripe provider redirects to Stripe Checkout (env-gated, never in tests); `POST /v1/billing/webhook` receives provider events with HMAC signature verification (reject 401 on bad signature — never process unsigned), idempotent event processing (event_id dedupe + per-session grant idempotency per G-303), entitlement granted on `checkout.completed` with state resolved from local rows only (G-302 — event payloads are untrusted ID references); webhook replay tolerance (at-least-once semantics — the A-309 seq pattern applied to billing) | critical | 3 | open |
|
||||
|
||||
### Entitlement Enforcement
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-004 | Posting entitlement enforcement: posting creation (admin API route per A-401) checks the employer's active package — posting limit reached → honest 402 with upgrade-CTA payload (pointing at catalog/checkout); unlimited/enterprise bypass; expired/cancelled package degrades to the same 402 path; entitlement check composed after the admin token gate (operator route — D-043 learner gate composition untouched); admin surface shows per-employer entitlement state (active package, posts used/limit, expiry) in the existing admin marketplace moderation view | high | 4 | open |
|
||||
|
||||
### Subscription Lifecycle
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-9-005 | Subscription lifecycle + billing admin: subscription states (trialing → active → past_due → cancelled/expired) with provider-driven transitions (webhook events `invoice.paid`, `invoice.payment_failed`, `subscription.cancelled`, `subscription.renewed`); renewals extend expiry idempotently; admin routes for manual grant/revoke/complement (operator remediation for failed webhooks — recorded as manual events with provenance); billing event log with full audit trail (every provider event + manual action stored, queryable); honest 402 + grace handling on payment failure (past_due grace window before downgrade) | high | 5 | open |
|
||||
|
||||
## v0.8 Requirements (Complete — Ship Anywhere, Feature; shipped as v0.7.6)
|
||||
|
||||
**Milestone type: Feature** — new distribution channels (docker image, deb packages, arm64 binaries) and release automation (Gitea Actions, pre-commit). Tags on the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release); branch `milestone/v0.8-ship-anywhere`.
|
||||
|
||||
@@ -290,7 +324,7 @@
|
||||
| ID | Description | Priority | Milestone | Status |
|
||||
|----|-------------|----------|-----------|--------|
|
||||
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.5 | activated → complete (REQ-5-003/004) |
|
||||
| REQ-F-018 | Payment processing and subscription management | high | v0.3+ | deferred |
|
||||
| REQ-F-018 | Payment processing and subscription management | high | v0.9 | activated → REQ-9-001..005 |
|
||||
| REQ-F-019 | Human tutor marketplace (third-party courses) | medium | v0.4+ | deferred |
|
||||
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
|
||||
|
||||
|
||||
+195
-1
@@ -1,4 +1,198 @@
|
||||
# Nextcraft — RESEARCH.md (v0.8 Phase 0 RESEARCH — Ship Anywhere)
|
||||
# Nextcraft — RESEARCH.md (v0.9 Phase 0 RESEARCH — Payment Engine)
|
||||
|
||||
Web-verified census of the payment/billing surface, 2026-09-29. Every
|
||||
conclusion carries its source (docs.stripe.com page or in-tree file probe).
|
||||
Binding clarify decisions D-075..D-084 are NOT re-litigated; this research
|
||||
operationalizes them. Upstream docs consulted: docs.stripe.com —
|
||||
api/checkout/sessions (create/retrieve/object), webhooks (manual signature
|
||||
verification + replay), api/subscriptions (object/cancel/update),
|
||||
billing/subscriptions/webhooks, billing/revenue-recovery/smart-retries,
|
||||
api/idempotent_requests, api/events/types, keys, billing/testing/test-clocks.
|
||||
In-tree probes grounding the module design: api/marketplace_admin.py
|
||||
(require_admin dep), api/deps.py, marketplace/store.py + identity/store.py
|
||||
(D-027 family), config.py (Settings env trio precedents),
|
||||
telemetry/tokens.py (HMAC), api/telemetry.py (WS ingest verify),
|
||||
marketplace/store.py insert_application (at-least-once dedupe precedent),
|
||||
main.py (lifespan store wiring + inline router mounts + G-11 fallback),
|
||||
voice/factory.py + marketplace/embedding/factory.py (provider factory),
|
||||
apps/web pricing page.tsx (inline JOB_PLANS/TALENT_PLANS constants),
|
||||
lib/engine-client.ts (jsonFetch + parseError), tests/marketplace/conftest.py
|
||||
(make_client + state injection), tests pins.test.ts (dual-schema pins).
|
||||
|
||||
## R-33..R-50 — Stripe-compatible protocol facts (web-verified)
|
||||
|
||||
**R-33 (high).** Checkout Session REST: `POST /v1/checkout/sessions`
|
||||
(create) + `GET /v1/checkout/sessions/{id}` (retrieve). Auth = HTTP Basic
|
||||
(`-u sk_...:`) or Bearer. Body is **form-encoded** with bracket nesting
|
||||
(`line_items[0][price_data][currency]=usd`) — NOT JSON. The stripe provider
|
||||
must encode bodies accordingly; MockTransport byte-contract tests assert the
|
||||
exact encoded body + Authorization header.
|
||||
|
||||
**R-34 (high).** `mode` required, enum `payment|setup|subscription`;
|
||||
`line_items` required for payment/subscription; `success_url` required
|
||||
(hosted); `client_reference_id` optional ≤200 chars — our reconciliation key
|
||||
(checkout_session row id); `metadata` optional map; `expires_at` 30m–24h.
|
||||
|
||||
**R-35 (high).** line item: exactly one of `price` (existing Price ID) or
|
||||
`price_data` {currency (lowercase ISO), unit_amount (int, smallest currency
|
||||
unit), product_data {name}, recurring {interval: day|week|month|year} for
|
||||
subscriptions}. quantity optional. One-time vs recurring differs only by the
|
||||
presence of `price_data[recurring][interval]` — matches D-078's catalog
|
||||
shape (packages vs plans as one table family with an interval field).
|
||||
|
||||
**R-36 (high).** Session response fields we normalize: `id` (`cs_...`),
|
||||
`url` (hosted checkout URL, present only while open), `status`
|
||||
(`open|complete|expired`), `payment_status` (`paid|unpaid|
|
||||
no_payment_required`), `customer` (nullable), `subscription` (nullable,
|
||||
subscription mode only), `mode`, `amount_total`, `currency`, `metadata`,
|
||||
`expires_at`, `livemode`.
|
||||
|
||||
**R-37 (high).** Completion predicate: `status == "complete" AND
|
||||
payment_status == "paid"` — never `status` alone (async methods can leave
|
||||
`complete` with payment still processing; `checkout.session.
|
||||
async_payment_succeeded` fires later). The billing webhook handler uses this
|
||||
predicate on `checkout.session.completed` before granting entitlements.
|
||||
|
||||
**R-38 (high).** Webhook header: `Stripe-Signature: t=<unix_seconds>,
|
||||
v1=<hex>` on one line; multiple `v1` during secret rolls; a fake `v0` scheme
|
||||
exists on test events — ignore all non-`v1` schemes (downgrade protection).
|
||||
|
||||
**R-39 (high).** Signed payload = `"{timestamp}.{raw body}"`; HMAC-SHA256
|
||||
with the endpoint secret (`whsec_...`); constant-time comparison; raw body
|
||||
must be un-parsed bytes at verification time. **D-077's scheme is
|
||||
byte-for-byte Stripe's** — adopt as-is; the in-tree precedent
|
||||
(telemetry/tokens.py) supplies the mint/verify/compare_digest shape.
|
||||
|
||||
**R-40 (high).** Replay tolerance: official default **300 s**; "Don't use a
|
||||
tolerance of 0". Each delivery/retry re-signs (new t + new v1) — attackers
|
||||
cannot alter t without invalidating. D-077's ±5 min = Stripe default.
|
||||
|
||||
**R-41 (high).** Stripe subscription.status enum: `incomplete`,
|
||||
`incomplete_expired` (23h terminal after failed initial payment),
|
||||
`trialing`, `active`, `past_due`, `canceled`, `unpaid`, `paused`.
|
||||
|
||||
**R-42 (high).** State normalization (design choice on verified facts):
|
||||
our `trialing` ← Stripe `trialing`/`incomplete`; `active` ← `active`;
|
||||
`past_due` ← `past_due`; `cancelled` ← `canceled`; `expired` ←
|
||||
`incomplete_expired`/`unpaid`. D-083's five states map cleanly.
|
||||
|
||||
**R-43 (high).** Event normalization — Stripe has no `subscription.renewed`
|
||||
or `checkout.completed` events; D-083's names are OUR canonical names,
|
||||
mapped at the provider boundary:
|
||||
- `checkout.completed` ← `checkout.session.completed`
|
||||
- `invoice.paid` ← `invoice.paid` (canonical provisioning signal;
|
||||
`invoice.payment_succeeded` also fires — accept both, treat as one)
|
||||
- `invoice.payment_failed` ← `invoice.payment_failed`
|
||||
- `subscription.cancelled` ← `customer.subscription.deleted`
|
||||
- `subscription.renewed` ← `invoice.paid` on a renewal invoice
|
||||
(corroborated by `customer.subscription.updated`)
|
||||
**Action recorded against D-083: the provider protocol carries a
|
||||
`normalize_event()` boundary; webhook processing stores + dedupes provider
|
||||
event ids verbatim (never normalized) so dedupe stays truthful.**
|
||||
|
||||
**R-44 (medium).** Dunning: after retries exhaust, subscription becomes
|
||||
`canceled` or `unpaid` per Dashboard settings; Smart Retries default ~8
|
||||
retries/2 weeks. Our mock dunning = D-083's 7-day past_due grace (module
|
||||
constant), independent of Stripe settings.
|
||||
|
||||
**R-45 (high).** Event delivery: NO ordering guarantee; duplicates happen —
|
||||
dedupe by event `id`; return 2xx fast before complex logic; retries
|
||||
exponential backoff (up to 3 days live). Confirms D-077's event_id dedupe +
|
||||
D-083's idempotent transitions; webhook route returns 200 immediately after
|
||||
persisting the event row (processing is synchronous but bounded).
|
||||
|
||||
**R-46 (high).** Cancel: `DELETE /v1/subscriptions/{id}` (immediate;
|
||||
`invoice_now`/`prorate` default false) → status `canceled`; or deferred:
|
||||
POST update `cancel_at_period_end=true` (stays active until period end, then
|
||||
`customer.subscription.deleted`). Protocol `cancel_subscription(id,
|
||||
at_period_end=False)` maps 1:1.
|
||||
|
||||
**R-47 (high).** Upgrade/downgrade: `POST /v1/subscriptions/{id}` with
|
||||
`items[0][id]=<subscription_item_id>` + `items[0][price]=<new price>` —
|
||||
**including the item id is mandatory** (else the new price is ADDED
|
||||
alongside the old → dual-charge bug). `proration_behavior`:
|
||||
`create_prorations` (default) | `none` | `always_invoice`. Failed immediate
|
||||
payment → update succeeds, subscription `past_due`. Protocol
|
||||
`update_subscription(subscription_id, new_price_ref)` must resolve the
|
||||
subscription_item id inside the provider (the route must not have to know
|
||||
it) — recorded as a byte-contract test case.
|
||||
|
||||
**R-48 (high).** `unit_amount` = integer in the smallest currency unit —
|
||||
cents for USD (`unit_amount: 1000` = $10.00). Confirms D-081 (integer cents
|
||||
end-to-end; no floats anywhere in billing).
|
||||
|
||||
**R-49 (high).** `Idempotency-Key` header on all POSTs; Stripe replays the
|
||||
saved response for 24h on key reuse; same key + different params errors;
|
||||
no key on a double-submit creates two sessions. The stripe provider sends
|
||||
`Idempotency-Key: <uuid4>` per logical checkout/subscription operation.
|
||||
|
||||
**R-50 (high).** Test mode (`sk_test_` keys) is an isolated sandbox — card
|
||||
networks never process; every object carries `livemode: false`. Combined
|
||||
with MockTransport-only tests (D-075), no test path can ever reach Stripe.
|
||||
Test clocks exist for time-simulated billing — noted as precedent; our
|
||||
tests use MockTransport + the deterministic mock exclusively.
|
||||
|
||||
## R-51..R-54 — In-tree integration facts (probe-verified)
|
||||
|
||||
**R-51.** Admin gate composition (REQ-9-003/004 operator routes): the
|
||||
`require_admin` FastAPI **dependency** (api/marketplace_admin.py:46-85) —
|
||||
`X-Admin-Token` or `Authorization: Bearer`; fail-closed when
|
||||
`AI_ADMIN_TOKEN` unset (403 even with a candidate token);
|
||||
`secrets.compare_digest` constant-time UTF-8 compare; returns `Settings`.
|
||||
Billing admin routes copy this dep verbatim; billing entitlement check
|
||||
composes AFTER it (REQ-9-004).
|
||||
|
||||
**R-52.** Store wiring: 7th D-027 store = `billing/store.py` mirroring
|
||||
marketplace/store.py — SQLModel table classes in-module (`__tablename__`,
|
||||
UniqueConstraints for idempotency keys, `sa_type=String` + `_validate()`
|
||||
instead of bare Literal annotations, `_as_utc()` re-labeling,
|
||||
`_sqlite_connect` pragmas WAL/NORMAL/busy_timeout/foreign_keys); Protocol +
|
||||
SQLiteImpl pair; `Settings().db_path` shared file; main.py lifespan
|
||||
state-injection override guard + close() in finally; `get_billing_store`
|
||||
dep in api/deps.py ("routes never import stores directly" territory rule).
|
||||
|
||||
**R-53.** Provider factory + settings trio: config.py gains
|
||||
`payment_provider: str = "mock"` + `payment_api_key: SecretStr = SecretStr("")`
|
||||
+ `webhook_secret: str = ""` (per-boot random when unset — the
|
||||
telemetry_ingest_secret shape). `billing/factory.py` mirrors
|
||||
voice/factory.py: `UnknownPaymentProviderError(ValueError)`; main.py
|
||||
lifespan catches factory errors → loud G-11 log → falls back to mock
|
||||
(R-51 boot-warning pattern; voice/embedding precedent at main.py:138-154).
|
||||
|
||||
**R-54.** Pricing page conversion (REQ-9-002): page.tsx currently holds
|
||||
inline `JOB_PLANS`/`TALENT_PLANS` constants (lines 41-111) +
|
||||
`COMPARISON` matrix; conversion = A-406 board-client pattern — page stays a
|
||||
server shell (metadata retained per A-411, pinned by pins.test.ts:189-190
|
||||
+ sitemap STATIC_PAGES), new client component fetches `/v1/billing/catalog`
|
||||
via engine-client `jsonFetch` with honest loading/error/empty states;
|
||||
`listBillingCatalog()` + `PaymentRequiredError` (402 dict-detail) added to
|
||||
engine-client.ts (parseError VerifyRequiredError precedent, lines 90-123);
|
||||
wire types in packages/types/billing.ts dual-schema-pinned in pins.test.ts.
|
||||
|
||||
## Open risks (tracked into PLAN)
|
||||
|
||||
1. **Webhook raw-body discipline**: signature verification needs the exact
|
||||
raw bytes — any FastAPI body-parsing middleware (or pydantic model
|
||||
binding) before verification breaks it. The webhook route reads
|
||||
`await request.body()` FIRST, verifies, then parses JSON. Tracked as a
|
||||
P3 must-have + probe test.
|
||||
2. **Dual-charge on upgrade** (R-47): `items[0][id]` omission is the
|
||||
classic Stripe bug — byte-contract test must assert the item id is
|
||||
present in the update body.
|
||||
3. **Webhook ordering** (R-45): `invoice.payment_failed` can arrive before
|
||||
`invoice.paid` under retries; transitions must be event-driven state
|
||||
machine transitions (InvalidTransition → 409 tolerated as already-reflected
|
||||
duplicates, recorded not raised) — the at-least-once lesson.
|
||||
4. **Clock skew in tests**: signature timestamps use `now` injection
|
||||
(protocol takes `now: float | None = None`) so replay-window tests are
|
||||
deterministic without sleeps.
|
||||
5. **Secret rotation**: multiple `v1` schemes (R-38) — verify() iterates
|
||||
ALL v1 values, accepts any match (supports secret rolls without
|
||||
downtime); test-pinned.
|
||||
|
||||
---
|
||||
|
||||
## v0.8 Phase 0 RESEARCH (Ship Anywhere — prior milestone, retained below)
|
||||
|
||||
Probe-verified census of the release/distribution surface, 2026-09-27.
|
||||
Every conclusion carries evidence (file paths probed in this repo, live HTTP
|
||||
|
||||
@@ -28,6 +28,26 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.9 (Active — Payment Engine)
|
||||
|
||||
Payment processing and subscription management (REQ-F-018 activated per D-074, auto-decided at SPECIFY under full autonomy): posting packages (single/bundle/enterprise), talent-access plans, subscription tiers with real checkout, provider webhooks (HMAC-verified, idempotent), posting-creation entitlement enforcement, and a full subscription lifecycle (renew/cancel/upgrade/past_due grace) — all behind a provider-agnostic `PaymentProvider` protocol, mock-first (D-014 pattern; tests never call Stripe). The pricing page (REQ-017) gains its real backend. Out of scope: employer auth/accounts (B2B network — post-payments per A-407), multi-currency, tax/VAT beyond passthrough, invoice PDFs, CIRR tracking (REQ-F-020). Tags on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`.
|
||||
|
||||
## Phase List
|
||||
|
||||
### v0.9 (active)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | in progress | — | — | Specify (D-074), clarify (D-075+), research (R-33+), plan, grill (G-301+), MVP/UX check complete; shipped as v0.8.0 |
|
||||
| 1 | Billing foundation | pending | 0 | REQ-9-001 | `ai_service/billing/` module: PaymentProvider protocol + deterministic mock + SQLite store (7th D-027 family) + HMAC webhook verification + env-gated provider selection; tests never call a real provider |
|
||||
| 2 | Catalog + pricing surface | pending | 1 | REQ-9-002 | Seeded catalog (posting packages + talent-access plans) via `/v1/billing/catalog`; pricing page runtime client fetch with honest loading/error states; tier copy data-driven |
|
||||
| 3 | Checkout + webhook flow | pending | 2 | REQ-9-003 | `POST /v1/billing/admin/checkout` (admin-gated, D-076) + `POST /v1/billing/webhook` (HMAC-verified, idempotent event processing, at-least-once tolerance, local-row state resolution per G-302); entitlement granted on checkout.completed |
|
||||
| 4 | Entitlement enforcement | pending | 3 | REQ-9-004 | Posting creation enforces package limits (honest 402 + upgrade CTA); expired/cancelled degrade to 402; admin surface shows per-employer entitlement state |
|
||||
| 5 | Subscription lifecycle | pending | 4 | REQ-9-005 | Subscription state machine (trialing/active/past_due/cancelled/expired); idempotent renewals; manual admin grant/revoke with provenance; full billing event audit log; past_due grace window |
|
||||
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit pass; milestone tagged v0.8.6 (final v0.8.x patch = milestone release); release with full asset matrix + registry image; milestone merged to main |
|
||||
|
||||
---
|
||||
|
||||
## v0.8 (Complete — Shipped as v0.7.6)
|
||||
|
||||
Ship Anywhere: installable on any Debian-based Linux server via docker (Gitea registry image, honest sandbox degrade per D-068), .deb packages (amd64+arm64, advisory postinst per D-067), and arch-aware binary (x64+arm64, R-24 arch-resolve) — with release consistency enforced by pre-commit hooks + Gitea Actions (3-runner fleet on CT 113 as committed fleet-as-code, machine-made releases with the 8-asset matrix + registry image on every release), the CLI carrying the full suite (guide + channel-aware doctor, D-066/D-072), and the evidence-backed admin runbook (G-210 parity-pinned). 7 phases (P0–P6), all 5 requirements (REQ-8-001..005) complete. Tags v0.7.0–v0.7.5 per phase, milestone release v0.7.6 (release 936). Final review CLEAN (G-201..G-210 honored — the smoke gate caught 4 real image bugs pre-ship, each fixed in-phase); audit PASS (reconstruction + discipline green).
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
"projects": [],
|
||||
"active_project": null,
|
||||
"milestone": {
|
||||
"version": "v0.8",
|
||||
"name": "ship-anywhere",
|
||||
"version": "v0.9",
|
||||
"name": "payment-engine",
|
||||
"type": "feature",
|
||||
"branch": "milestone/v0.8-ship-anywhere"
|
||||
"branch": "milestone/v0.9-payment-engine"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +103,18 @@ AI_EMBED_PROVIDER=mock
|
||||
# -H "Authorization: Bearer $AI_EMBED_API_KEY" \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d '{"model":"'"$AI_EMBED_MODEL"'","input":["Nextcraft probe"]}' | jq -e '.data[0].embedding | length'
|
||||
|
||||
# --- Billing / Payment Engine (REQ-9-001, D-075, D-077) ---
|
||||
# Payment provider: 'mock' (default — deterministic, zero-network; tests
|
||||
# NEVER call a real payment API) or 'stripe' (real Stripe-compatible REST,
|
||||
# P3+). Unknown names raise at the factory; the boot falls back to mock
|
||||
# with a loud log (G-11).
|
||||
# AI_PAYMENT_PROVIDER=mock
|
||||
# Real provider API key (Stripe-style sk_test_... placeholder — NEVER a
|
||||
# real key here; real keys live in .ciagent/.env.secrets, env-only per
|
||||
# D-014, never logged, SecretStr in Settings). 'mock' ignores it.
|
||||
# AI_PAYMENT_API_KEY=
|
||||
# Webhook HMAC secret (whsec_... style; UNSET → per-boot random → signed
|
||||
# webhooks FAIL CLOSED with 401 until configured — correct for a payment
|
||||
# surface). Set a fixed value only when a provider actually delivers.
|
||||
# AI_WEBHOOK_SECRET=
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Billing engine package (REQ-9-001, D-075..D-084, v0.9 Payment Engine).
|
||||
|
||||
The 7th domain module inside apps/ai-service (no new apps, no new ports —
|
||||
the D-031 boundary): pydantic contracts + the provider-agnostic
|
||||
`PaymentProvider` protocol (`base.py`), the shared HMAC webhook
|
||||
verification core (`webhooks.py`, D-077), the deterministic mock provider
|
||||
(`mock.py`, D-075 default), and the settings-driven factory
|
||||
(`factory.py`). The D-027 store family's seventh SQLite store
|
||||
(`store.py`), the seeded catalog (`catalog.py`), and the real Stripe
|
||||
provider (`stripe.py`) land with their owning phases.
|
||||
|
||||
Boundary (D-031 family discipline): `billing/` never imports `agents/`
|
||||
or `api/`; this package imports stdlib + pydantic + `config` only.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
CANONICAL_EVENT_NAMES,
|
||||
CanonicalBillingEvent,
|
||||
CanonicalEventName,
|
||||
CheckoutMode,
|
||||
CheckoutSession,
|
||||
CheckoutSessionSpec,
|
||||
CheckoutStatus,
|
||||
Package,
|
||||
PaymentProvider,
|
||||
PaymentStatus,
|
||||
Plan,
|
||||
PlanInterval,
|
||||
SubscriptionRef,
|
||||
SubscriptionStatus,
|
||||
UnknownPaymentProviderError,
|
||||
VerifiedEvent,
|
||||
WebhookSignatureError,
|
||||
)
|
||||
from .factory import payment_provider_from_settings
|
||||
from .mock import MockPaymentProvider
|
||||
|
||||
__all__ = [
|
||||
"CANONICAL_EVENT_NAMES",
|
||||
"CanonicalBillingEvent",
|
||||
"CanonicalEventName",
|
||||
"CheckoutMode",
|
||||
"CheckoutSession",
|
||||
"CheckoutSessionSpec",
|
||||
"CheckoutStatus",
|
||||
"MockPaymentProvider",
|
||||
"Package",
|
||||
"PaymentProvider",
|
||||
"PaymentStatus",
|
||||
"Plan",
|
||||
"PlanInterval",
|
||||
"SubscriptionRef",
|
||||
"SubscriptionStatus",
|
||||
"UnknownPaymentProviderError",
|
||||
"VerifiedEvent",
|
||||
"WebhookSignatureError",
|
||||
"payment_provider_from_settings",
|
||||
]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Billing pydantic contracts + PaymentProvider protocol (REQ-9-001).
|
||||
|
||||
The wire + provider shapes for the payment engine. `Package`/`Plan` are
|
||||
the catalog item shapes (D-078: seeded DB rows; P2 seeds them); the
|
||||
`CheckoutSession*` pair is the provider session seam (R-33..R-36);
|
||||
`CanonicalBillingEvent` is OUR event vocabulary — normalized at the
|
||||
provider boundary, never taken raw from a vendor (D-083, R-43).
|
||||
|
||||
Money (D-081): integer cents end-to-end — `int` with `ge=0`, no floats
|
||||
anywhere in billing, currency `usd` fixed. Stripe itself is cents-native
|
||||
(R-48: `unit_amount: 1000` = $10.00).
|
||||
|
||||
Id honesty (the identity-mock unique-mint lesson, A-305): every id in
|
||||
this module is an opaque handle minted UNIQUELY per creation call —
|
||||
never derived from user input or a deterministic hash of it.
|
||||
|
||||
Boundary (D-031 family): `billing/` never imports `agents/` or `api/`;
|
||||
stdlib + pydantic + config only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
#: One-time posting packages vs recurring talent-access plans: one shape
|
||||
#: family, one table family (D-078/D-080 — a one-time purchase is NOT a
|
||||
#: degenerate subscription).
|
||||
PlanInterval = Literal["month", "year"]
|
||||
|
||||
#: Stripe-compatible checkout modes (R-34): payment = one-time packages,
|
||||
#: subscription = recurring plans. `setup` exists on the wire enum but no
|
||||
#: catalog item maps to it in v0.9.
|
||||
CheckoutMode = Literal["payment", "setup", "subscription"]
|
||||
|
||||
#: Session status vocabulary (R-36): open (awaiting payment) → complete
|
||||
#: (checkout finished) | expired (window elapsed). The GRANT predicate is
|
||||
#: `status == "complete" AND payment_status == "paid"` — never status
|
||||
#: alone (R-37: async methods can leave complete with payment still
|
||||
#: processing).
|
||||
CheckoutStatus = Literal["open", "complete", "expired"]
|
||||
|
||||
#: Payment status vocabulary (R-36). `no_payment_required` exists on the
|
||||
#: wire enum; v0.9 grants only on `paid`.
|
||||
PaymentStatus = Literal["paid", "unpaid", "no_payment_required"]
|
||||
|
||||
#: Subscription lifecycle states (D-083). G-305: `upgraded` is NOT a
|
||||
#: status — upgrade is a plan_id transition WITHIN `active`, recorded as
|
||||
#: a billing_event with provenance. `expired` here is the terminal past-
|
||||
#: due-grace outcome; the store owns the transition matrix (P5).
|
||||
SubscriptionStatus = Literal[
|
||||
"trialing", "active", "past_due", "cancelled", "expired"
|
||||
]
|
||||
|
||||
#: OUR canonical event names (D-083, R-43: Stripe has no
|
||||
#: `subscription.renewed`/`checkout.completed` — the provider boundary
|
||||
#: maps vendor names onto these; provider event ids are stored + deduped
|
||||
#: VERBATIM so dedupe stays truthful).
|
||||
CanonicalEventName = Literal[
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
]
|
||||
|
||||
#: Tuple mirror of the Literal (docs + tests import this; keeps the enum
|
||||
#: and any exhaustive switch in one place).
|
||||
CANONICAL_EVENT_NAMES: tuple[CanonicalEventName, ...] = (
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
)
|
||||
|
||||
|
||||
class WebhookSignatureError(ValueError):
|
||||
"""Webhook signature verification failed (D-077).
|
||||
|
||||
Raised with a precise reason (missing header / no v1 schemes / stale
|
||||
timestamp / signature mismatch). The webhook route maps this to 401 —
|
||||
a payment surface never processes an unverifiable event.
|
||||
"""
|
||||
|
||||
|
||||
class UnknownPaymentProviderError(ValueError):
|
||||
"""Raised for a provider name outside the contract, or a real
|
||||
provider selected without its required configuration (R-53 — the
|
||||
voice/embedding factory pattern).
|
||||
|
||||
main.py's lifespan CATCHES this and falls back to mock with a loud
|
||||
G-11 log; direct callers (tests) see the actionable raise.
|
||||
"""
|
||||
|
||||
|
||||
class Package(BaseModel):
|
||||
"""A one-time posting package catalog row (D-078/D-080).
|
||||
|
||||
`posts_limit` counts posting creations (consumed one per
|
||||
create_posting, newest-non-expired-first — D-080); `duration_days`
|
||||
bounds the purchase window from the buy time. `features` is the
|
||||
pricing-page copy list (P2 pins the wire shape both sides).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
#: D-081: integer cents, ge=0 — currency usd fixed; never a float.
|
||||
price_cents: int = Field(ge=0)
|
||||
posts_limit: int = Field(ge=1)
|
||||
duration_days: int = Field(ge=1)
|
||||
features: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Plan(BaseModel):
|
||||
"""A recurring talent-access plan catalog row (D-078/D-080).
|
||||
|
||||
`interval` distinguishes the billing cadence; entitlement derives
|
||||
from the subscription's `expires_at` extension on each renewal
|
||||
(D-083 — idempotent per event dedupe).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
name: str = Field(min_length=1)
|
||||
#: D-081: integer cents, ge=0 — currency usd fixed; never a float.
|
||||
price_cents: int = Field(ge=0)
|
||||
interval: PlanInterval
|
||||
features: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CheckoutSessionSpec(BaseModel):
|
||||
"""What the operator asks a provider to create (R-33..R-35).
|
||||
|
||||
`item_id` references a catalog row (package or plan); the provider
|
||||
turns it into a line item (Stripe: `line_items[0][price_data]` — one-
|
||||
time vs recurring differs only by `price_data[recurring][interval]`,
|
||||
R-35). `client_reference_id` is our reconciliation key (R-34 — the
|
||||
local checkout_sessions row id). `metadata` rides the session both
|
||||
directions (D-076: operator_ref + employer_id provenance).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
mode: CheckoutMode
|
||||
item_id: str = Field(min_length=1)
|
||||
success_url: str = ""
|
||||
cancel_url: str = ""
|
||||
client_reference_id: str = Field(min_length=1, max_length=200)
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CheckoutSession(BaseModel):
|
||||
"""Provider session state (R-36 normalized fields).
|
||||
|
||||
`url` is present only while a session is open (hosted checkout);
|
||||
`expires_at` bounds the payment window (R-34: 30m–24h on Stripe).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
url: str = ""
|
||||
status: CheckoutStatus
|
||||
payment_status: PaymentStatus
|
||||
mode: CheckoutMode
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class SubscriptionRef(BaseModel):
|
||||
"""A provider subscription handle (R-41/R-42 normalized states).
|
||||
|
||||
The store owns the lifecycle rows; this is what provider calls
|
||||
(cancel/update) and webhook payloads exchange.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
status: SubscriptionStatus
|
||||
|
||||
|
||||
class CanonicalBillingEvent(BaseModel):
|
||||
"""A provider event normalized onto OUR vocabulary (D-083, R-43).
|
||||
|
||||
`payload` is UNTRUSTED (G-302): webhook processing treats it as ID
|
||||
references only — session/subscription state resolves from LOCAL
|
||||
rows; purchase price/name come from the stored snapshot (D-078),
|
||||
never from the event body.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: CanonicalEventName
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VerifiedEvent(BaseModel):
|
||||
"""What verify_webhook returns: the authenticated event.
|
||||
|
||||
Verification authenticates the TRANSPORT (HMAC over the raw body);
|
||||
`event` is the parsed body AFTER the signature gate. Providers that
|
||||
need raw-body parsing keep it inside their own verify_webhook.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
event: CanonicalBillingEvent
|
||||
#: Provider event id, stored + deduped VERBATIM (R-43/D-083 — never
|
||||
#: normalized; the UniqueConstraint is on this exact string).
|
||||
provider_event_id: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PaymentProvider(Protocol):
|
||||
"""The payment port (D-075): checkout + subscriptions + webhooks.
|
||||
|
||||
Two implementations ship in-milestone: deterministic mock (default —
|
||||
tests never call a real provider) and Stripe-compatible REST (P3,
|
||||
exercised ONLY through httpx.MockTransport byte-contract tests).
|
||||
Never imports agents/api; selected by `factory.py` from Settings.
|
||||
"""
|
||||
|
||||
async def create_checkout_session(
|
||||
self, spec: CheckoutSessionSpec
|
||||
) -> CheckoutSession:
|
||||
"""Create a provider checkout session for a catalog item."""
|
||||
...
|
||||
|
||||
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
|
||||
"""Fetch session state; None when the provider does not know the id."""
|
||||
...
|
||||
|
||||
async def cancel_subscription(
|
||||
self, subscription_id: str, *, at_period_end: bool = False
|
||||
) -> SubscriptionRef | None:
|
||||
"""Cancel a subscription (R-46: immediate DELETE or deferred
|
||||
cancel_at_period_end); None when unknown to the provider."""
|
||||
...
|
||||
|
||||
async def update_subscription(
|
||||
self, subscription_id: str, new_price_cents: int
|
||||
) -> SubscriptionRef | None:
|
||||
"""Move a subscription to a new price (R-47: the provider resolves
|
||||
the subscription_item id inside — items[0][id] must be in the
|
||||
request or the new price is ADDED alongside the old → dual
|
||||
charge)."""
|
||||
...
|
||||
|
||||
def verify_webhook(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
signature_header: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> VerifiedEvent:
|
||||
"""Authenticate + parse a webhook delivery (D-077).
|
||||
|
||||
`raw_body` must be the exact un-parsed bytes (risk-1); raises
|
||||
`WebhookSignatureError` on any verification failure — the route
|
||||
maps that to 401, never processes further.
|
||||
"""
|
||||
...
|
||||
|
||||
def normalize_event(self, provider_event: dict[str, Any]) -> CanonicalBillingEvent:
|
||||
"""Map a provider-native event payload onto our canonical names
|
||||
(R-43). Mock speaks canonical names directly → passthrough."""
|
||||
...
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Payment provider factory (D-075, R-53, REQ-9-001).
|
||||
|
||||
`AI_PAYMENT_PROVIDER = mock | stripe` (default: mock — the no-key path
|
||||
is first-class; tests never call a real provider). Mirrors the
|
||||
voice/embedding factory pattern: the factory raises
|
||||
`UnknownPaymentProviderError` with an actionable message for direct
|
||||
callers (tests), while the lifespan in main.py CATCHES it and falls
|
||||
back to mock with a loud log — a typo'd env must never crash the
|
||||
unattended boot (G-11).
|
||||
|
||||
`stripe` raises NotImplementedError here in P1: the real Stripe-
|
||||
compatible REST provider lands with Phase 3 (Task 3-1-01) — the branch
|
||||
exists now so the selection surface + the P3 route wiring have their
|
||||
final shape, and a stripe-configured box gets an honest actionable
|
||||
error (caught by main.py → G-11 mock fallback) rather than a silent
|
||||
misconfiguration.
|
||||
|
||||
Webhook secret (D-077): the settings default is the empty string —
|
||||
UNSET → the factory substitutes a per-boot random secret (the
|
||||
telemetry_ingest_secret pattern). Restart invalidates it, so stale
|
||||
signed deliveries fail: webhooks fail closed when unconfigured, which
|
||||
is correct for a payment surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..config import Settings
|
||||
from .base import PaymentProvider, UnknownPaymentProviderError
|
||||
from .mock import MockPaymentProvider
|
||||
|
||||
|
||||
def payment_provider_from_settings(settings: Settings) -> PaymentProvider:
|
||||
"""Select the payment provider by settings (env `AI_PAYMENT_PROVIDER`).
|
||||
|
||||
The selected provider gets the webhook secret resolved here:
|
||||
configured `AI_WEBHOOK_SECRET` verbatim, else per-boot random
|
||||
(fail-closed webhooks, D-077).
|
||||
"""
|
||||
name = (settings.payment_provider or "mock").strip().lower()
|
||||
# D-077: empty configured secret → per-boot random inside the
|
||||
# provider (mock mints its own when handed ""; stripe will use the
|
||||
# same resolution in P3).
|
||||
webhook_secret = settings.webhook_secret
|
||||
if name == "mock":
|
||||
return MockPaymentProvider(webhook_secret=webhook_secret)
|
||||
if name == "stripe":
|
||||
# P1 shape-lock: the stripe branch is real but not yet — the
|
||||
# REST provider lands in P3 (Task 3-1-01) with MockTransport
|
||||
# byte-contract tests. Loud + actionable, never a silent miss.
|
||||
raise NotImplementedError(
|
||||
"stripe provider lands in P3 (REQ-9-003) — set "
|
||||
"AI_PAYMENT_PROVIDER=mock for the pilot path"
|
||||
)
|
||||
raise UnknownPaymentProviderError(
|
||||
f"unknown AI_PAYMENT_PROVIDER {name!r}: use 'mock' (or 'stripe' "
|
||||
"once the P3 provider ships)"
|
||||
)
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Deterministic mock payment provider (D-075, REQ-9-001).
|
||||
|
||||
Stripe-shaped without any network: sessions (`cs_mock_...` ids), the
|
||||
same HMAC webhook scheme (via webhooks.mint/verify — byte-identical to
|
||||
the real provider path), and canonical event names spoken directly
|
||||
(normalize_event is a passthrough — R-43's boundary maps VENDOR names;
|
||||
the mock has none to map). Tests never call a real provider; the
|
||||
mock-complete path (D-082, P3) mints a signed event through the REAL
|
||||
webhook processing via `mint_event`.
|
||||
|
||||
Id honesty (the identity-mock unique-mint lesson, verified there):
|
||||
session ids are minted UNIQUELY per create call — a monotonic counter
|
||||
plus per-process seed from `secrets`. A deterministic id derived from
|
||||
the spec would collide on any retry/re-entry of the same purchase
|
||||
request and the store's per-session idempotency (G-303) would silently
|
||||
conflate distinct logical checkouts. Uniqueness per call is the
|
||||
contract; determinism of VERDICTS (what tests pin) is preserved — the
|
||||
session lifecycle is scriptable, not the ids.
|
||||
|
||||
Session lifecycle: create → status open / payment unpaid; the operator
|
||||
completes out-of-band payment via `confirm_completion` (the D-076
|
||||
pilot flow — P3's admin-gated mock-complete route calls this) →
|
||||
status complete / payment_status paid (the R-37 grant predicate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from . import webhooks
|
||||
from .base import (
|
||||
CanonicalBillingEvent,
|
||||
CheckoutSession,
|
||||
CheckoutSessionSpec,
|
||||
SubscriptionRef,
|
||||
VerifiedEvent,
|
||||
)
|
||||
|
||||
#: Session payment window (R-34's shape; Stripe allows 30m–24h — the
|
||||
#: mock keeps a roomy 24h default so operator-mediated completion is
|
||||
#: never raced in the pilot).
|
||||
_SESSION_TTL = timedelta(hours=24)
|
||||
|
||||
#: Event-id prefix — provider event ids are stored + deduped VERBATIM
|
||||
#: (R-43/D-083); the mock mints them uniquely like session ids.
|
||||
_EVENT_ID_PREFIX = "evt_mock_"
|
||||
|
||||
|
||||
class MockPaymentProvider:
|
||||
"""In-memory, deterministic, zero-network PaymentProvider.
|
||||
|
||||
Scriptable sessions + real-scheme webhook minting — the full
|
||||
checkout→webhook loop is provable without any vendor.
|
||||
"""
|
||||
|
||||
def __init__(self, webhook_secret: str = "") -> None:
|
||||
# Per-process nonce: ids are opaque handles, never derived from
|
||||
# user input — counter + nonce keeps ids unique within and
|
||||
# across provider instances on one box.
|
||||
self._nonce = secrets.randbits(32)
|
||||
self._session_counter = itertools.count()
|
||||
self._event_counter = itertools.count()
|
||||
# Sessions by id: the provider's view of checkout state.
|
||||
self._sessions: dict[str, CheckoutSession] = {}
|
||||
# Subscriptions by id: created on subscription-mode completion
|
||||
# (P5 drives the lifecycle; the mock keeps the ref shape honest).
|
||||
self._subscriptions: dict[str, SubscriptionRef] = {}
|
||||
self._webhook_secret = webhook_secret or secrets.token_urlsafe(32)
|
||||
|
||||
# -- ids -----------------------------------------------------------------
|
||||
|
||||
def _mint_session_id(self) -> str:
|
||||
# Stripe-shaped cs_ prefix + mock marker (R-36's id shape; the
|
||||
# mock marker keeps logs/test failures self-explanatory).
|
||||
return (
|
||||
f"cs_mock_{self._nonce:08x}{next(self._session_counter):08x}"
|
||||
)
|
||||
|
||||
def _mint_event_id(self) -> str:
|
||||
return (
|
||||
f"{_EVENT_ID_PREFIX}{self._nonce:08x}{next(self._event_counter):08x}"
|
||||
)
|
||||
|
||||
# -- checkout ------------------------------------------------------------
|
||||
|
||||
async def create_checkout_session(
|
||||
self, spec: CheckoutSessionSpec
|
||||
) -> CheckoutSession:
|
||||
"""Create a session (status open, payment unpaid — R-36)."""
|
||||
session_id = self._mint_session_id()
|
||||
session = CheckoutSession(
|
||||
id=session_id,
|
||||
# Hosted-checkout stand-in: the mock has no hosted page; the
|
||||
# url is a stable pseudo-hosted pointer (never fetched).
|
||||
url=f"https://mock.local/checkout/{session_id}",
|
||||
status="open",
|
||||
payment_status="unpaid",
|
||||
mode=spec.mode,
|
||||
expires_at=datetime.now(UTC) + _SESSION_TTL,
|
||||
)
|
||||
self._sessions[session.id] = session
|
||||
return session
|
||||
|
||||
async def retrieve_session(self, session_id: str) -> CheckoutSession | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
async def confirm_completion(self, session_id: str) -> CheckoutSession:
|
||||
"""Flip a session to complete/paid (the R-37 grant predicate).
|
||||
|
||||
The out-of-band payment step of the pilot flow (D-076): P3's
|
||||
admin-gated mock-complete route calls this, then mints the
|
||||
signed webhook event through the real processing path. Unknown
|
||||
or already-complete sessions raise ValueError (fail loud, not
|
||||
idempotent-silent: the ROUTE owns idempotency via event dedupe).
|
||||
"""
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
raise ValueError(f"unknown checkout session {session_id!r}")
|
||||
if session.status != "open":
|
||||
raise ValueError(
|
||||
f"session {session_id!r} is {session.status!r}, not open"
|
||||
)
|
||||
completed = session.model_copy(
|
||||
update={"status": "complete", "payment_status": "paid"}
|
||||
)
|
||||
self._sessions[session_id] = completed
|
||||
if completed.mode == "subscription":
|
||||
# Subscription-mode completion creates the provider-side
|
||||
# subscription ref (R-36: `subscription` present in
|
||||
# subscription mode only).
|
||||
self._subscriptions.setdefault(
|
||||
f"sub_mock_{session_id.removeprefix('cs_')}",
|
||||
SubscriptionRef(
|
||||
id=f"sub_mock_{session_id.removeprefix('cs_')}",
|
||||
status="active",
|
||||
),
|
||||
)
|
||||
return completed
|
||||
|
||||
# -- subscriptions ---------------------------------------------------------
|
||||
|
||||
async def cancel_subscription(
|
||||
self, subscription_id: str, *, at_period_end: bool = False
|
||||
) -> SubscriptionRef | None:
|
||||
subscription = self._subscriptions.get(subscription_id)
|
||||
if subscription is None:
|
||||
return None
|
||||
# R-46 shape: immediate → cancelled now; deferred → stays active
|
||||
# until period end (the P5 webhook drives the final state).
|
||||
if at_period_end:
|
||||
return subscription
|
||||
cancelled = subscription.model_copy(update={"status": "cancelled"})
|
||||
self._subscriptions[subscription_id] = cancelled
|
||||
return cancelled
|
||||
|
||||
async def update_subscription(
|
||||
self, subscription_id: str, new_price_cents: int
|
||||
) -> SubscriptionRef | None:
|
||||
subscription = self._subscriptions.get(subscription_id)
|
||||
if subscription is None:
|
||||
return None
|
||||
# R-47 note: the real provider resolves items[0][id] inside; the
|
||||
# mock has no line items to dual-charge — price is recorded by
|
||||
# the store's plan snapshot (D-078), so this is a state no-op
|
||||
# that keeps the protocol shape honest.
|
||||
return subscription
|
||||
|
||||
# -- webhooks (D-077 scheme, shared core) -----------------------------------
|
||||
|
||||
def verify_webhook(
|
||||
self,
|
||||
raw_body: bytes,
|
||||
signature_header: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> VerifiedEvent:
|
||||
"""Authenticate + parse a mock delivery via the shared core."""
|
||||
webhooks.verify(
|
||||
raw_body, signature_header, self._webhook_secret, now=now
|
||||
)
|
||||
body = _parse_json_body(raw_body)
|
||||
event = self.normalize_event(body)
|
||||
# Provider event id: top-level on the body, kept VERBATIM (the
|
||||
# R-43 rule — normalize the NAME, never the id).
|
||||
provider_event_id = str(body.get("id", ""))
|
||||
return VerifiedEvent(
|
||||
event=event, provider_event_id=provider_event_id
|
||||
)
|
||||
|
||||
def mint_event(
|
||||
self, name: str, payload: dict[str, Any]
|
||||
) -> tuple[bytes, str, str]:
|
||||
"""Mint a signed webhook delivery for tests + the complete path.
|
||||
|
||||
Returns `(raw_body, signature_header, provider_event_id)`. The
|
||||
body is the canonical event JSON — the exact bytes a provider
|
||||
would POST and the webhook route would read via
|
||||
`await request.body()`. Round-trip green through
|
||||
verify_webhook by construction (mint/verify share the scheme).
|
||||
"""
|
||||
provider_event_id = self._mint_event_id()
|
||||
body = json.dumps(
|
||||
{
|
||||
"id": provider_event_id,
|
||||
"name": name,
|
||||
"payload": payload,
|
||||
}
|
||||
).encode()
|
||||
header = webhooks.mint(self._webhook_secret, body)
|
||||
return body, header, provider_event_id
|
||||
|
||||
def normalize_event(
|
||||
self, provider_event: dict[str, Any]
|
||||
) -> CanonicalBillingEvent:
|
||||
"""Passthrough (R-43): the mock speaks canonical names directly."""
|
||||
name = str(provider_event.get("name", ""))
|
||||
if name not in (
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
):
|
||||
raise ValueError(f"unknown event name {name!r}")
|
||||
payload = provider_event.get("payload")
|
||||
return CanonicalBillingEvent(
|
||||
name=name, # type: ignore[arg-type] # validated in-contract above
|
||||
payload=payload if isinstance(payload, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_body(raw_body: bytes) -> dict[str, Any]:
|
||||
"""Parse the event JSON body (post-verification, risk-1 safe)."""
|
||||
decoded = json.loads(raw_body.decode("utf-8"))
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError("event body is not a JSON object")
|
||||
return decoded
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
"""Webhook signature verification core (D-077, R-38..R-40).
|
||||
|
||||
Byte-for-byte Stripe's scheme shape, shared by every provider so the
|
||||
mock and the real Stripe path verify identically (the byte-contract
|
||||
lesson): header `Stripe-Signature: t=<unix_seconds>,v1=<hex>` on one
|
||||
line; signed payload `"{timestamp}.{raw_body}"`; HMAC-SHA256 with the
|
||||
endpoint secret; constant-time comparison; ±300 s replay window.
|
||||
|
||||
Security properties (each pinned by tests/billing/test_webhooks.py):
|
||||
- raw body: verification needs the exact UN-PARSED bytes (risk-1) — the
|
||||
caller passes `raw_body: bytes`, never a pre-parsed model;
|
||||
- multiple `v1` values (secret rolls — R-38): verify iterates ALL v1
|
||||
candidates, any match wins (R-53 risk-5);
|
||||
- non-`v1` schemes (`v0` on Stripe test events, unknown future ones):
|
||||
ignored entirely — a v0-only header has no v1 to check and fails
|
||||
(downgrade protection);
|
||||
- replay window (R-40, Stripe default 300 s): the timestamp bounds how
|
||||
long a captured delivery can be replayed; `now` is injectable so
|
||||
tests are deterministic without sleeps (risk-4);
|
||||
- fail closed (D-077): every failure raises `WebhookSignatureError`
|
||||
with a precise reason — a payment surface never processes an
|
||||
unverifiable event.
|
||||
|
||||
`mint` exists for tests + the mock provider: it produces exactly what a
|
||||
signed provider delivery looks like, so the mock-complete path (D-082)
|
||||
can post through the REAL webhook processing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
from .base import WebhookSignatureError
|
||||
|
||||
#: Replay tolerance in seconds (R-40: Stripe's official default; "don't
|
||||
#: use a tolerance of 0" — clock skew between provider and box is real).
|
||||
DEFAULT_TOLERANCE_S: float = 300.0
|
||||
|
||||
|
||||
def _parse_header(header: str) -> tuple[int | None, list[str]]:
|
||||
"""Parse `t=...,v1=...,v1=...` into (timestamp, v1 candidates).
|
||||
|
||||
Elements are comma-separated `key=value` pairs (R-38). Unknown
|
||||
schemes (v0, anything else) are ignored: only `v1` is ever checked.
|
||||
Returns timestamp None when the header carries no valid `t` pair.
|
||||
"""
|
||||
timestamp: int | None = None
|
||||
v1_candidates: list[str] = []
|
||||
for element in header.split(","):
|
||||
key, sep, value = element.strip().partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
if key == "t":
|
||||
try:
|
||||
timestamp = int(value)
|
||||
except ValueError:
|
||||
# Malformed t → treat as absent; verify() fails on the
|
||||
# stale/missing-timestamp branch (fail closed).
|
||||
continue
|
||||
elif key == "v1":
|
||||
v1_candidates.append(value)
|
||||
return timestamp, v1_candidates
|
||||
|
||||
|
||||
def _compute_signature(
|
||||
secret: str, timestamp: int, raw_body: bytes
|
||||
) -> str:
|
||||
"""HMAC-SHA256 over `"{timestamp}.{raw_body}"` (R-39).
|
||||
|
||||
The timestamp is attacker-immutable in transit: changing `t` without
|
||||
the secret invalidates every v1 in the header.
|
||||
"""
|
||||
signed_payload = f"{timestamp}.".encode() + raw_body
|
||||
return hmac.new(
|
||||
secret.encode("utf-8"), signed_payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def mint(
|
||||
secret: str,
|
||||
raw_body: bytes,
|
||||
now: float | None = None,
|
||||
) -> str:
|
||||
"""Mint a `Stripe-Signature` header for `raw_body` (D-077 scheme).
|
||||
|
||||
Test + mock-provider helper (the mint half of the telemetry
|
||||
tokens.py precedent). `now` defaults to the wall clock; inject for
|
||||
determinism. Produces exactly one `t` + one `v1` pair — what a
|
||||
provider delivery carries.
|
||||
"""
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
signature = _compute_signature(secret, timestamp, raw_body)
|
||||
return f"t={timestamp},v1={signature}"
|
||||
|
||||
|
||||
def verify(
|
||||
raw_body: bytes,
|
||||
header: str | None,
|
||||
secret: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
tolerance_s: float = DEFAULT_TOLERANCE_S,
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Verify a `Stripe-Signature` header against the raw body.
|
||||
|
||||
Returns `(timestamp, v1_candidates)` on success — the parsed header
|
||||
the caller may want for replay bookkeeping. Raises
|
||||
`WebhookSignatureError` with a precise reason otherwise:
|
||||
|
||||
- missing/empty header → "missing Stripe-Signature header";
|
||||
- no `v1` schemes present → "no v1 signature schemes in header"
|
||||
(a v0-only header lands here — non-v1 schemes are ignored);
|
||||
- timestamp absent or outside ±tolerance_s of now → "stale
|
||||
timestamp" (replay window, R-40);
|
||||
- no v1 candidate matches → "signature mismatch" (constant-time
|
||||
compare per candidate — any match wins across secret rolls).
|
||||
"""
|
||||
if not header:
|
||||
raise WebhookSignatureError("missing Stripe-Signature header")
|
||||
timestamp, v1_candidates = _parse_header(header)
|
||||
if not v1_candidates:
|
||||
raise WebhookSignatureError(
|
||||
f"no v1 signature schemes in header {header!r} "
|
||||
"(non-v1 schemes are ignored)"
|
||||
)
|
||||
if timestamp is None:
|
||||
raise WebhookSignatureError(
|
||||
f"missing or malformed timestamp in header {header!r}"
|
||||
)
|
||||
current = now if now is not None else time.time()
|
||||
if abs(current - timestamp) > tolerance_s:
|
||||
raise WebhookSignatureError(
|
||||
f"stale timestamp {timestamp}: outside the "
|
||||
f"{tolerance_s:g}s replay window at {current:g}"
|
||||
)
|
||||
for candidate in v1_candidates:
|
||||
expected = _compute_signature(secret, timestamp, raw_body)
|
||||
if hmac.compare_digest(expected, candidate):
|
||||
return timestamp, v1_candidates
|
||||
raise WebhookSignatureError("signature mismatch: no v1 candidate matches")
|
||||
@@ -213,3 +213,24 @@ class Settings(BaseSettings):
|
||||
# A-302/D-041: upload guard before the provider call (webm/opus is
|
||||
# ~0.5-1MB/min, so 10MB tolerates very long answers).
|
||||
voice_max_audio_mb: int = 10
|
||||
|
||||
# v0.9 Payment Engine (REQ-9-001, D-075, R-53): payment provider
|
||||
# selection — 'mock' (default: deterministic, zero-network; tests
|
||||
# NEVER call a real payment API) or 'stripe' (real Stripe-compatible
|
||||
# REST, P3). A typo'd name raises at the factory; main.py's lifespan
|
||||
# catches it and falls back to mock with a loud log (G-11 — the
|
||||
# voice/embedding factory pattern).
|
||||
payment_provider: str = "mock"
|
||||
|
||||
# Real payment API key (Stripe sk_... class) — 'stripe' requires
|
||||
# this; 'mock' ignores it. Keys env-only (D-014), never committed,
|
||||
# never logged (SecretStr mirrors the admin token; tests use fixed
|
||||
# test secrets, never real ones).
|
||||
payment_api_key: SecretStr = SecretStr("")
|
||||
|
||||
# Webhook HMAC secret (D-077, the telemetry_ingest_secret pattern):
|
||||
# UNSET (empty default) → the factory substitutes a PER-BOOT RANDOM
|
||||
# secret — restart invalidates stale signed deliveries, so webhooks
|
||||
# FAIL CLOSED when unconfigured (401, never process unverifiable —
|
||||
# correct for a payment surface). Set for multi-process deploys.
|
||||
webhook_secret: str = ""
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Billing engine tests (REQ-9-001) — module usable WITHOUT any route.
|
||||
|
||||
P1 Must-have: mock provider + webhooks core proven by direct
|
||||
instantiation only (no app, no routes, zero network). The store tests
|
||||
(test_store.py) join this package with the data-engineer's Task 1-1-02.
|
||||
"""
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Base contract tests (Task 1-1-01, REQ-9-001, D-081/D-083).
|
||||
|
||||
Pins:
|
||||
- money is integer cents with ge=0 (D-081): negative prices rejected on
|
||||
Package AND Plan; the int wire type is the dual-schema pin's backend
|
||||
half (P2's pins.test.ts checks the TS side)
|
||||
- the canonical event-name enum is CLOSED (D-083/R-43): unknown names
|
||||
rejected by CanonicalBillingEvent, all five canonical names accepted
|
||||
- contract defaults: features lists default empty, payload dict default
|
||||
empty, CheckoutSessionSpec carries the reconciliation key
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ai_service.billing.base import (
|
||||
CANONICAL_EVENT_NAMES,
|
||||
CanonicalBillingEvent,
|
||||
CheckoutSessionSpec,
|
||||
Package,
|
||||
Plan,
|
||||
)
|
||||
|
||||
|
||||
def _package(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": "pkg-single",
|
||||
"name": "Single post",
|
||||
"price_cents": 4900,
|
||||
"posts_limit": 1,
|
||||
"duration_days": 30,
|
||||
"features": ["1 job posting"],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _plan(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": "plan-pro",
|
||||
"name": "Pro",
|
||||
"price_cents": 29900,
|
||||
"interval": "month",
|
||||
"features": ["talent access"],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestPackageContract:
|
||||
def test_valid_package_roundtrip(self) -> None:
|
||||
pkg = Package(**_package())
|
||||
assert pkg.price_cents == 4900
|
||||
assert pkg.posts_limit == 1
|
||||
assert pkg.duration_days == 30
|
||||
assert pkg.features == ["1 job posting"]
|
||||
|
||||
def test_negative_price_cents_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="price_cents"):
|
||||
Package(**_package(price_cents=-1))
|
||||
|
||||
def test_zero_cents_allowed(self) -> None:
|
||||
# ge=0 — a $0 promo tier is a valid catalog row (D-081 pins
|
||||
# NON-negative, not positive).
|
||||
assert Package(**_package(price_cents=0)).price_cents == 0
|
||||
|
||||
def test_cents_must_be_int_no_floats(self) -> None:
|
||||
# D-081: no floats anywhere in billing — 49.5 dollars must not
|
||||
# silently coerce. model_validate keeps mypy from rejecting the
|
||||
# deliberately-typed-wrong input (the runtime is the pin).
|
||||
with pytest.raises(ValidationError):
|
||||
Package.model_validate(_package(price_cents=49.5))
|
||||
|
||||
def test_posts_limit_and_duration_positive(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Package(**_package(posts_limit=0))
|
||||
with pytest.raises(ValidationError):
|
||||
Package(**_package(duration_days=0))
|
||||
|
||||
|
||||
class TestPlanContract:
|
||||
def test_valid_plan_roundtrip(self) -> None:
|
||||
plan = Plan(**_plan())
|
||||
assert plan.price_cents == 29900
|
||||
assert plan.interval == "month"
|
||||
|
||||
def test_negative_price_cents_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="price_cents"):
|
||||
Plan(**_plan(price_cents=-100))
|
||||
|
||||
def test_unknown_interval_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError, match="interval"):
|
||||
Plan(**_plan(interval="fortnight"))
|
||||
|
||||
def test_features_default_empty(self) -> None:
|
||||
plan = Plan(id="plan-x", name="X", price_cents=0, interval="month")
|
||||
assert plan.features == []
|
||||
|
||||
|
||||
class TestCanonicalEventEnum:
|
||||
def test_all_five_canonical_names_accepted(self) -> None:
|
||||
for name in CANONICAL_EVENT_NAMES:
|
||||
event = CanonicalBillingEvent(name=name, payload={"id": "evt_1"})
|
||||
assert event.name == name
|
||||
|
||||
def test_canonical_names_are_the_d083_set(self) -> None:
|
||||
# D-083's exact vocabulary (R-43: OUR names, mapped at the
|
||||
# provider boundary — never vendor names).
|
||||
assert set(CANONICAL_EVENT_NAMES) == {
|
||||
"checkout.completed",
|
||||
"invoice.paid",
|
||||
"invoice.payment_failed",
|
||||
"subscription.cancelled",
|
||||
"subscription.renewed",
|
||||
}
|
||||
|
||||
def test_unknown_event_name_rejected(self) -> None:
|
||||
# Vendor names must NOT validate as canonical (R-43 — the
|
||||
# boundary maps, never trusts). model_validate: the dict payload
|
||||
# is deliberately mistyped; the runtime enum is the pin.
|
||||
with pytest.raises(ValidationError, match="checkout.session.completed"):
|
||||
CanonicalBillingEvent.model_validate(
|
||||
{"name": "checkout.session.completed"}
|
||||
)
|
||||
|
||||
def test_payload_defaults_empty_dict(self) -> None:
|
||||
assert CanonicalBillingEvent(name="invoice.paid").payload == {}
|
||||
|
||||
|
||||
class TestCheckoutSessionSpec:
|
||||
def test_spec_shape(self) -> None:
|
||||
spec = CheckoutSessionSpec(
|
||||
mode="payment",
|
||||
item_id="pkg-single",
|
||||
success_url="https://app.example/success",
|
||||
cancel_url="https://app.example/cancel",
|
||||
client_reference_id="sess_local_1",
|
||||
metadata={"employer_id": "E1"},
|
||||
)
|
||||
assert spec.mode == "payment"
|
||||
assert spec.metadata == {"employer_id": "E1"}
|
||||
|
||||
def test_client_reference_id_required(self) -> None:
|
||||
# R-34: our reconciliation key — no spec without it.
|
||||
with pytest.raises(ValidationError):
|
||||
CheckoutSessionSpec.model_validate(
|
||||
{"mode": "payment", "item_id": "pkg-single"}
|
||||
)
|
||||
|
||||
def test_unknown_mode_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
CheckoutSessionSpec.model_validate(
|
||||
{
|
||||
"mode": "rent",
|
||||
"item_id": "pkg-single",
|
||||
"client_reference_id": "sess_local_1",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Factory + settings trio tests (Task 1-1-05, R-53, D-075/D-077).
|
||||
|
||||
Pins (MH-1d):
|
||||
- factory: mock default; explicit mock; unknown name → actionable
|
||||
UnknownPaymentProviderError; 'stripe' → NotImplementedError (P1
|
||||
shape-lock: the real provider lands in P3)
|
||||
- webhook secret resolution: configured secret is used VERBATIM (the
|
||||
mint/verify round-trip proves the provider holds it); default empty
|
||||
→ per-boot random (two providers do not accept each other's
|
||||
signatures — fail-closed, D-077)
|
||||
- settings redaction: payment_api_key is SecretStr — repr(Settings)
|
||||
NEVER leaks the key material (the admin-token/embedding precedent)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.billing.base import (
|
||||
UnknownPaymentProviderError,
|
||||
WebhookSignatureError,
|
||||
)
|
||||
from ai_service.billing.factory import payment_provider_from_settings
|
||||
from ai_service.billing.mock import MockPaymentProvider
|
||||
from ai_service.config import Settings
|
||||
|
||||
|
||||
class TestFactorySelection:
|
||||
def test_default_is_mock(self) -> None:
|
||||
# D-075: mock default — the no-key pilot path is first-class.
|
||||
provider = payment_provider_from_settings(Settings())
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
|
||||
def test_explicit_mock_selected(self) -> None:
|
||||
provider = payment_provider_from_settings(
|
||||
Settings(payment_provider="mock")
|
||||
)
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
|
||||
def test_provider_name_normalized(self) -> None:
|
||||
# Voice/embedding factory precedent: strip + lower — env typos
|
||||
# in casing/whitespace must not 500 the selection.
|
||||
provider = payment_provider_from_settings(
|
||||
Settings(payment_provider=" MOCK ")
|
||||
)
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
|
||||
def test_unknown_provider_raises(self) -> None:
|
||||
with pytest.raises(UnknownPaymentProviderError, match="satellite"):
|
||||
payment_provider_from_settings(Settings(payment_provider="satellite"))
|
||||
|
||||
def test_stripe_raises_not_implemented_p3(self) -> None:
|
||||
# P1 shape-lock: the branch exists with its final name; the
|
||||
# REST provider lands in P3 (REQ-9-003 Task 3-1-01).
|
||||
with pytest.raises(NotImplementedError, match="P3"):
|
||||
payment_provider_from_settings(Settings(payment_provider="stripe"))
|
||||
|
||||
|
||||
class TestWebhookSecretResolution:
|
||||
def test_configured_secret_used_verbatim(self) -> None:
|
||||
# The mint/verify round-trip proves the provider was built with
|
||||
# the CONFIGURED secret (not a random one). mint_event is a
|
||||
# mock-only helper (D-082's signed-delivery mint) — narrow the
|
||||
# factory's protocol return to the concrete mock first.
|
||||
provider = payment_provider_from_settings(
|
||||
Settings(webhook_secret="whsec_test_configured")
|
||||
)
|
||||
assert isinstance(provider, MockPaymentProvider)
|
||||
raw_body, header, _ = provider.mint_event("invoice.paid", {})
|
||||
verified = provider.verify_webhook(raw_body, header)
|
||||
assert verified.event.name == "invoice.paid"
|
||||
|
||||
def test_unset_secret_is_per_boot_random_fail_closed(self) -> None:
|
||||
# D-077 (telemetry_ingest_secret pattern): unset → per-boot
|
||||
# random. Two independently built providers hold DIFFERENT
|
||||
# random secrets — one cannot verify the other's signatures,
|
||||
# so an unconfigured deployment fails closed (401s), never
|
||||
# processes unsigned traffic.
|
||||
a = payment_provider_from_settings(Settings())
|
||||
b = payment_provider_from_settings(Settings())
|
||||
assert isinstance(a, MockPaymentProvider)
|
||||
assert isinstance(b, MockPaymentProvider)
|
||||
raw_body, header, _ = a.mint_event("invoice.paid", {})
|
||||
with pytest.raises(WebhookSignatureError):
|
||||
b.verify_webhook(raw_body, header)
|
||||
|
||||
|
||||
class TestSettingsTrio:
|
||||
def test_settings_defaults(self) -> None:
|
||||
settings = Settings()
|
||||
assert settings.payment_provider == "mock"
|
||||
assert settings.payment_api_key.get_secret_value() == ""
|
||||
assert settings.webhook_secret == ""
|
||||
|
||||
def test_env_prefix_wiring(self) -> None:
|
||||
# R-53: the env trio is AI_PAYMENT_PROVIDER / AI_PAYMENT_API_KEY
|
||||
# / AI_WEBHOOK_SECRET (pydantic-settings prefix AI_).
|
||||
settings = Settings(
|
||||
payment_provider="stripe",
|
||||
payment_api_key=SecretStr("sk_test_placeholder"),
|
||||
webhook_secret="whsec_placeholder",
|
||||
)
|
||||
assert settings.payment_provider == "stripe"
|
||||
assert settings.payment_api_key.get_secret_value() == "sk_test_placeholder"
|
||||
assert settings.webhook_secret == "whsec_placeholder"
|
||||
|
||||
def test_repr_does_not_leak_payment_api_key(self) -> None:
|
||||
# SecretStr redaction: key material must never survive a repr
|
||||
# (logs, error messages, tracebacks). 'sk_' is the leak canary.
|
||||
settings = Settings(payment_api_key=SecretStr("sk_test_super_secret_key"))
|
||||
rendered = repr(settings)
|
||||
assert "sk_test_super_secret_key" not in rendered
|
||||
assert "sk_" not in rendered
|
||||
|
||||
def test_str_does_not_leak_payment_api_key(self) -> None:
|
||||
settings = Settings(payment_api_key=SecretStr("sk_test_super_secret_key"))
|
||||
assert "sk_test_super_secret_key" not in str(settings)
|
||||
assert "sk_" not in str(settings)
|
||||
|
||||
def test_webhook_secret_is_plain_str_by_design(self) -> None:
|
||||
# The webhook secret is compared in-process (HMAC key), not
|
||||
# echoed to surfaces — plain str mirrors telemetry_ingest_secret
|
||||
# (SecretStr adds friction for a value that must be readable by
|
||||
# the signer; it is never logged either way).
|
||||
settings = Settings(webhook_secret="whsec_plain_ok")
|
||||
assert settings.webhook_secret == "whsec_plain_ok"
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Mock provider tests (Task 1-1-03, D-075, REQ-9-001).
|
||||
|
||||
Pins:
|
||||
- session ids are UNIQUE per create call across 100 calls (the
|
||||
identity-mock unique-mint lesson — a deterministic id would collide
|
||||
on retries and conflate distinct logical checkouts, G-303)
|
||||
- ids are Stripe-shaped with the mock marker: cs_mock_... (R-36)
|
||||
- session lifecycle: create → open/unpaid; confirm_completion →
|
||||
complete/paid (the R-37 grant predicate, scriptable not faked);
|
||||
unknown/already-complete confirm raises (fail loud — the ROUTE owns
|
||||
idempotency via event dedupe)
|
||||
- mint_event → verify_webhook round-trip green (the mock-complete path
|
||||
D-082 posts through the REAL verification scheme)
|
||||
- verify_webhook fails closed on tampered bytes + wrong secret
|
||||
- normalize_event is a passthrough that still enforces the canonical
|
||||
enum (R-43: the mock speaks OUR names directly)
|
||||
- retrieve unknown id → None; subscriptions cancel per R-46
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_service.billing.base import (
|
||||
CheckoutSessionSpec,
|
||||
PaymentProvider,
|
||||
WebhookSignatureError,
|
||||
)
|
||||
from ai_service.billing.mock import MockPaymentProvider
|
||||
|
||||
SECRET = "whsec_mock_test_secret"
|
||||
|
||||
|
||||
def _spec(**overrides: Any) -> CheckoutSessionSpec:
|
||||
base: dict[str, Any] = {
|
||||
"mode": "payment",
|
||||
"item_id": "pkg-single",
|
||||
"client_reference_id": "sess_local_1",
|
||||
"metadata": {"employer_id": "E1"},
|
||||
}
|
||||
base.update(overrides)
|
||||
return CheckoutSessionSpec(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> MockPaymentProvider:
|
||||
return MockPaymentProvider(webhook_secret=SECRET)
|
||||
|
||||
|
||||
class TestIdUniqueness:
|
||||
async def test_ids_unique_across_100_calls(self) -> None:
|
||||
# The unique-mint lesson: per-process nonce + monotonic counter
|
||||
# — uniqueness per call is the contract.
|
||||
provider = MockPaymentProvider()
|
||||
ids = {
|
||||
(await provider.create_checkout_session(_spec())).id
|
||||
for _ in range(100)
|
||||
}
|
||||
assert len(ids) == 100
|
||||
|
||||
def test_ids_unique_across_instances(self) -> None:
|
||||
# Two providers on one box (e.g. multi-worker) never mint the
|
||||
# same session id: the nonce is per-instance.
|
||||
a = MockPaymentProvider()._mint_session_id()
|
||||
b = MockPaymentProvider()._mint_session_id()
|
||||
assert a != b
|
||||
|
||||
async def test_id_shape_is_stripe_mock(self) -> None:
|
||||
provider = MockPaymentProvider()
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
assert session.id.startswith("cs_mock_")
|
||||
assert len(session.id) > len("cs_mock_")
|
||||
|
||||
|
||||
class TestSessionLifecycle:
|
||||
async def test_create_is_open_unpaid(self, provider: MockPaymentProvider) -> None:
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
assert session.status == "open"
|
||||
assert session.payment_status == "unpaid"
|
||||
assert session.mode == "payment"
|
||||
assert session.expires_at is not None
|
||||
|
||||
async def test_confirm_flips_complete_paid(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
completed = await provider.confirm_completion(session.id)
|
||||
assert completed.status == "complete"
|
||||
assert completed.payment_status == "paid" # R-37 predicate
|
||||
|
||||
async def test_retrieve_reflects_completion(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
await provider.confirm_completion(session.id)
|
||||
retrieved = await provider.retrieve_session(session.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.status == "complete"
|
||||
assert retrieved.payment_status == "paid"
|
||||
|
||||
async def test_confirm_unknown_session_raises(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="unknown checkout session"):
|
||||
await provider.confirm_completion("cs_mock_does_not_exist")
|
||||
|
||||
async def test_confirm_twice_raises(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
# Fail loud, not idempotent-silent: the ROUTE owns idempotency
|
||||
# (event dedupe + G-303 per-session grants), not the provider.
|
||||
session = await provider.create_checkout_session(_spec())
|
||||
await provider.confirm_completion(session.id)
|
||||
with pytest.raises(ValueError, match="not open"):
|
||||
await provider.confirm_completion(session.id)
|
||||
|
||||
async def test_retrieve_unknown_session_none(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
assert await provider.retrieve_session("cs_mock_nope") is None
|
||||
|
||||
|
||||
class TestSubscriptionMode:
|
||||
async def test_subscription_completion_creates_active_ref(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
session = await provider.create_checkout_session(
|
||||
_spec(mode="subscription", item_id="plan-pro")
|
||||
)
|
||||
await provider.confirm_completion(session.id)
|
||||
cancelled = await provider.cancel_subscription(
|
||||
f"sub_mock_{session.id.removeprefix('cs_')}"
|
||||
)
|
||||
assert cancelled is not None
|
||||
assert cancelled.status == "cancelled"
|
||||
|
||||
async def test_cancel_unknown_subscription_none(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
assert await provider.cancel_subscription("sub_mock_nope") is None
|
||||
|
||||
|
||||
class TestWebhookRoundTrip:
|
||||
async def test_mint_event_verify_roundtrip_green(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
# The D-082 path: mint_event produces exactly what a provider
|
||||
# delivery looks like; verify_webhook (the REAL shared scheme)
|
||||
# authenticates + parses it.
|
||||
raw_body, header, event_id = provider.mint_event(
|
||||
"checkout.completed", {"session_id": "cs_mock_x"}
|
||||
)
|
||||
verified = provider.verify_webhook(raw_body, header)
|
||||
assert verified.event.name == "checkout.completed"
|
||||
assert verified.event.payload == {"session_id": "cs_mock_x"}
|
||||
assert verified.provider_event_id == event_id
|
||||
|
||||
async def test_event_ids_unique_across_mints(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
ids = {
|
||||
provider.mint_event("invoice.paid", {})[2] for _ in range(50)
|
||||
}
|
||||
assert len(ids) == 50
|
||||
|
||||
def test_verify_tampered_body_fails_closed(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
raw_body, header, _ = provider.mint_event("invoice.paid", {})
|
||||
with pytest.raises(WebhookSignatureError):
|
||||
provider.verify_webhook(raw_body + b"x", header)
|
||||
|
||||
def test_verify_wrong_secret_fails_closed(self) -> None:
|
||||
other = MockPaymentProvider(webhook_secret="whsec_other")
|
||||
raw_body, header, _ = other.mint_event("invoice.paid", {})
|
||||
provider = MockPaymentProvider(webhook_secret=SECRET)
|
||||
with pytest.raises(WebhookSignatureError):
|
||||
provider.verify_webhook(raw_body, header)
|
||||
|
||||
def test_verify_missing_header_fails_closed(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="missing"):
|
||||
provider.verify_webhook(b"{}", "")
|
||||
|
||||
|
||||
class TestNormalizePassthrough:
|
||||
def test_canonical_names_pass_through(self, provider: MockPaymentProvider) -> None:
|
||||
# R-43: the mock speaks canonical names directly — no mapping.
|
||||
event = provider.normalize_event(
|
||||
{"name": "subscription.renewed", "payload": {"subscription_id": "s1"}}
|
||||
)
|
||||
assert event.name == "subscription.renewed"
|
||||
assert event.payload == {"subscription_id": "s1"}
|
||||
|
||||
def test_vendor_name_rejected(self, provider: MockPaymentProvider) -> None:
|
||||
# Passthrough still enforces the closed enum: a vendor-shaped
|
||||
# name must not silently validate as canonical.
|
||||
with pytest.raises(ValueError, match="unknown event name"):
|
||||
provider.normalize_event({"name": "checkout.session.completed"})
|
||||
|
||||
def test_payload_non_dict_degrades_to_empty(
|
||||
self, provider: MockPaymentProvider
|
||||
) -> None:
|
||||
event = provider.normalize_event({"name": "invoice.paid", "payload": "oops"})
|
||||
assert event.payload == {}
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
def test_satisfies_payment_provider_protocol(self) -> None:
|
||||
# runtime_checkable protocol: the mock IS a PaymentProvider
|
||||
# (structural) — the seam P3's stripe provider must also satisfy.
|
||||
provider = MockPaymentProvider()
|
||||
assert isinstance(provider, PaymentProvider)
|
||||
@@ -0,0 +1,556 @@
|
||||
"""BillingStore tests — the 7th D-027 store (REQ-9-001).
|
||||
|
||||
Mirrors tests/marketplace/test_store.py: tmp_path db, sqlite_master
|
||||
table-existence pins, PRAGMA pins, IntegrityError surfacing, snapshot
|
||||
integrity, dedupe idempotency, transition matrix, and the grill
|
||||
guards: G-303 (per-session grants), G-304 (conditional-UPDATE
|
||||
consumption), G-305 (no upgraded status), G-306 (lazy grace expiry).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session
|
||||
|
||||
from ai_service.billing.base import Package, Plan
|
||||
from ai_service.billing.store import (
|
||||
PAST_DUE_GRACE_DAYS,
|
||||
CheckoutSessionRow,
|
||||
InvalidTransition,
|
||||
NoActivePackage,
|
||||
PackageExhausted,
|
||||
SQLiteBillingStore,
|
||||
)
|
||||
|
||||
SEED_PACKAGES = [
|
||||
Package(
|
||||
id="pkg-single",
|
||||
name="Single Post",
|
||||
price_cents=4900,
|
||||
posts_limit=1,
|
||||
duration_days=30,
|
||||
features=["1 posting", "30 days"],
|
||||
),
|
||||
Package(
|
||||
id="pkg-bundle",
|
||||
name="Bundle",
|
||||
price_cents=39900,
|
||||
posts_limit=10,
|
||||
duration_days=90,
|
||||
features=["10 postings", "90 days each"],
|
||||
),
|
||||
]
|
||||
|
||||
SEED_PLANS = [
|
||||
Plan(
|
||||
id="plan-starter",
|
||||
name="Starter",
|
||||
price_cents=9900,
|
||||
interval="month",
|
||||
features=["browse talent"],
|
||||
),
|
||||
Plan(
|
||||
id="plan-pro",
|
||||
name="Pro",
|
||||
price_cents=29900,
|
||||
interval="month",
|
||||
features=["unlimited views"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def make_store(tmp_path: Path) -> SQLiteBillingStore:
|
||||
return SQLiteBillingStore(db_path=tmp_path / "billing-test.db")
|
||||
|
||||
|
||||
def seeded_store(tmp_path: Path) -> SQLiteBillingStore:
|
||||
store = make_store(tmp_path)
|
||||
store.seed_catalog(SEED_PACKAGES, SEED_PLANS)
|
||||
return store
|
||||
|
||||
|
||||
def make_session_row(
|
||||
store: SQLiteBillingStore, employer: str = "emp-1"
|
||||
) -> CheckoutSessionRow:
|
||||
return store.insert_checkout_session(
|
||||
provider_session_id=f"cs_mock_{employer}",
|
||||
employer_id=employer,
|
||||
item_id="pkg-bundle",
|
||||
item_name="Bundle",
|
||||
price_cents=39900,
|
||||
mode="payment",
|
||||
)
|
||||
|
||||
|
||||
class TestSchema:
|
||||
def test_all_seven_tables_exist(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with store._engine.connect() as conn:
|
||||
names = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
).fetchall()
|
||||
}
|
||||
for table in (
|
||||
"billing_packages",
|
||||
"billing_plans",
|
||||
"billing_purchases",
|
||||
"billing_subscriptions",
|
||||
"billing_provider_events",
|
||||
"billing_checkout_sessions",
|
||||
"billing_billing_events",
|
||||
):
|
||||
assert table in names
|
||||
|
||||
def test_pragmas(self, tmp_path: Path) -> None:
|
||||
"""WAL + synchronous=NORMAL + busy_timeout + foreign_keys — the
|
||||
D-027 per-connection contract."""
|
||||
store = make_store(tmp_path)
|
||||
with store._engine.connect() as conn:
|
||||
journal_row = conn.execute(text("PRAGMA journal_mode")).fetchone()
|
||||
sync_row = conn.execute(text("PRAGMA synchronous")).fetchone()
|
||||
busy_row = conn.execute(text("PRAGMA busy_timeout")).fetchone()
|
||||
fk_row = conn.execute(text("PRAGMA foreign_keys")).fetchone()
|
||||
assert journal_row is not None and sync_row is not None
|
||||
assert busy_row is not None and fk_row is not None
|
||||
assert str(journal_row[0]).lower() == "wal"
|
||||
assert sync_row[0] == 1 # NORMAL
|
||||
assert busy_row[0] == 5000
|
||||
assert fk_row[0] == 1
|
||||
|
||||
def test_seed_catalog_idempotent(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
store.seed_catalog(SEED_PACKAGES, SEED_PLANS) # second run
|
||||
packages, plans = store.list_catalog()
|
||||
assert [p.id for p in packages] == ["pkg-single", "pkg-bundle"]
|
||||
assert [p.id for p in plans] == ["plan-starter", "plan-pro"]
|
||||
|
||||
|
||||
class TestSnapshots:
|
||||
def test_purchase_snapshot_survives_catalog_edit(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
purchase = store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
assert purchase.package_name == "Bundle"
|
||||
assert purchase.price_cents == 39900
|
||||
# Catalog price edit must NOT rewrite purchase history (D-078).
|
||||
edited = [p.model_copy(update={"price_cents": 99900}) for p in SEED_PACKAGES]
|
||||
store.seed_catalog(edited, SEED_PLANS)
|
||||
packages, _ = store.list_catalog()
|
||||
assert packages[1].price_cents == 99900
|
||||
entitlement = store.get_entitlement("emp-1")
|
||||
assert entitlement["purchase"]["posts_limit"] == 10
|
||||
assert purchase.price_cents == 39900 # snapshot intact
|
||||
|
||||
|
||||
class TestProviderEventDedupe:
|
||||
def test_same_event_id_replay_created_false(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert store.insert_provider_event("evt-1", "invoice.paid", {"x": 1}) is True
|
||||
assert store.insert_provider_event("evt-1", "invoice.paid", {"x": 1}) is False
|
||||
|
||||
def test_distinct_event_ids_pass_through(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
assert store.insert_provider_event("evt-1", "invoice.paid", {}) is True
|
||||
assert store.insert_provider_event("evt-2", "invoice.paid", {}) is True
|
||||
|
||||
|
||||
class TestPerSessionGrants:
|
||||
"""G-303: grants are idempotent per SESSION, not per event id."""
|
||||
|
||||
def test_second_purchase_for_same_session_raises(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
|
||||
def test_subscription_upsert_keyed_on_provider_ref(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_mock_sub_1",
|
||||
employer_id="emp-1",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
first = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_prov_1",
|
||||
interval="month",
|
||||
)
|
||||
second = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_prov_1",
|
||||
interval="month",
|
||||
)
|
||||
assert first.id == second.id # the EXISTING row rides the replay
|
||||
|
||||
def test_distinct_event_ids_same_session_one_purchase(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""G-303 composite pin (Task 1-1-02's exact test): two provider
|
||||
events with DIFFERENT event ids announcing the SAME session's
|
||||
completion → exactly one purchase row. Event-id dedupe alone
|
||||
would pass both through; the per-SESSION UniqueConstraint is
|
||||
what makes the grant exactly-once."""
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
# Both distinct events are stored verbatim (dedupe keys differ)…
|
||||
assert store.insert_provider_event("evt-a", "checkout.completed", {}) is True
|
||||
assert store.insert_provider_event("evt-b", "checkout.completed", {}) is True
|
||||
# …each handler attempt grants from the same session snapshot…
|
||||
first = store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
# …and the second grant for that session is refused by the
|
||||
# constraint (IntegrityError → the caller records
|
||||
# already-granted, never a second purchase row).
|
||||
with pytest.raises(IntegrityError):
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
with Session(store._engine) as session:
|
||||
count = int(
|
||||
session.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM billing_purchases "
|
||||
"WHERE employer_id = 'emp-1'"
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
assert count == 1 # one purchase despite two distinct event ids
|
||||
assert first.posts_limit == 10
|
||||
|
||||
|
||||
class TestConsumePosting:
|
||||
def test_consume_increments(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
row = store.consume_posting("emp-1")
|
||||
assert row.posts_used == 1
|
||||
|
||||
def test_no_purchase_raises_no_active(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
with pytest.raises(NoActivePackage):
|
||||
store.consume_posting("emp-none")
|
||||
|
||||
def test_exhausted_raises(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_mock_single",
|
||||
employer_id="emp-1",
|
||||
item_id="pkg-single",
|
||||
item_name="Single Post",
|
||||
price_cents=4900,
|
||||
mode="payment",
|
||||
)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=1, duration_days=30
|
||||
)
|
||||
assert store.consume_posting("emp-1").posts_used == 1
|
||||
with pytest.raises(PackageExhausted):
|
||||
store.consume_posting("emp-1")
|
||||
|
||||
def test_unlimited_never_exhausts(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
purchase = store.create_purchase_from_session(
|
||||
session_row, posts_limit=None, duration_days=None
|
||||
)
|
||||
assert purchase.posts_limit is None
|
||||
for _ in range(5):
|
||||
assert store.consume_posting("emp-1") is not None
|
||||
|
||||
def test_expired_purchase_skipped(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = make_session_row(store)
|
||||
purchase = store.create_purchase_from_session(
|
||||
session_row, posts_limit=10, duration_days=90
|
||||
)
|
||||
assert purchase.expires_at is not None
|
||||
# Force-expire: created_at back far enough that expires_at passed.
|
||||
with Session(store._engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE billing_purchases SET expires_at = :past "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"past": datetime.now(UTC) - timedelta(days=1), "id": purchase.id},
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(PackageExhausted):
|
||||
store.consume_posting("emp-1")
|
||||
|
||||
def test_newest_purchase_consumed_first(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
older = store.insert_checkout_session(
|
||||
provider_session_id="cs_old",
|
||||
employer_id="emp-1",
|
||||
item_id="pkg-single",
|
||||
item_name="Single Post",
|
||||
price_cents=4900,
|
||||
mode="payment",
|
||||
)
|
||||
store.create_purchase_from_session(
|
||||
older, posts_limit=1, duration_days=30
|
||||
)
|
||||
with Session(store._engine) as session:
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE billing_purchases SET created_at = :past WHERE id = "
|
||||
"(SELECT id FROM billing_purchases WHERE checkout_session_id = "
|
||||
"(SELECT id FROM billing_checkout_sessions WHERE "
|
||||
"provider_session_id = 'cs_old'))"
|
||||
),
|
||||
{"past": datetime.now(UTC) - timedelta(days=10)},
|
||||
)
|
||||
session.commit()
|
||||
newer = make_session_row(store) # cs_mock_emp-1, newest
|
||||
store.create_purchase_from_session(
|
||||
newer, posts_limit=10, duration_days=90
|
||||
)
|
||||
row = store.consume_posting("emp-1")
|
||||
# The newest (bundle, 10 posts) must be the one consumed.
|
||||
assert row.checkout_session_id == newer.id
|
||||
assert row.package_id == "pkg-bundle"
|
||||
|
||||
def test_concurrent_consumes_one_post_left(self, tmp_path: Path) -> None:
|
||||
"""G-304: the conditional UPDATE means two concurrent consumes
|
||||
against ONE remaining post → exactly one succeeds. Deterministic:
|
||||
both connections fire the same statement; SQLite WAL serializes
|
||||
the write; the loser's rowcount is 0."""
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_race",
|
||||
employer_id="emp-race",
|
||||
item_id="pkg-single",
|
||||
item_name="Single Post",
|
||||
price_cents=4900,
|
||||
mode="payment",
|
||||
)
|
||||
store.create_purchase_from_session(
|
||||
session_row, posts_limit=1, duration_days=30
|
||||
)
|
||||
results: list[str] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def consume() -> None:
|
||||
barrier.wait() # both attempt the same instant
|
||||
try:
|
||||
store.consume_posting("emp-race")
|
||||
results.append("ok")
|
||||
except (PackageExhausted, NoActivePackage):
|
||||
results.append("exhausted")
|
||||
|
||||
threads = [threading.Thread(target=consume) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert sorted(results) == ["exhausted", "ok"] # exactly one win
|
||||
with Session(store._engine) as session:
|
||||
used = int(
|
||||
session.execute(
|
||||
text(
|
||||
"SELECT posts_used FROM billing_purchases "
|
||||
"WHERE employer_id = 'emp-race'"
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
assert used == 1 # single increment, no double-spend
|
||||
|
||||
|
||||
class TestTransitionMatrix:
|
||||
def _active_subscription(self, store: SQLiteBillingStore) -> str:
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_sub",
|
||||
employer_id="emp-sub",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
sub = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_prov",
|
||||
interval="month",
|
||||
)
|
||||
return sub.id
|
||||
|
||||
def test_legal_transitions(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
assert store.transition_subscription(sub_id, "past_due") is not None
|
||||
assert store.transition_subscription(sub_id, "active") is not None
|
||||
assert store.transition_subscription(sub_id, "cancelled") is not None
|
||||
|
||||
def test_illegal_transition_raises(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
with pytest.raises(InvalidTransition):
|
||||
store.transition_subscription(sub_id, "trialing")
|
||||
|
||||
def test_cancelled_is_terminal(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
store.transition_subscription(sub_id, "cancelled")
|
||||
with pytest.raises(InvalidTransition):
|
||||
store.transition_subscription(sub_id, "active")
|
||||
|
||||
def test_past_due_sets_timestamp(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
row = store.transition_subscription(sub_id, "past_due")
|
||||
assert row is not None
|
||||
assert row.past_due_at is not None
|
||||
recovered = store.transition_subscription(sub_id, "active")
|
||||
assert recovered is not None
|
||||
assert recovered.past_due_at is None
|
||||
|
||||
def test_no_upgraded_status_value(self, tmp_path: Path) -> None:
|
||||
"""G-305: the validated set contains no `upgraded`."""
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
with pytest.raises(ValueError, match="invalid subscription status"):
|
||||
store.transition_subscription(sub_id, "upgraded")
|
||||
|
||||
def test_upgrade_is_plan_transition_within_active(self, tmp_path: Path) -> None:
|
||||
"""G-305: upgrade changes plan_id, keeps status=active, writes
|
||||
a manual billing_event."""
|
||||
store = seeded_store(tmp_path)
|
||||
sub_id = self._active_subscription(store)
|
||||
row = store.upgrade_subscription(sub_id, "plan-pro", "Pro", 29900)
|
||||
assert row is not None
|
||||
assert row.status == "active"
|
||||
assert row.plan_id == "plan-pro"
|
||||
events = store.list_billing_events(employer_id="emp-sub", kind="manual")
|
||||
assert any(e.name == "upgrade" for e in events)
|
||||
|
||||
|
||||
class TestLazyGraceExpiry:
|
||||
"""G-306: past_due → expired computed LAZILY at read time."""
|
||||
|
||||
def _past_due(self, store: SQLiteBillingStore) -> None:
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_grace",
|
||||
employer_id="emp-grace",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
sub = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_grace",
|
||||
interval="month",
|
||||
)
|
||||
store.transition_subscription(sub.id, "past_due")
|
||||
|
||||
def _past_due_at(self, store: SQLiteBillingStore) -> datetime:
|
||||
with Session(store._engine) as session:
|
||||
raw = session.execute(
|
||||
text(
|
||||
"SELECT past_due_at FROM billing_subscriptions "
|
||||
"WHERE employer_id = 'emp-grace'"
|
||||
)
|
||||
).scalar_one()
|
||||
assert isinstance(raw, str) # raw text() returns ISO strings
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=UTC)
|
||||
|
||||
def test_inside_grace_still_granted(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
self._past_due(store)
|
||||
past_due_at = self._past_due_at(store)
|
||||
inside = past_due_at + timedelta(days=PAST_DUE_GRACE_DAYS - 1)
|
||||
entitlement = store.get_entitlement("emp-grace", now=inside)
|
||||
assert entitlement["subscription"]["status"] == "past_due"
|
||||
|
||||
def test_beyond_grace_reads_expired(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
self._past_due(store)
|
||||
past_due_at = self._past_due_at(store)
|
||||
beyond = past_due_at + timedelta(days=PAST_DUE_GRACE_DAYS + 1)
|
||||
entitlement = store.get_entitlement("emp-grace", now=beyond)
|
||||
assert entitlement["subscription"]["status"] == "expired"
|
||||
# No writes happened — lazy evaluation (G-306).
|
||||
with Session(store._engine) as session:
|
||||
status = session.execute(
|
||||
text(
|
||||
"SELECT status FROM billing_subscriptions "
|
||||
"WHERE employer_id = 'emp-grace'"
|
||||
)
|
||||
).scalar_one()
|
||||
assert status == "past_due"
|
||||
|
||||
|
||||
class TestRenewalExtension:
|
||||
def test_extend_period_idempotent_per_event(self, tmp_path: Path) -> None:
|
||||
store = seeded_store(tmp_path)
|
||||
session_row = store.insert_checkout_session(
|
||||
provider_session_id="cs_renew",
|
||||
employer_id="emp-renew",
|
||||
item_id="plan-pro",
|
||||
item_name="Pro",
|
||||
price_cents=29900,
|
||||
mode="subscription",
|
||||
)
|
||||
sub = store.create_subscription_from_session(
|
||||
session_row,
|
||||
provider_subscription_id="sub_renew",
|
||||
interval="month",
|
||||
)
|
||||
assert sub.current_period_end is not None
|
||||
before = sub.current_period_end
|
||||
extended = store.extend_subscription_period("sub_renew", days=30)
|
||||
assert extended is not None
|
||||
assert extended.current_period_end == before + timedelta(days=30)
|
||||
|
||||
|
||||
class TestAuditLog:
|
||||
def test_manual_provenance_round_trips(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.insert_billing_event(
|
||||
"manual", "grant", employer_id="emp-1", operator="op-1"
|
||||
)
|
||||
rows = store.list_billing_events(employer_id="emp-1", kind="manual")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].operator == "op-1"
|
||||
|
||||
def test_unmatched_marker_round_trips(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.insert_billing_event(
|
||||
"unmatched", "checkout.completed", detail={"session": "cs_unknown"}
|
||||
)
|
||||
rows = store.list_billing_events(kind="unmatched")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].name == "checkout.completed"
|
||||
|
||||
def test_filters_by_employer_and_kind(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
store.insert_billing_event("provider", "invoice.paid", employer_id="emp-1")
|
||||
store.insert_billing_event("manual", "grant", employer_id="emp-2")
|
||||
assert len(store.list_billing_events(employer_id="emp-1")) == 1
|
||||
assert len(store.list_billing_events(kind="provider")) == 1
|
||||
|
||||
def test_invalid_kind_rejected(self, tmp_path: Path) -> None:
|
||||
store = make_store(tmp_path)
|
||||
with pytest.raises(ValueError, match="invalid billing event kind"):
|
||||
store.insert_billing_event("bogus", "x")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Webhook HMAC verification matrix (Task 1-1-04, D-077, R-38..R-40).
|
||||
|
||||
Pins (each maps to a security property of webhooks.py):
|
||||
- good signature verifies, returns the parsed (t, v1) header
|
||||
- tampered body fails (the signature binds the exact raw bytes)
|
||||
- missing header fails; empty string header fails the same way
|
||||
- stale timestamp (outside the ±300s window) fails; fresh passes
|
||||
- multiple v1 candidates: any match wins (secret rolls, R-38)
|
||||
- v0-only header rejected (non-v1 schemes ignored — downgrade protection)
|
||||
- tampered timestamp fails (t is bound into the signed payload)
|
||||
- unknown scheme elements never crash the parse (ignored, not fatal)
|
||||
- replay of the SAME delivery verifies twice (idempotent at the verify
|
||||
layer — dedupe is the store/route's job, R-45)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_service.billing.base import WebhookSignatureError
|
||||
from ai_service.billing.webhooks import mint, verify
|
||||
|
||||
SECRET = "whsec_test_secret_123"
|
||||
OTHER_SECRET = "whsec_rotated_secret_456"
|
||||
BODY = b'{"id": "evt_1", "name": "invoice.paid", "payload": {}}'
|
||||
NOW = 1_800_000_000.0
|
||||
|
||||
|
||||
class TestGoodSignatures:
|
||||
def test_good_signature_verifies(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
timestamp, v1s = verify(BODY, header, SECRET, now=NOW)
|
||||
assert timestamp is not None
|
||||
assert len(v1s) == 1
|
||||
|
||||
def test_header_shape_t_and_v1(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
# R-38's wire shape: t=<unix_seconds>,v1=<hex> on one line.
|
||||
assert header.startswith("t=1800000000,v1=")
|
||||
assert len(header.split("v1=", 1)[1]) == 64 # sha256 hex
|
||||
|
||||
def test_mint_uses_wall_clock_by_default(self) -> None:
|
||||
before = time.time()
|
||||
header = mint(SECRET, BODY)
|
||||
minted_t = int(header.split(",", 1)[0].removeprefix("t="))
|
||||
assert before - 1 <= minted_t <= time.time() + 1
|
||||
|
||||
def test_verify_uses_wall_clock_by_default(self) -> None:
|
||||
header = mint(SECRET, BODY) # stamped with the real now
|
||||
verify(BODY, header, SECRET) # must be inside the window
|
||||
|
||||
def test_same_delivery_replays_green(self) -> None:
|
||||
# R-45: at-least-once delivery — the SAME signed bytes verify
|
||||
# any number of times; dedupe is downstream's concern.
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestTampering:
|
||||
def test_tampered_body_fails(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY + b"x", header, SECRET, now=NOW)
|
||||
|
||||
def test_wrong_secret_fails(self) -> None:
|
||||
header = mint(OTHER_SECRET, BODY, now=NOW)
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_tampered_timestamp_fails(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
minted_v1 = header.split("v1=", 1)[1]
|
||||
# t participates in the signed payload: moving it without the
|
||||
# secret invalidates v1 (R-40's attacker-immutable t).
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, f"t={int(NOW) + 1},v1={minted_v1}", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestMissingHeader:
|
||||
def test_missing_header_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="missing"):
|
||||
verify(BODY, None, SECRET, now=NOW)
|
||||
|
||||
def test_empty_header_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="missing"):
|
||||
verify(BODY, "", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestReplayWindow:
|
||||
def test_stale_timestamp_fails(self) -> None:
|
||||
# 400s old — outside the 300s window (R-40, risk-4: injected now,
|
||||
# no sleeps).
|
||||
header = mint(SECRET, BODY, now=NOW - 400)
|
||||
with pytest.raises(WebhookSignatureError, match="stale timestamp"):
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_future_timestamp_fails(self) -> None:
|
||||
# Clock-skew guard cuts both ways: a future-stamped delivery is
|
||||
# also outside the window.
|
||||
header = mint(SECRET, BODY, now=NOW + 400)
|
||||
with pytest.raises(WebhookSignatureError, match="stale timestamp"):
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_fresh_inside_window_passes(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW - 299)
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_malformed_timestamp_fails(self) -> None:
|
||||
header = mint(SECRET, BODY, now=NOW)
|
||||
minted_v1 = header.split("v1=", 1)[1]
|
||||
with pytest.raises(WebhookSignatureError, match="timestamp"):
|
||||
verify(BODY, f"t=not-a-number,v1={minted_v1}", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestMultiV1SecretRolls:
|
||||
def test_multiple_v1_any_match_wins(self) -> None:
|
||||
# R-38/R-53 risk-5: a secret roll sends both the old and new
|
||||
# signatures; verify iterates ALL v1 candidates.
|
||||
old_sig = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
new_sig = mint(OTHER_SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
header = f"t={int(NOW)},v1={old_sig},v1={new_sig}"
|
||||
timestamp, v1s = verify(BODY, header, OTHER_SECRET, now=NOW)
|
||||
assert timestamp == int(NOW)
|
||||
assert len(v1s) == 2
|
||||
|
||||
def test_old_secret_still_verifies_during_roll(self) -> None:
|
||||
old_sig = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
new_sig = mint(OTHER_SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
header = f"t={int(NOW)},v1={old_sig},v1={new_sig}"
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_all_v1_wrong_fails(self) -> None:
|
||||
a = mint("whsec_a", BODY, now=NOW).split("v1=", 1)[1]
|
||||
b = mint("whsec_b", BODY, now=NOW).split("v1=", 1)[1]
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, f"t={int(NOW)},v1={a},v1={b}", SECRET, now=NOW)
|
||||
|
||||
|
||||
class TestNonV1Schemes:
|
||||
def test_v0_only_rejected(self) -> None:
|
||||
# R-38: a v0-only header (Stripe test events) has NO v1 to
|
||||
# check — ignored, never trusted (downgrade protection).
|
||||
with pytest.raises(WebhookSignatureError, match="no v1 signature"):
|
||||
verify(BODY, f"t={int(NOW)},v0=deadbeef", SECRET, now=NOW)
|
||||
|
||||
def test_unknown_scheme_ignored_not_fatal(self) -> None:
|
||||
# Unknown scheme elements are skipped; a valid v1 among them
|
||||
# still verifies (forward compatibility without downgrade).
|
||||
v1 = mint(SECRET, BODY, now=NOW).split("v1=", 1)[1]
|
||||
header = f"t={int(NOW)},v9=abc,v1={v1},v0=deadbeef"
|
||||
verify(BODY, header, SECRET, now=NOW)
|
||||
|
||||
def test_v1_empty_value_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="signature mismatch"):
|
||||
verify(BODY, f"t={int(NOW)},v1=", SECRET, now=NOW)
|
||||
|
||||
def test_garbage_header_no_v1_fails(self) -> None:
|
||||
with pytest.raises(WebhookSignatureError, match="no v1 signature"):
|
||||
verify(BODY, "not a signature header at all", SECRET, now=NOW)
|
||||
Reference in New Issue
Block a user