diff --git a/cmd/cosift/community.go b/cmd/cosift/community.go index eef0465..4351492 100644 --- a/cmd/cosift/community.go +++ b/cmd/cosift/community.go @@ -56,7 +56,7 @@ func runCommunity(ctx context.Context, args []string) error { defer client.Close() provider = client } - s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, DataDir: *dir, Backend: *backend, PublicURL: *publicURL, AdminToken: os.Getenv("COSIFT_COMMUNITY_ADMIN_TOKEN"), TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1"}) + s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, DataDir: *dir, Backend: *backend, PublicURL: *publicURL, AdminToken: os.Getenv("COSIFT_COMMUNITY_ADMIN_TOKEN"), TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID")}) if err != nil { return err } diff --git a/deploy/community.env.example b/deploy/community.env.example index f82e50e..646f3ab 100644 --- a/deploy/community.env.example +++ b/deploy/community.env.example @@ -3,6 +3,11 @@ COSIFT_COMMUNITY_ADMIN_TOKEN= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +# Dedicated Cosift Billing Portal: cancel at period end, payment methods and +# invoice history only. Do not reuse another product's default configuration. +COSIFT_STRIPE_PORTAL_CONFIGURATION_ID= +# Test keys cannot mint credits by default. Set to 1 only with an isolated QA DB. +COSIFT_ALLOW_TEST_PAYMENTS=0 # Default: standalone email/password accounts. See docs/SHARED-ACCOUNTS.md. COSIFT_AUTH_MODE=local diff --git a/docs/AGENT-SETUP.md b/docs/AGENT-SETUP.md index 1c49924..c78cbb5 100644 --- a/docs/AGENT-SETUP.md +++ b/docs/AGENT-SETUP.md @@ -7,7 +7,9 @@ feedback, and good public sources from any field are welcome. ## Install and connect -Run the public installer in your own terminal: +Start on the [Cosift website](https://cosift.pilotprotocol.network/): sign in and +open **Agents** for connection instructions. Then run the public installer in your +own terminal, using the same email: ```sh curl -fsSL https://raw.githubusercontent.com/pilot-protocol/cosift-install/v1/install.sh | sh @@ -28,7 +30,10 @@ For a manual connection, use streamable HTTP: Use the token obtained by the installer in your agent's private configuration. Do not paste it into chats, commit it, or send it to another origin. Public -transport still requires a Cosift account token for every MCP tool. +transport still requires a Cosift account token for every MCP tool. Website +login establishes a browser session; it does not automatically configure an MCP +client or expose a copy-token button. The installer verifies your email and +saves the agent token in private client configuration. ## Tools your agent can use @@ -60,8 +65,7 @@ then reload the agent. This does not require uploading a token to the skill file ## Authenticated CLI Use [Cosift v0.2.7 or a newer stable release](https://github.com/pilot-protocol/cosift/releases/latest) -for your OS and architecture. Put the binary on your PATH. After installer login, -the CLI discovers the private `cosift/community-session.json` under +for your OS and architecture; verify its signature using the [signed CLI guide](https://github.com/pilot-protocol/cosift-install/blob/main/docs/CLI-INSTALL.md). Put the binary on your PATH before running the installer so it can connect the CLI. If you installed the binary later, rerun the installer with `--cli` to save the shared session. After that handoff, the CLI discovers the private `cosift/community-session.json` under `$XDG_CONFIG_HOME`, or `~/.config` when unset. An explicit `-session-file` can select another saved session. Keep session files private (mode `0600`). @@ -75,7 +79,8 @@ cosift contribute -server https://cosift.pilotprotocol.network -credits ``` Contributions require login. A CSV can contain one URL per row or a `url`, `urls`, -`webpage`, or `website` column. A request accepts at most 100 URLs and 1 MB. +`webpage`, or `website` column. A request accepts at most 100 URLs and 1 MB; +an account may submit 1,000 new URLs per rolling 24 hours. Put flags before positional URLs. For example: ```csv @@ -90,6 +95,9 @@ filler; useful educational, medical and defensive-security material is allowed. Accepted submissions enter validation; acceptance alone does not mean indexing or a credit award. Check contribution status in the web app. Checks are automated and do not guarantee that every unsafe or poor-quality page is detected. +Production combines URL/network/content checks with semantic review using +`qwen3.5:9b-fp8`. Uncertain or unsupported material stays unverified. Text and +metadata review does not guarantee moderation of images or video. ## Index and embed locally @@ -115,15 +123,39 @@ uses server compute; local embeddings do not bypass validation. ## Allowance and credits -Web, CLI and MCP searches share the account's gateway allowance and credit ledger. -A verified new contribution earns 10 credits once per unique content; rejected, -unverified, duplicate or already-indexed pages earn none. After the free allowance, -an extra successful Search, Answer or Research request spends one credit. Credits -do not bypass mode limits or MCP's separate daily call cap. Check +Web, CLI, and MCP searches share the account's gateway allowance and credit ledger. +Every account gets **60 shared free requests per minute plus 1,000 free credits +per UTC calendar month**, with no subscription required. The monthly grant is +applied once on authenticated use in the current month; inactive past months are +not backfilled. Unused credits carry over. + +A verified new contribution earns **10 credits** once per unique content. +Rejected, unverified, duplicate, or already-indexed pages earn none. After the +shared free requests, an extra successful request spends: + +| Mode | Credits | Hard cap per account | +| --- | --- | --- | +| Search | 1 | 120/minute | +| Answer | 2 | 20/minute | +| Research | 3 | 3/10 minutes | + +Failed backend requests release reservations and refund credits. Credits do not +bypass mode limits or MCP's separate daily call cap. Check [`/api/limits`](https://cosift.pilotprotocol.network/api/limits) and the authenticated credits view for current policy. Respect retry guidance after a rate limit. -Credit purchasing is available only when payments are enabled in the app. -There are no automatic charges or subscriptions. The integration supports a -one-time $5 purchase of 50,000 credits; payment availability is not implied by -having a balance. +## Optional paid plan + +The Billing page offers a **$5/month subscription for 50,000 additional credits +per paid month**. Subscribers keep their 1,000 free monthly credits and can also +buy **$5/50,000-credit one-time top-ups**. Top-ups require a paid current +subscription period. At this price, 1,000 credit-funded requests cost $0.10 for +Search, $0.20 for Answer, or $0.30 for Research. + +Unused credits carry over. Cancel through the billing portal; cancellation does +not remove your remaining earned or purchased balance. Refunds revoke the +corresponding purchased credits. Live purchases stay unavailable until the +operator configures live Stripe billing. An existing credit balance does not +mean payment is enabled. The app displays the payment mode, and test payments +belong only on an isolated test ledger. See [Stripe configuration](STRIPE.md) for +webhook events, portal restrictions, and activation checks. diff --git a/docs/COMMUNITY.md b/docs/COMMUNITY.md index 3fb4880..e071864 100644 --- a/docs/COMMUNITY.md +++ b/docs/COMMUNITY.md @@ -1,34 +1,41 @@ # Community app and contributions -**Shared-account integration update:** see [SHARED-ACCOUNTS.md](SHARED-ACCOUNTS.md) and [its verification record](SHARED-ACCOUNTS-VALIDATION.md). Shared mode now connects to Andrei’s auth/MCP infrastructure. Its live staging and companion-change gates must pass before rollout; earlier standalone checks do not establish shared-mode production readiness. +The production web app, CLI, and agent MCP use the same email-verified Cosift account. See [the connected architecture](SHARED-ACCOUNTS.md), [the rollout record](COMMUNITY-ROLLOUT.md), and [agent setup](AGENT-SETUP.md). Standalone email/password authentication remains available for local installations; production uses the shared email-code service. `cosift community` runs a small web app alongside the search backend. It ships inside the existing binary, with no JavaScript build step. Stripe webhook verification uses the official Go SDK. People can: -- Use Search, Research, or Answer as a guest, or create an account with email and password. All three call the corresponding Cosift endpoint and preserve its retrieval defaults. +- Use Search, Research, or Answer as a guest, or sign in using an email verification code. All three call the corresponding Cosift endpoint and preserve its retrieval defaults. - Choose interests during onboarding and use them as search starting points. - Save, rerun, and remove requests in their own account. Each saved request retains its Search, Research, or Answer mode; older saved searches migrate automatically. -- Submit public webpage URLs in a multiline field or a CSV upload. +- Submit public webpage URLs in a multiline field or a CSV upload after signing in. - See their most recent 200 contributions, indexing status and credit balance. -- Submit the same URLs or CSV files using `cosift contribute`. - -Guests share **one successful Search, Research, Answer, or submission per minute per IP**. -The allowance is persistent and atomic across concurrent requests. Invalid input -and failed backend searches do not consume it. Reading pages or checking the -allowance is free. A submission may contain up to 100 URLs, just like a member -submission. HTTP 429 includes `Retry-After`, `retry_at` and -`retry_after_seconds`. Guest Answer is capped at one per 5 minutes and Research at one per 30 minutes. -Members receive 60 shared free requests/minute, 500 new contributed URLs per rolling 24 hours, and 200 saved searches. -People on a shared public IP share the guest allowance. +- Submit the same URLs or CSV files using `cosift contribute`, optionally extracting text and computing embeddings locally. +- Follow topics across the web and MCP, check article coverage, and record requests for missing articles. Automatic article authoring is still in development. +- View monthly credits and manage an optional paid subscription or subscriber top-up when live payments are configured. + +Guests share **one successful Search, Research, or Answer request per minute per IP**. +Guest Answer is additionally capped at one per 5 minutes and Research at one per +30 minutes. Reading pages or checking the allowance is free. Invalid input and +failed backend requests do not consume the allowance. HTTP 429 includes +`Retry-After`, `retry_at`, and `retry_after_seconds`. People on a shared public IP +share this persistent, atomic guest allowance. **Contributions require login.** + +Members receive **60 shared free requests per minute plus 1,000 free credits each +UTC calendar month**, 1,000 new contributed URLs per rolling 24 hours, and 200 +saved searches. The monthly grant is applied once per account for the current +month on authenticated use; it does not accumulate grants for inactive past +months. All unused credits carry over. Mode caps and credit costs are described +below. ## Start the services Build the current code: ```sh -go build -o cosift ./cmd/cosift +GOWORK=off go build -o cosift ./cmd/cosift ``` Use an existing Pebble backend with its in-process crawler enabled, an embedding @@ -79,9 +86,9 @@ The receiving backend performs guarded direct indexing through a separate crawle `pending` appears as **Checking**; `rejected` and `unverified` stay outside indexing. `indexed` means the backend acknowledged an indexable document. Older queue acknowledgements remain `queued`. An acknowledgement does not guarantee successful embedding of every passage; standard crawler embedding errors still apply. -Checks reject adult material, malware/phishing, graphic violent abuse, extremist promotion and serious illegal harm while allowing neutral education, medicine, news and defensive security research. Malformed decisions cannot authorize indexing. These are automated URL/text checks, not a guarantee or antivirus scan. Images/video are not visually classified, and pages can change between validation and indexing. Unreadable or inconclusive pages remain unverified; unavailable services retry. +Checks reject adult material, malware/phishing, graphic violent abuse, extremist promotion and serious illegal harm while allowing neutral education, medicine, news and defensive security research. Malformed decisions cannot authorize indexing. Production combines URL/network/content checks with semantic review using `qwen3.5:9b-fp8`. These automated text and metadata checks do not guarantee image/video moderation or act as an antivirus scan. Unreadable, unsupported, or inconclusive pages remain unverified; unavailable services retry. The backend binds approval to the checked content and rejects a contribution when the fetched content no longer matches its approval. No indexing or credits occur before approval. -## Local indexing, credits and future payments +## Local indexing and credits Authenticated CLI users can fetch, parse, chunk and embed webpages locally, save them in their local SQLite index, and submit text, metadata and vectors: @@ -105,19 +112,42 @@ If chunk boundaries differ, the backend computes the missing vectors normally. A newly indexed member contribution earns **10 credits**. Rewards are globally idempotent by content hash, so retrying or mirroring the same content cannot earn multiple rewards. Existing corpus URLs and rejected/unverified submissions do -not earn credits. Guests do not earn credits. After the shared free 60 requests/minute, -each additional Search, Answer or Research costs **1 credit**, with a ceiling of -120 Search/minute, 20 Answer/minute and 3 Research/10 minutes per account. Credits cannot bypass these hard caps. Mode caps are persisted across restarts and shared by all sessions and native endpoint aliases. Backend failures refund the debit. Credits are -spent rather than granting permanent tiers. `GET /api/credits` returns the -balance and policy; the web app displays the balance. - -A one-time **$5 Stripe Checkout purchases 50,000 credits** ($0.10 per 1,000 -extra requests). Payments remain disabled until `STRIPE_SECRET_KEY` and -`STRIPE_WEBHOOK_SECRET` are configured. Signed webhooks grant credits exactly -once after payment; refunds revoke the corresponding purchased credits. There -are no subscriptions or automatic charges. See [Stripe setup and validation](STRIPE.md). - -The candidate Caddy configuration routes public `/search`, `/answer` and `/research` +not earn credits. A submission acknowledgement is not a reward: the backend must +confirm approved new content was indexed. + +After the shared free 60 requests/minute, successful extra requests spend credits: + +| Mode | Credits per extra request | Hard cap per account | +| --- | --- | --- | +| Search | 1 | 120/minute | +| Answer | 2 | 20/minute | +| Research | 3 | 3/10 minutes | + +The monthly 1,000 free credits, contribution rewards, and purchased credits use +one balance. They are spent, rather than granting permanent rate-limit tiers. +Credits cannot bypass hard caps. Mode limits persist across restarts and are +shared by sessions and public endpoint aliases. Failed backend requests release +reservations and refund debits. `GET /api/credits` returns the balance, current UTC +month's free/earned/purchased/spent activity, and policy. MCP search uses this same +gateway ledger; the MCP service also has a separate daily tool-call cap. + +## Plans and payments + +The **Free plan requires no subscription** and includes the monthly 1,000 credits +and 60 free requests/minute. An optional **$5/month subscription adds 50,000 credits +per paid month** and unlocks one-time **$5/50,000-credit top-ups**. Subscribers still +receive the free monthly credits. Unused credits carry over; cancellation does +not erase remaining earned or purchased credits. Refunds revoke the corresponding +purchased credits. + +At this pack price, 1,000 credit-funded Search requests cost $0.10, Answer $0.20, +and Research $0.30. Rate caps still apply. Live purchases remain unavailable until +live Stripe keys and verified webhooks are configured. Browser redirects cannot +grant credits: signed paid invoices grant subscription credits, and verified paid +Checkout events grant top-ups. See [Stripe setup and validation](STRIPE.md), +including the restricted billing portal and isolated test-mode requirements. + +The production Caddy configuration routes public `/search`, `/answer` and `/research` through the same portal policy as `/api/*`. These public aliases support GET with `q`; POST and advanced native engine parameters are not supported on the public portal. Unlisted native routes return 404 to prevent quota bypasses. The internal @@ -133,52 +163,50 @@ usage can consume 60 free requests and then 60 credit-funded requests per minute ## CLI and CSV -Guest contribution (no account or token needed): - -```sh -./cosift contribute -server https://community.example.com -guest \ - https://go.dev/doc/ https://www.rust-lang.org/learn - -./cosift contribute -server https://community.example.com -guest -csv sources.csv -``` - -For member submissions, create an account in the web app and set `COSIFT_EMAIL` -and `COSIFT_PASSWORD` in your shell environment. Keep the password out of command -arguments and shell history. Omit `-guest`: +Install the signed CLI and run the [shared-account installer](AGENT-SETUP.md). +When a compatible CLI is already on PATH, the installer can save its session +without asking you to copy a token from agent configuration. The CLI discovers +`$XDG_CONFIG_HOME/cosift/community-session.json`, or +`~/.config/cosift/community-session.json` when `XDG_CONFIG_HOME` is unset. ```sh -./cosift contribute -server https://community.example.com -csv sources.csv +cosift request -query "Go modules" +cosift contribute -csv sources.csv +cosift contribute https://go.dev/doc/ https://www.rust-lang.org/learn +cosift contribute -credits ``` -For repeated commands, explicitly save a reusable session. The file contains -an opaque session token bound to this exact server origin, with mode 0600; -it never stores your password. Use a private directory outside the repository: +Guest retrieval is explicit when a saved account is present: ```sh -mkdir -p "$HOME/.config/cosift" -chmod 700 "$HOME/.config/cosift" -export COSIFT_SESSION_FILE="$HOME/.config/cosift/community-session.json" -./cosift login -server https://community.example.com -unset COSIFT_PASSWORD -./cosift request -server https://community.example.com -query "Go modules" -./cosift contribute -server https://community.example.com -csv sources.csv -./cosift logout -server https://community.example.com +cosift request -server https://cosift.pilotprotocol.network -guest -query "Go modules" ``` -`-session-file FILE` overrides `COSIFT_SESSION_FILE`. Login refuses to overwrite -an existing file; logout revokes this CLI session and deletes the file. An -expired/revoked session requires another login. A failed logout keeps the file -for retry unless the server confirms the session is already invalid. Browser -sessions are independent. Never commit, share, or upload the session file. - -Without credentials or a session file, the CLI defaults to guest access. -`-guest` explicitly ignores both. `-email` overrides `COSIFT_EMAIL`; `-csv -` -reads stdin. Flags precede positional URLs. Without a saved session, email/password -commands use a temporary login and revoke it afterward; the password-authentication -throttle remains 10 attempts per account/minute and 30 per IP/minute. Use a saved -session to access the full retrieval allowance without repeated password logins. -Invalid session files fail before requests; revoked cookies return 401 rather -than silently submitting a member's work as an uncredited guest. +Contributions always require an account. An existing shared token may be supplied +through `COSIFT_TOKEN`; keep its value out of command arguments, shell history, +logs, and repositories. To persist such a token, use `cosift login -server URL +-session-file FILE` in a private directory. The CLI validates the token at that +origin before writing the session file. See [signed CLI installation and session +setup](https://github.com/pilot-protocol/cosift-install/blob/main/docs/CLI-INSTALL.md). + +`-session-file FILE` overrides `COSIFT_SESSION_FILE`; explicit token, password, +server, and guest choices are never replaced by an implicit saved session. The +session is origin-bound, mode 0600, and contains no password. Login refuses to +overwrite an existing file. A saved origin cannot override an explicit `-server`. +Invalid files and expired/revoked sessions fail instead of silently changing to +guest access. `-guest` ignores saved credentials, and `-csv -` reads stdin. Flags +precede positional URLs. + +`cosift logout` revokes the selected token and deletes its saved file. In shared +mode, revoking an installer-issued token also signs out agents using that same +token; separately issued browser tokens remain independent. A failed logout +keeps the file for retry unless the service confirms it is already invalid. +Never commit, share, or upload a session file. + +Local standalone installations can instead use `COSIFT_EMAIL` and +`COSIFT_PASSWORD`, or an explicitly selected session file. Temporary password +logins are revoked after each command; use a saved session for repeated requests. +Those local password routes are disabled in shared production mode. CSV accepts a single headerless URL column, or a column called `url`, `urls`, `webpage`, or `website`. Other columns are ignored when a recognized header is @@ -190,10 +218,11 @@ Go documentation,https://go.dev/doc/ "Rust, getting started",https://www.rust-lang.org/learn ``` -Limits: 100 rows/URLs per request, 1 MB request body, 2,048 characters per URL. +Limits: 100 rows/URLs per request, 1 MB request body, 2,048 characters per URL, and +1,000 new contributed URLs per account per rolling 24 hours. Duplicate URLs are normalized and collapsed; members also receive a duplicate count for URLs they previously contributed. An invalid row rejects the whole -batch without saving partial input or using a guest allowance. +batch without saving partial input. Duplicates do not consume the new-URL allowance. ## API @@ -204,8 +233,11 @@ enabled. CLI clients may omit Origin. Login returns an HttpOnly session cookie. | Method and path | Access | Body / behavior | | --- | --- | --- | -| `POST /api/register` | Public | `{email,password,name}`; creates account and session | -| `POST /api/login` | Public | `{email,password}`; creates session | +| `GET /api/auth/config` | Public | Reports whether shared email-code authentication is enabled | +| `POST /api/auth/start` | Public | `{email}`; starts email verification | +| `POST /api/auth/verify` | Public | `{request_id,code}`; verifies email and establishes a session | +| `POST /api/register` | Local mode only | `{email,password,name}`; creates account and session | +| `POST /api/login` | Local mode only | `{email,password}`; creates session | | `POST /api/logout` | Member | Revokes current session | | `GET /api/me` | Member | Profile and interests | | `PUT /api/interests` | Member | `{interests:[...]}`; completes onboarding, including an empty list | @@ -216,11 +248,13 @@ enabled. CLI clients may omit Origin. Login returns an HttpOnly session cookie. | `GET /api/saved` | Member | Own saved searches | | `POST /api/saved` | Member | `{query,mode}`; mode defaults to `search`; idempotent per account/query/mode | | `DELETE /api/saved/{id}` | Member | Removes an owned saved search | -| `GET /api/credits` | Member | Credit balance, allowance, pack price and payment availability | -| `POST /api/payments/checkout` | Member | `{idempotency_key}`; returns a hosted Stripe checkout URL | -| `POST /api/payments/webhook` | Stripe signature | Paid-session fulfillment and refund reconciliation | +| `GET /api/credits` | Member | Balance, monthly activity, weighted request costs, subscription state, top-up eligibility, and payment mode | +| `POST /api/payments/checkout` | Member | `{kind:"subscription"\|"topup",idempotency_key}`; returns a hosted Stripe Checkout URL | +| `POST /api/payments/portal` | Member | `{}`; returns an existing subscriber's restricted billing portal URL | +| `POST /api/payments/webhook` | Stripe signature | Paid invoice/top-up fulfillment, subscription state, and refund reconciliation | +| `POST /api/shared` | Member, shared mode | Allowlisted topic, article lookup, or article request tool; search uses the gateway retrieval routes | | `GET /api/submissions` | Member | Own recent contributions | -| `POST /api/submissions` | Guest or member | `{urls:[...]}`, authenticated `{artifacts:[...]}`, or multipart CSV; returns HTTP 202 | +| `POST /api/submissions` | Member | `{urls:[...]}`, authenticated `{artifacts:[...]}`, or multipart CSV; returns HTTP 202 | ## Account data and operational scope @@ -230,27 +264,30 @@ and SQLite uses WAL. Back up the entire directory while the community service is stopped, or use a SQLite-consistent backup tool. Run one dispatcher process per community database. -Passwords use PBKDF2-HMAC-SHA256 with independent random salts and 600,000 -iterations. Only hashes of session tokens are stored; sessions expire after 30 -days. Account queries always scope saved searches and submissions to the session -owner. Guest allowance records use a salted IP hash, not a raw IP; expired -records are removed as allowances are reserved. Guest submissions have no -account history and are not retroactively attached after signup. - -Interests provide clickable search suggestions; they do not alter retrieval -ranking. Queries still go to the configured backend, whose logging policy -applies. The app has no automatic email sending, email verification, or -self-service password reset in this first version. It does not implement the -older distributed-compute contribution-network proposal. +Shared production accounts and tokens are verified against the auth service's +Firestore and Secret Manager records. The gateway binds its local database to +the shared project/database namespace and maps the verified UID to one account, +preserving its saves and credits. Email identity comes from the shared verifier. +Local standalone passwords use PBKDF2-HMAC-SHA256 with independent random salts +and 600,000 iterations; local sessions store only token hashes and expire after +30 days. Shared token lifetimes and revocation are controlled by the auth service. + +Account queries scope saved searches and submissions to their owner. Guest +allowance records use a salted IP hash rather than a raw IP. Interests provide +clickable search suggestions and shared topic follows; they do not alter retrieval +ranking. Queries go to the configured backend, whose logging policy applies. +Shared production verifies email with a code; standalone mode has no email +verification or self-service password reset. Automatic article authoring and the +distributed contributor network remain separate, unfinished work. Search returns ordinary result cards; Answer and Research render the returned answer, source citations, and research plan. Their synchronous backend timeout is three minutes; configure reverse proxies to allow at least four minutes. Missing LLM configuration is reported clearly and does not consume the guest allowance. -Tests cover account isolation, mode-aware saved-request migration, endpoint parity, session expiry/logout, CSV atomicity, durable prevalidation/delivery, strict moderation decisions, guest cooldown/restart/concurrency, trusted proxies, public-network egress, and CLI member/guest submissions. No production deployment is performed +Tests cover account isolation, mode-aware saved-request migration, endpoint parity, session expiry/logout, CSV atomicity, durable prevalidation/delivery, strict moderation decisions, guest cooldown/restart/concurrency, trusted proxies, public-network egress, and authenticated CLI submissions/guest retrieval. No production deployment is performed by building or running the app locally. ## Production service and release -See [the operator rollout and rollback plan](COMMUNITY-ROLLOUT.md) before deployment. The current user instruction is to keep these changes in review; do not deploy automatically. +See [the operator rollout and rollback record](COMMUNITY-ROLLOUT.md) for the connected production rollout. A release is not ready merely because it builds: verify shared login, source retrieval, contribution receipts, quota persistence, and backups after an upgrade. Billing additionally needs the live Stripe setup below. `deploy/systemd/cosift-community.service` runs the portal on loopback port 7780. Create its private data directory before starting it and supply @@ -267,7 +304,7 @@ rollout also requires preserving the old binary, backend config and Caddy config Signed release assets include Linux ARM64/AMD64, macOS ARM64/AMD64 and Windows AMD64. Install the matching binary and use the same public server URL for both -`contribute` and `request`. Credit purchases are available only when Stripe is configured. +`contribute` and `request`. Subscriptions and subscriber top-ups are available only when live Stripe billing is configured and verified. ## Public entry and operations visibility diff --git a/docs/STRIPE.md b/docs/STRIPE.md index 24d5fde..262679f 100644 --- a/docs/STRIPE.md +++ b/docs/STRIPE.md @@ -1,118 +1,162 @@ -# Stripe credit purchases - -A single one-time pack: **US$5 buys 50,000 credits**. One credit pays for one -additional Search, Answer or Research request after the shared free allowance. -That is **$0.10 per 1,000 paid requests**. Buying credits does not bypass the -existing mode caps. At the default Search limit of 120/minute, an account can -use 60 shared free requests and then 60 credit-funded searches in that minute. -Earned credits and purchased credits use the same balance. -There are no subscriptions, automatic top-ups, recurring charges, saved-card -billing, or Stripe product/price IDs to provision. - -## Pricing basis, checked 2026-09-16 - -[Parallel's pricing](https://docs.parallel.ai/getting-started/pricing) lists -`turbo`/`fast` Search at $1 per 1,000 requests (10 results), and `basic`/`advanced` -at $5. [Exa's pricing](https://exa.ai/pricing) lists Search at $7 per 1,000 -requests (up to 10 results), Answer at $5, and Deep Search at $12–15. - -Cosift's $0.10 rate is one tenth of Parallel's cheapest listed search rate and -less than one tenth of Exa's search rate. This is a posted request-price -comparison, not a claim of equivalent coverage, quality, latency or research -capabilities. Cosift applies the same simple credit price to all three modes; -free requests and credits earned by contributing lower a user's cash spend. -Competitor prices are a dated comparison, not dynamically synchronized pricing. - -## Configuration (after review and deployment approval) - -Purchases are disabled until both environment variables are configured: - -- `STRIPE_SECRET_KEY`: a Stripe secret API key (`sk_test_...` for testing, - `sk_live_...` for live charges; suitable restricted keys are also supported). -- `STRIPE_WEBHOOK_SECRET`: the `whsec_...` signing secret for this app's webhook - endpoint in the same Stripe test/live environment. - -Put these in the community service's root-owned environment file, alongside -`COSIFT_COMMUNITY_ADMIN_TOKEN`. Do not put secret values in command arguments, -frontend code, logs or git. `deploy/community.env.example` contains empty fields. -Blank or partial configuration leaves the rest of the app usable and hides the -purchase button; `GET /api/credits` reports `payments_enabled: false`. - -Create a **snapshot event** webhook endpoint at: - -`https://YOUR-COMMUNITY-HOST/api/payments/webhook` - -Subscribe to `checkout.session.completed`, -`checkout.session.async_payment_succeeded`, and `charge.refunded`. Use the -endpoint's signing secret, which is separate from the API key. Stripe-hosted -Checkout collects the card details; Cosift never handles card numbers. It uses -USD and card payments with price localization disabled. No publishable key or -Stripe.js is needed. The API request pins the version supplied by the installed -Stripe Go SDK. Thin-event destinations are not supported by this handler. +# Stripe subscriptions and credit top-ups + +Every account has a **Free plan: 1,000 credits per UTC calendar month plus 60 +shared free requests/minute**, with no subscription required. The optional +**$5/month paid plan adds 50,000 credits per paid month** and permits **one-time +$5/50,000-credit top-ups**. Subscribers still receive their free monthly credits. +Unused free, earned, and purchased credits carry over. + +After the free request allowance, Search costs 1 credit, Answer 2, and Research 3. +At this pack price, 1,000 paid requests cost $0.10 for Search, $0.20 for Answer, or +$0.30 for Research. Credits do not bypass request caps. There are no automatic +top-ups; the subscription itself renews monthly until canceled. + +## Live configuration + +Live billing needs: + +- `STRIPE_SECRET_KEY`: a live Stripe secret API key, or a suitable live restricted + key with access to the Checkout, subscription, invoice, charge, and billing + portal operations used by this service. +- `STRIPE_WEBHOOK_SECRET`: the `whsec_...` signing secret for this service's live + snapshot webhook endpoint. +- `COSIFT_STRIPE_PORTAL_CONFIGURATION_ID`: a dedicated `bpc_...` billing portal + configuration allowing cancellation, payment-method updates, and invoices. + Disable subscription price/product changes, quantity changes, coupons, and + other plan changes; Cosift supports one fixed monthly plan. + +Store values in the community service's root-owned environment file. Never put +secrets in frontend code, git, logs, shell arguments, or a public issue. No +publishable key or Stripe.js is needed: Stripe-hosted Checkout collects payment +details. The server owns the USD price, credit amount, and monthly recurrence; +no product or price ID needs to be provisioned manually. + +Without a usable API key and matching webhook setup, leave purchases unavailable. +`GET /api/credits` reports `payments_enabled` and `payment_mode` (`live`, `test`, or +`unavailable`). The billing portal also requires its dedicated configuration ID. +A nonempty configuration is not proof that a real payment and webhook work. + +Create a **snapshot event** webhook at the exact production URL: + +```text +https://cosift.pilotprotocol.network/api/payments/webhook +``` + +Enable these eight events: + +| Event | Purpose | +| --- | --- | +| `checkout.session.completed` | Bind a completed subscription checkout or fulfill a paid top-up | +| `checkout.session.async_payment_succeeded` | Fulfill delayed paid Checkout sessions | +| `invoice.paid` | Grant one monthly subscription credit allocation per paid invoice | +| `invoice.payment_failed` | Reconcile payment/subscription status | +| `customer.subscription.created` | Reconcile subscription identity and state | +| `customer.subscription.updated` | Reconcile renewals and cancellation state | +| `customer.subscription.deleted` | Reconcile cancellation without deleting the credit balance | +| `charge.refunded` | Reverse the corresponding purchased credits | + +Use this endpoint's signing secret from the same Stripe environment as the API +key. Thin-event destinations are unsupported. The handler verifies the signature +against the raw request body. The exact webhook path is exempt from browser CSRF +headers; other payment mutations still require the normal authenticated client +and origin checks. API object retrieval uses the installed official Stripe Go +SDK's API version. See Stripe's [hosted Checkout guide](https://docs.stripe.com/checkout/quickstart), -[fulfillment guide](https://docs.stripe.com/checkout/fulfillment), +[subscription lifecycle](https://docs.stripe.com/billing/subscriptions/webhooks), +[billing portal configuration](https://docs.stripe.com/customer-management/configure-portal), [signature verification](https://docs.stripe.com/webhooks/signature), and [idempotent requests](https://docs.stripe.com/api/idempotent_requests). -## Payment flow - -1. A signed-in member clicks “Buy 50,000 credits · $5.00”. -2. `POST /api/payments/checkout` accepts only an `idempotency_key` (16–64 letters, - digits, underscores or hyphens). The amount, currency and credit quantity - come from the server, never the browser. A stored order and Stripe's - idempotency key keep retries from creating another checkout session. -3. The browser follows the validated `https://checkout.stripe.com/` URL. -4. A verified Stripe webhook must report a paid, completed, one-time Checkout - Session whose owner, order, session, amount, currency and test/live mode - match. The order, ledger credit and event receipt commit in one transaction. - Deduplication uses the session ID as well as the event receipt, so different - notifications of the same payment cannot add credits twice. -5. The return page refreshes the balance. It cannot grant credits: adding - `?payment=success` to a URL has no financial effect. If webhook delivery is - delayed, credits appear after it succeeds. The balance is also available to - the CLI through `cosift contribute -credits`. - -Payments start only after the member completes Stripe Checkout. An abandoned -checkout does not grant credits or initiate an automatic retry charge. Failed -API requests can be retried with the same idempotency key. After 23 hours, reload -the page to start a new checkout; the app will not reuse Stripe's expired -idempotency window. Missing/mismatched local orders or storage errors return a -retryable failure to Stripe rather than silently acknowledging an unfulfilled -purchase. Ignore unrelated Stripe events with no Cosift order metadata. - -## Refunds and operations - -Issue refunds manually in the Stripe Dashboard. Signed `charge.refunded` -notifications revoke the corresponding fraction of purchased credits. They use -cumulative refunded cents, so duplicates, partial refunds and notifications -arriving out of order cannot revoke twice. A refund received before fulfillment -returns a retryable error. A late duplicate payment event cannot restore refunded -credits. A member who has already spent refunded credits may have a negative -balance; further credit-funded requests require replenishing it. - -Back up payment orders, the ledger and webhook receipts together with the account -database. Monitor non-2xx webhook deliveries and retry them from Stripe after -fixing configuration or storage issues. This basic integration does not automate -chargeback/dispute handling, tax calculation, or subscription management; those -remain operator responsibilities. Tax and receipt settings should be reviewed in -the merchant's Stripe account before enabling live purchases. +## Browser and API flow + +1. A member chooses **Subscribe** or, with a paid current subscription period, + **Top up** on Billing. +2. `POST /api/payments/checkout` accepts + `{ "kind": "subscription", "idempotency_key": "..." }` or + `{ "kind": "topup", "idempotency_key": "..." }`. The key is 16–64 letters, + digits, underscores, or hyphens. Prices and credit quantities come only from + the server. Persisted orders and Stripe idempotency prevent duplicate charges + from retrying the same checkout. +3. The browser follows a validated `https://checkout.stripe.com/` URL. Subscription + Checkout establishes a recurring monthly agreement; top-up Checkout is a + one-time payment. Abandoning Checkout grants no credits. +4. Signed webhooks reconcile the stored owner, order, Stripe identity, amount, + currency, and test/live environment. **Only a verified `invoice.paid` grants + subscription credits.** Subscription Checkout completion never grants them. + A verified paid top-up Checkout event grants its pack once. Duplicate events + must not duplicate credits. +5. The return page refreshes the account. A URL such as `?payment=success` has no + financial authority. If delivery is delayed, the balance changes after a valid + webhook succeeds. The same balance is visible with `cosift contribute -credits`. + +`GET /api/credits` includes: + +- `subscription`: `status`, `active`, `cancel_at_period_end`, and + `current_period_end` (Unix seconds). +- `can_top_up` and `portal_available`. +- `subscription_plan`: server-owned `amount_cents`, `currency`, `credits`, and + `interval` (`month`), alongside the existing one-time `credit_pack`. +- `monthly_free_credits`, monthly activity, and `request_credit_costs`. + +Top-up eligibility requires an active subscription and a paid current period; +merely starting Checkout or holding a credit balance is insufficient. The +backend rechecks eligibility when a top-up starts. A member can use +`POST /api/payments/portal` with `{}` to obtain a validated +`https://billing.stripe.com/` URL for their own customer record. No customer ID +is accepted from the browser. + +Retry failed checkout requests using the same idempotency key. The app will not +reuse an expired Stripe idempotency window: after 23 hours, begin a new checkout. +A missing local order, ownership mismatch, or storage failure returns a retryable +failure to Stripe rather than silently claiming fulfillment. Unrelated Stripe +objects without Cosift ownership are not credited. + +## Cancellation, refunds, and operations + +Cancellation changes the recurring agreement; it does not erase unused free, +earned, subscription, or top-up credits. Existing credits remain subject to the +same request caps. New top-ups require paid subscription access, even if the +account still has a positive balance. + +Issue refunds in the Stripe Dashboard. Signed refund notifications reverse the +corresponding purchased credit allocation. Duplicate or out-of-order events must +not reverse it twice, and a late duplicate payment event must not restore refunded +credits. Refunding credits already spent may leave a negative balance; further +credit-funded requests need sufficient credit. Cancellation alone is not a refund. + +Back up payment orders, subscription/invoice records, the credit ledger, and +webhook receipts together with the account database. Monitor non-2xx deliveries +and replay them from Stripe after correcting configuration or storage problems. +The basic integration does not automate disputes, tax calculation, prorations, +plan changes, or metered billing. Keep the restricted portal configuration in +place and review merchant tax/receipt settings before live activation. ## Validation before live activation -Automated tests use a fake Stripe HTTP transport and signatures generated by the -official SDK. They cover server-owned pricing, missing configuration, login/CSRF, -idempotent checkout, invalid/old signatures, unpaid or mismatched events, -concurrent/repeated fulfillment, restart persistence, transaction rollback and -partial/full/out-of-order refunds. They do not move money or contact Stripe. - -Use an isolated staging account database for Stripe test mode; never put test -credentials on the production credit ledger. Test purchases grant test credits -in that database. Checkout idempotency is separated by test/live mode. - -After keys are supplied, use Stripe **test mode** to complete a hosted Checkout, -confirm one 50,000-credit grant, replay its event, cancel another checkout, and -perform a partial then full refund. Confirm the dashboard's delivery status and -Cosift's ledger balance. This credentialed end-to-end check is still required; -local tests do not claim it has happened. Production remains unchanged until a -new explicit deployment instruction. +Test keys are rejected by default. Only an isolated QA service with a separate +account database may set `COSIFT_ALLOW_TEST_PAYMENTS=1` and use test API/webhook +credentials. Never enable public test-card purchases on the production ledger. +The API and UI must label the isolated test payment mode explicitly. + +Automated tests use controlled Stripe responses and signed events to check +server-owned pricing, account isolation, login/CSRF, checkout idempotency, +subscription gating, paid-invoice fulfillment, duplicate/out-of-order handling, +and refund behavior. They do not prove that merchant configuration or external +webhook delivery works. + +Before activating live billing, use the isolated test environment to: + +1. Complete a subscription Checkout and confirm exactly one 50,000-credit grant + from its paid invoice. Replaying Checkout or invoice events must not add more. +2. Verify a free account cannot buy top-ups, a paid subscriber can, and a paid + top-up adds its credits once. +3. Exercise a renewal, failed payment, cancellation, and the restricted portal. + Confirm the free monthly allowance and remaining balance survive cancellation. +4. Exercise partial/full refunds and duplicate deliveries; inspect the balance + and Stripe delivery status. +5. Confirm public production still reports unavailable until live credentials, + the correct live webhook, and the dedicated portal configuration are ready. + +Record actual hosted Checkout and webhook outcomes separately from local tests. +Do not describe payment activation as verified merely because keys were supplied. diff --git a/internal/community/credits.go b/internal/community/credits.go index 1b32bf4..8dba9ed 100644 --- a/internal/community/credits.go +++ b/internal/community/credits.go @@ -44,7 +44,16 @@ FROM credit_ledger WHERE user_id=?`, start.Unix(), end.Unix(), start.Unix(), end problem(w, 500, "credits unavailable") return } - respond(w, 200, map[string]any{"balance": balance, "monthly_free_credits": monthlyFreeCredits, "monthly": map[string]any{"month": start.Format("2006-01"), "starts_at": start.Format(time.RFC3339), "timezone": "UTC", "free": free, "earned": earned, "purchased": purchased, "spent": spent}, "free_requests_per_minute": s.cfg.MemberFreeRPM, "limits": s.limitPolicy(), "extra_request_cost": 1, "request_credit_costs": requestCreditCosts(), "verified_contribution_reward": contributionReward, "payments_enabled": s.paymentsEnabled(), "payment_mode": s.paymentMode(), "credit_pack": creditPack()}) + billing, err := s.subscriptionFields(r.Context(), u.ID) + if err != nil { + problem(w, 503, "billing status unavailable") + return + } + out := map[string]any{"balance": balance, "monthly_free_credits": monthlyFreeCredits, "monthly": map[string]any{"month": start.Format("2006-01"), "starts_at": start.Format(time.RFC3339), "timezone": "UTC", "free": free, "earned": earned, "purchased": purchased, "spent": spent}, "free_requests_per_minute": s.cfg.MemberFreeRPM, "limits": s.limitPolicy(), "extra_request_cost": 1, "request_credit_costs": requestCreditCosts(), "verified_contribution_reward": contributionReward, "payments_enabled": s.paymentsEnabled(), "payment_mode": s.paymentMode(), "credit_pack": creditPack()} + for key, value := range billing { + out[key] = value + } + respond(w, 200, out) } // reserveCredit performs a conditional debit atomically. Refunds have an diff --git a/internal/community/payments.go b/internal/community/payments.go index e67e239..c3533d6 100644 --- a/internal/community/payments.go +++ b/internal/community/payments.go @@ -65,6 +65,7 @@ func (s *Server) checkout(w http.ResponseWriter, r *http.Request, u User) { } var in struct { IdempotencyKey string `json:"idempotency_key"` + Kind string `json:"kind"` } if decode(r, &in) != nil || !checkoutKeyPattern.MatchString(in.IdempotencyKey) { problem(w, 400, "a checkout idempotency key of 16–64 letters, digits, underscores or hyphens is required") @@ -75,8 +76,25 @@ func (s *Server) checkout(w http.ResponseWriter, r *http.Request, u User) { problem(w, 429, "too many checkout attempts; try again in a minute") return } + if in.Kind == "subscription" { + s.checkoutSubscription(w, r, u, in.IdempotencyKey) + return + } + if in.Kind != "" && in.Kind != "topup" { + problem(w, 400, "checkout kind must be subscription or topup") + return + } + subscriber, err := s.requireTopup(r.Context(), u.ID) + if err != nil { + if errors.Is(err, errSubscriptionRequired) { + problem(w, 403, errSubscriptionRequired.Error()) + } else { + problem(w, 502, "could not verify subscription; retry") + } + return + } id := "checkout:" + tokenHash(strconv.FormatBool(s.stripeLive())+":"+u.ID+":"+in.IdempotencyKey) - _, err := s.db.ExecContext(r.Context(), `INSERT INTO payment_checkouts(id,user_id,amount_cents,credits,currency,created_at) VALUES(?,?,?,?,'usd',?) ON CONFLICT(id) DO NOTHING`, id, u.ID, packAmountCents, packCredits, time.Now().Unix()) + _, err = s.db.ExecContext(r.Context(), `INSERT INTO payment_checkouts(id,user_id,amount_cents,credits,currency,created_at) VALUES(?,?,?,?,'usd',?) ON CONFLICT(id) DO NOTHING`, id, u.ID, packAmountCents, packCredits, time.Now().Unix()) if err != nil { problem(w, 500, "could not prepare checkout") return @@ -97,7 +115,8 @@ func (s *Server) checkout(w http.ResponseWriter, r *http.Request, u User) { } costs := requestCreditCosts() form := url.Values{ - "mode": {"payment"}, "payment_method_types[0]": {"card"}, + "customer": {subscriber.CustomerID}, + "mode": {"payment"}, "payment_method_types[0]": {"card"}, "adaptive_pricing[enabled]": {"false"}, "payment_intent_data[metadata][cosift_order_id]": {id}, "client_reference_id": {u.ID}, "metadata[cosift_order_id]": {id}, @@ -172,9 +191,24 @@ func (s *Server) stripeWebhook(w http.ResponseWriter, r *http.Request) { } switch event.Type { case "checkout.session.completed", "checkout.session.async_payment_succeeded": - err = s.fulfillCheckout(r.Context(), event) + var session struct { + Mode string `json:"mode"` + } + if json.Unmarshal(event.Data.Object, &session) != nil { + err = errors.New("invalid checkout event") + } else if session.Mode == "subscription" { + err = s.completeSubscriptionCheckout(r.Context(), event) + } else { + err = s.fulfillCheckout(r.Context(), event) + } case "charge.refunded": err = s.refundCheckout(r.Context(), event) + case "invoice.paid": + err = s.invoiceEvent(r.Context(), event, true) + case "invoice.payment_failed": + err = s.invoiceEvent(r.Context(), event, false) + case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted": + err = s.subscriptionEvent(r.Context(), event) default: respond(w, 200, map[string]bool{"received": true}) return @@ -254,7 +288,7 @@ func (s *Server) refundCheckout(ctx context.Context, event stripeEvent) error { return err } if charge.Metadata["cosift_order_id"] == "" { - return nil + return s.refundSubscriptionCharge(ctx, event) } if charge.LiveMode != s.stripeLive() || charge.PaymentIntent == "" || charge.Refunded < 0 || charge.Refunded > charge.Amount { return errors.New("invalid refund") diff --git a/internal/community/payments_test.go b/internal/community/payments_test.go index be8610c..e93ac4f 100644 --- a/internal/community/payments_test.go +++ b/internal/community/payments_test.go @@ -27,7 +27,7 @@ func stripeTestServer(t *testing.T) (*Server, *http.Cookie, User) { if err := json.Unmarshal(request(t, s, "GET", "/api/me", nil, cookie).Body.Bytes(), &u); err != nil { t.Fatal(err) } - s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + s.paymentClient.Transport = paymentTestTransport(s, func(r *http.Request) (*http.Response, error) { t.Error("unexpected Stripe network call") return nil, fmt.Errorf("network disabled") }) @@ -65,6 +65,7 @@ func deliver(s *Server, id, kind string, object any) *httptest.ResponseRecorder func TestStripeCheckoutConfigurationAuthAndServerPrice(t *testing.T) { s, cookie, u := stripeTestServer(t) + seedTopupSubscription(t, s, u) s.cfg.StripeWebhookSecret = "" expect(t, request(t, s, "POST", "/api/payments/checkout", map[string]string{"idempotency_key": "test-checkout-key-0001"}, cookie), 503) if s.paymentsEnabled() { @@ -75,7 +76,7 @@ func TestStripeCheckoutConfigurationAuthAndServerPrice(t *testing.T) { expect(t, request(t, s, "POST", "/api/payments/checkout", map[string]any{"idempotency_key": "test-checkout-key-0001", "amount": 1, "credits": 999999}, cookie), 400) expect(t, request(t, s, "POST", "/api/payments/checkout", map[string]string{"idempotency_key": "short"}, cookie), 400) calls := 0 - s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + s.paymentClient.Transport = paymentTestTransport(s, func(r *http.Request) (*http.Response, error) { calls++ if r.URL.String() != "https://api.stripe.com/v1/checkout/sessions" || r.Method != "POST" { t.Error("wrong Stripe endpoint") @@ -222,9 +223,10 @@ func TestStripeRefundsPartialFullDuplicateAndOutOfOrder(t *testing.T) { } func TestStripeCheckoutRejectsRedirectsAndLeaksNoKey(t *testing.T) { - s, cookie, _ := stripeTestServer(t) + s, cookie, u := stripeTestServer(t) + seedTopupSubscription(t, s, u) for i, raw := range []string{"https://evil.example/pay", "http://checkout.stripe.com/pay", "https://checkout.stripe.com.evil.example/pay", "https://user@checkout.stripe.com/pay"} { - s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + s.paymentClient.Transport = paymentTestTransport(s, func(r *http.Request) (*http.Response, error) { b, _ := json.Marshal(map[string]any{"id": "cs_bad", "url": raw, "livemode": false}) return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(b))}, nil }) @@ -246,10 +248,11 @@ func TestStripeCheckoutRejectsRedirectsAndLeaksNoKey(t *testing.T) { } func TestStripeCheckoutRetryKeepsSameOrderAfterTimeout(t *testing.T) { - s, cookie, _ := stripeTestServer(t) + s, cookie, u := stripeTestServer(t) + seedTopupSubscription(t, s, u) var first string calls := 0 - s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + s.paymentClient.Transport = paymentTestTransport(s, func(r *http.Request) (*http.Response, error) { calls++ key := r.Header.Get("Idempotency-Key") if first == "" { @@ -278,9 +281,10 @@ func TestStripeCheckoutRetryKeepsSameOrderAfterTimeout(t *testing.T) { } func TestStripeCheckoutSeparatesTestAndLiveSessions(t *testing.T) { - s, cookie, _ := stripeTestServer(t) + s, cookie, u := stripeTestServer(t) + seedTopupSubscription(t, s, u) keys := map[string]bool{} - s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + s.paymentClient.Transport = paymentTestTransport(s, func(r *http.Request) (*http.Response, error) { keys[r.Header.Get("Idempotency-Key")] = true mode := "test" if s.stripeLive() { @@ -292,6 +296,7 @@ func TestStripeCheckoutSeparatesTestAndLiveSessions(t *testing.T) { body := map[string]string{"idempotency_key": "checkout-mode-isolation"} expect(t, request(t, s, "POST", "/api/payments/checkout", body, cookie), 200) s.cfg.StripeSecretKey = "sk_live_fake_for_unit_tests" + seedTopupSubscription(t, s, u) expect(t, request(t, s, "POST", "/api/payments/checkout", body, cookie), 200) if len(keys) != 2 { t.Fatal("live mode reused a cached test checkout") @@ -356,3 +361,39 @@ func TestStripeCreditPackDisclosesModePrices(t *testing.T) { } } } + +// Legacy one-time checkout tests now exercise top-ups after a paid subscription. +func seedTopupSubscription(t *testing.T, s *Server, u User) { + t.Helper() + suffix := "test" + if s.stripeLive() { + suffix = "live" + } + id := "sub_fixture_" + suffix + order := "subscription_fixture_" + suffix + now := time.Now().Unix() + end := now + 30*86400 + if _, err := s.db.Exec(`INSERT INTO subscription_orders(id,user_id,livemode,amount_cents,credits,currency,created_at,subscription_id,customer_id) VALUES(?,?,?,500,50000,'usd',?,?,'cus_fixture')`, order, u.ID, s.stripeLive(), now, id); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`INSERT INTO billing_subscriptions(id,user_id,order_id,customer_id,livemode,status,cancel_at_period_end,current_period_end,paid_until,price_id,created_at) VALUES(?,?,?,'cus_fixture',?,'active',0,?,?,'price_fixture',?)`, id, u.ID, order, s.stripeLive(), end, end, now); err != nil { + t.Fatal(err) + } +} +func paymentTestTransport(s *Server, next pageTransport) pageTransport { + return pageTransport(func(r *http.Request) (*http.Response, error) { + if r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/v1/subscriptions/sub_fixture_") { + var id, order, customer, status, price string + var live, cancel bool + var end int64 + id = strings.TrimPrefix(r.URL.Path, "/v1/subscriptions/") + if err := s.db.QueryRow(`SELECT order_id,customer_id,livemode,status,cancel_at_period_end,current_period_end,price_id FROM billing_subscriptions WHERE id=?`, id).Scan(&order, &customer, &live, &status, &cancel, &end, &price); err != nil { + return nil, err + } + obj := map[string]any{"id": id, "livemode": live, "customer": customer, "status": status, "cancel_at_period_end": cancel, "metadata": map[string]string{"cosift_order_id": order}, "items": map[string]any{"has_more": false, "data": []any{map[string]any{"id": "si_fixture", "quantity": 1, "current_period_end": end, "price": map[string]any{"id": price, "unit_amount": 500, "currency": "usd", "recurring": map[string]any{"interval": "month", "interval_count": 1}}}}}} + body, _ := json.Marshal(obj) + return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(body))}, nil + } + return next(r) + }) +} diff --git a/internal/community/server.go b/internal/community/server.go index f6d3237..8e271a1 100644 --- a/internal/community/server.go +++ b/internal/community/server.go @@ -37,21 +37,22 @@ const sessionAge = 30 * 24 * time.Hour const dailyContributionLimit = 1000 type Config struct { - Shared sharedaccount.Provider - DataDir string - Backend string - PublicURL string - AdminToken string // Only used for crawl-enqueue, never forwarded with searches. - TrustedProxies []string - GuestInterval time.Duration - MemberFreeRPM int - SearchRPM int - AnswerRPM int - ResearchPer10Min int - StripeSecretKey string - StripeWebhookSecret string - AllowTestPayments bool // Explicit opt-in for an isolated QA ledger only. - GAMeasurementID string + Shared sharedaccount.Provider + DataDir string + Backend string + PublicURL string + AdminToken string // Only used for crawl-enqueue, never forwarded with searches. + TrustedProxies []string + GuestInterval time.Duration + MemberFreeRPM int + SearchRPM int + AnswerRPM int + ResearchPer10Min int + StripeSecretKey string + StripeWebhookSecret string + AllowTestPayments bool // Explicit opt-in for an isolated QA ledger only. + StripePortalConfigurationID string + GAMeasurementID string } type bucket struct { @@ -65,6 +66,7 @@ type Server struct { paymentClient *http.Client handler http.Handler mu sync.Mutex + billingMu sync.Mutex // Serializes provider-state refreshes in this single gateway. limits map[string]bucket hashSlots chan struct{} trustedProxies []netip.Prefix @@ -163,6 +165,7 @@ func Open(cfg Config) (*Server, error) { respond(w, 200, map[string]string{"measurement_id": s.cfg.GAMeasurementID}) }) mux.HandleFunc("POST /api/payments/checkout", s.auth(s.checkout)) + mux.HandleFunc("POST /api/payments/portal", s.auth(s.portal)) mux.HandleFunc("POST /api/payments/webhook", s.stripeWebhook) mux.HandleFunc("GET /api/saved", s.auth(s.saved)) mux.HandleFunc("POST /api/saved", s.auth(s.save)) diff --git a/internal/community/store.go b/internal/community/store.go index 31268d8..332c43f 100644 --- a/internal/community/store.go +++ b/internal/community/store.go @@ -77,6 +77,10 @@ CREATE INDEX IF NOT EXISTS guest_usage_expiry ON guest_usage(expires_at);`) db.Close() return nil, err } + if err := subscriptionSchema(db); err != nil { + db.Close() + return nil, fmt.Errorf("subscription schema: %w", err) + } rows, err := db.Query(`PRAGMA table_info(submissions)`) if err != nil { db.Close() diff --git a/internal/community/subscription_invoices.go b/internal/community/subscription_invoices.go new file mode 100644 index 0000000..748d106 --- /dev/null +++ b/internal/community/subscription_invoices.go @@ -0,0 +1,238 @@ +package community + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "time" + + stripe "github.com/stripe/stripe-go/v86" +) + +func invoiceSubscription(inv *stripe.Invoice) (string, string) { + if inv.Parent == nil || inv.Parent.Type != "subscription_details" || inv.Parent.SubscriptionDetails == nil || inv.Parent.SubscriptionDetails.Subscription == nil { + return "", "" + } + d := inv.Parent.SubscriptionDetails + return d.Subscription.ID, d.Metadata["cosift_order_id"] +} + +func (s *Server) knownSubscriptionInvoice(ctx context.Context, inv *stripe.Invoice) (bool, error) { + sub, order := invoiceSubscription(inv) + if sub == "" { + return false, nil + } + if order != "" { + return true, nil + } + var n int + err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM billing_subscriptions WHERE id=?`, sub).Scan(&n) + return n > 0, err +} + +func (s *Server) loadSubscriptionInvoice(ctx context.Context, id string) (*stripe.Invoice, *billingSubscription, error) { + if !strings.HasPrefix(id, "in_") || !stripeIDPattern.MatchString(id) { + return nil, nil, errors.New("invalid invoice id") + } + var inv stripe.Invoice + if err := s.stripeRequest(ctx, "GET", "/v1/invoices/"+id, nil, "", &inv); err != nil { + return nil, nil, err + } + if inv.ID != id || inv.Livemode != s.stripeLive() { + return nil, nil, errors.New("invoice environment mismatch") + } + known, err := s.knownSubscriptionInvoice(ctx, &inv) + if err != nil { + return nil, nil, err + } + if !known { + return nil, nil, nil + } + subID, orderID := invoiceSubscription(&inv) + b, err := s.syncSubscription(ctx, subID) + if err != nil { + return nil, nil, err + } + if b == nil || (orderID != "" && orderID != b.OrderID) || inv.Customer == nil || inv.Customer.ID != b.CustomerID { + return nil, nil, errors.New("invoice ownership mismatch") + } + return &inv, b, nil +} + +func validatePaidSubscriptionInvoice(inv *stripe.Invoice, b *billingSubscription) (int64, error) { + if inv.Status != "paid" || inv.AmountPaid != packAmountCents || inv.AmountRemaining != 0 || inv.AmountDue != packAmountCents || inv.Total != packAmountCents || inv.Subtotal != packAmountCents || inv.AmountPaidOffStripe != 0 || inv.Currency != "usd" { + return 0, errors.New("invoice is not a full paid monthly plan") + } + if inv.BillingReason != "subscription_create" && inv.BillingReason != "subscription_cycle" { + return 0, errors.New("invoice is not a subscription billing period") + } + if inv.Lines == nil || inv.Lines.HasMore || len(inv.Lines.Data) != 1 { + return 0, errors.New("invoice must contain exactly one monthly plan line") + } + line := inv.Lines.Data[0] + if line == nil || line.ID == "" || line.Amount != packAmountCents || line.Currency != "usd" || line.Quantity != 1 || (line.QuantityDecimal != 0 && line.QuantityDecimal != 1) || line.Pricing == nil || line.Pricing.Type != "price_details" || line.Pricing.PriceDetails == nil || line.Pricing.PriceDetails.Price == nil || line.Pricing.PriceDetails.Price.ID != b.PriceID { + return 0, errors.New("invoice line price or quantity mismatch") + } + if line.Parent == nil || line.Parent.Type != "subscription_item_details" || line.Parent.SubscriptionItemDetails == nil || line.Parent.SubscriptionItemDetails.SubscriptionItem == "" || line.Parent.SubscriptionItemDetails.Subscription != b.ID || line.Parent.SubscriptionItemDetails.Proration { + return 0, errors.New("invoice line subscription mismatch or proration") + } + if line.Period == nil || line.Period.Start <= 0 || line.Period.End-line.Period.Start < 27*86400 || line.Period.End-line.Period.Start > 32*86400 { + return 0, errors.New("invoice period is not monthly") + } + return line.Period.End, nil +} + +func (s *Server) invoiceEvent(ctx context.Context, event stripeEvent, fulfill bool) error { + var snapshot stripe.Invoice + if json.Unmarshal(event.Data.Object, &snapshot) != nil { + return errors.New("invalid invoice event") + } + known, err := s.knownSubscriptionInvoice(ctx, &snapshot) + if err != nil { + return err + } + if !known { + return nil + } + inv, b, err := s.loadSubscriptionInvoice(ctx, snapshot.ID) + if err != nil { + return err + } + if b == nil { + return nil + } + if !fulfill { + return nil + } // Current subscription status was still synchronized. + end, err := validatePaidSubscriptionInvoice(inv, b) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO subscription_invoices(id,subscription_id,user_id,amount_cents,credits,currency,period_end,livemode) VALUES(?,?,?,?,?,'usd',?,?) ON CONFLICT(id) DO NOTHING`, inv.ID, b.ID, b.UserID, packAmountCents, packCredits, end, s.stripeLive()) + if err != nil { + return err + } + var storedSub, storedUser string + var storedEnd int64 + if err = tx.QueryRowContext(ctx, `SELECT subscription_id,user_id,period_end FROM subscription_invoices WHERE id=?`, inv.ID).Scan(&storedSub, &storedUser, &storedEnd); err != nil { + return err + } + if storedSub != b.ID || storedUser != b.UserID || storedEnd != end { + return errors.New("invoice was already bound to different content") + } + _, err = tx.ExecContext(ctx, `INSERT INTO credit_ledger(id,user_id,delta,reason,created_at) VALUES(?,?,?,'stripe_purchase',?) ON CONFLICT(id) DO NOTHING`, "stripe-invoice:"+inv.ID, b.UserID, packCredits, time.Now().Unix()) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE billing_subscriptions SET paid_until=COALESCE((SELECT MAX(period_end) FROM subscription_invoices WHERE subscription_id=? AND refunded_cents charge.Amount { + return errors.New("invalid refund amount") + } + pi := charge.PaymentIntent.ID + if !strings.HasPrefix(pi, "pi_") || !stripeIDPattern.MatchString(pi) { + return errors.New("invalid refund payment intent") + } + values := url.Values{"payment[type]": {"payment_intent"}, "payment[payment_intent]": {pi}, "status": {"paid"}, "limit": {"100"}} + for page := 0; page < 100; page++ { + var payments stripe.InvoicePaymentList + if err := s.stripeRequest(ctx, "GET", "/v1/invoice_payments?"+values.Encode(), nil, "", &payments); err != nil { + return err + } + for _, payment := range payments.Data { + if payment == nil || payment.Invoice == nil || payment.Payment == nil || payment.Payment.Type != "payment_intent" || payment.Payment.PaymentIntent == nil || payment.Payment.PaymentIntent.ID != pi || payment.Status != "paid" { + return errors.New("invalid invoice payment association") + } + inv, b, err := s.loadSubscriptionInvoice(ctx, payment.Invoice.ID) + if err != nil { + return err + } + if b == nil { + continue + } + if charge.Customer == nil || charge.Customer.ID != b.CustomerID || charge.Amount != packAmountCents || charge.Currency != "usd" || payment.AmountPaid != packAmountCents || payment.Livemode != s.stripeLive() { + return errors.New("refund does not match subscription invoice payment") + } + if _, err := validatePaidSubscriptionInvoice(inv, b); err != nil { + return err + } + if err := s.refundSubscriptionInvoice(ctx, event.ID, inv.ID, b, charge.AmountRefunded); err != nil { + return err + } + } + if !payments.HasMore { + return nil + } + if len(payments.Data) == 0 || payments.Data[len(payments.Data)-1].ID == "" { + return errors.New("invalid invoice payment pagination") + } + values.Set("starting_after", payments.Data[len(payments.Data)-1].ID) + } + return errors.New("invoice payment pagination limit exceeded") +} + +func (s *Server) refundSubscriptionInvoice(ctx context.Context, eventID, invoiceID string, b *billingSubscription, refunded int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var user, sub string + var amount, credits, previous int64 + err = tx.QueryRowContext(ctx, `SELECT user_id,subscription_id,amount_cents,credits,refunded_cents FROM subscription_invoices WHERE id=?`, invoiceID).Scan(&user, &sub, &amount, &credits, &previous) + if err != nil { + return err + } // A refund preceding invoice.paid must retry. + if sub != b.ID || user != b.UserID || amount <= 0 || refunded > amount { + return errors.New("refund invoice binding mismatch") + } + if refunded <= previous { + return nil + } + revoke := credits*refunded/amount - credits*previous/amount + _, err = tx.ExecContext(ctx, `INSERT INTO credit_ledger(id,user_id,delta,reason,created_at) VALUES(?,?,?,'stripe_refund',?)`, fmt.Sprintf("stripe-invoice-refund:%s:%d", invoiceID, refunded), user, -revoke, time.Now().Unix()) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE subscription_invoices SET refunded_cents=? WHERE id=?`, refunded, invoiceID) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE billing_subscriptions SET paid_until=COALESCE((SELECT MAX(period_end) FROM subscription_invoices WHERE subscription_id=? AND refunded_cents time.Now().Unix() +} + +func subscriptionPlan() map[string]any { + return map[string]any{"amount_cents": packAmountCents, "credits": packCredits, "currency": "usd", "interval": "month"} +} + +func (s *Server) accountSubscription(ctx context.Context, userID string) (billingSubscription, error) { + var b billingSubscription + err := s.db.QueryRowContext(ctx, `SELECT id,user_id,order_id,customer_id,livemode,status,cancel_at_period_end,current_period_end,paid_until,price_id,created_at +FROM billing_subscriptions WHERE user_id=? AND livemode=? ORDER BY CASE WHEN status IN ('canceled','incomplete_expired') THEN 1 ELSE 0 END,created_at DESC,id DESC LIMIT 1`, userID, s.stripeLive()).Scan(&b.ID, &b.UserID, &b.OrderID, &b.CustomerID, &b.LiveMode, &b.Status, &b.CancelAtPeriodEnd, &b.CurrentPeriodEnd, &b.PaidUntil, &b.PriceID, &b.Created) + if errors.Is(err, sql.ErrNoRows) { + return billingSubscription{Status: "none"}, nil + } + return b, err +} + +func (s *Server) subscriptionFields(ctx context.Context, userID string) (map[string]any, error) { + b, err := s.accountSubscription(ctx, userID) + if err != nil { + return nil, err + } + return map[string]any{ + "subscription": map[string]any{"status": b.Status, "active": b.active(), "cancel_at_period_end": b.CancelAtPeriodEnd, "current_period_end": b.CurrentPeriodEnd}, + "can_top_up": s.paymentsEnabled() && b.active(), + "portal_available": s.paymentsEnabled() && b.CustomerID != "" && strings.HasPrefix(s.cfg.StripePortalConfigurationID, "bpc_"), + "subscription_plan": subscriptionPlan(), + }, nil +} + +// Basil-or-newer snapshot events are routed by stable IDs, then objects are +// retrieved at the installed SDK API version. No Stripe keys are logged. +func (s *Server) stripeRequest(ctx context.Context, method, path string, form url.Values, idempotency string, out any) error { + if !strings.HasPrefix(path, "/v1/") { + return errors.New("invalid Stripe API path") + } + var body io.Reader + if form != nil { + body = strings.NewReader(form.Encode()) + } + req, err := http.NewRequestWithContext(ctx, method, "https://api.stripe.com"+path, body) + if err != nil { + return err + } + req.SetBasicAuth(s.cfg.StripeSecretKey, "") + req.Header.Set("Stripe-Version", stripe.APIVersion) + if form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if idempotency != "" { + req.Header.Set("Idempotency-Key", idempotency) + } + res, err := s.paymentClient.Do(req) + if err != nil { + return errors.New("Stripe API unavailable") + } + defer res.Body.Close() + if res.StatusCode != 200 { + return fmt.Errorf("Stripe API returned HTTP %d", res.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(res.Body, (1<<20)+1)) + if err != nil || len(data) > 1<<20 { + return errors.New("Stripe response unavailable") + } + if json.Unmarshal(data, out) != nil { + return errors.New("invalid Stripe response") + } + return nil +} + +func (s *Server) requireTopup(ctx context.Context, userID string) (billingSubscription, error) { + b, err := s.accountSubscription(ctx, userID) + if err != nil { + return b, err + } + if b.ID == "" { + return b, errSubscriptionRequired + } + current, err := s.syncSubscription(ctx, b.ID) + if err != nil { + return b, err + } + if current == nil || !current.active() { + return b, errSubscriptionRequired + } + return *current, nil +} + +func (s *Server) checkoutSubscription(w http.ResponseWriter, r *http.Request, u User, key string) { + b, err := s.accountSubscription(r.Context(), u.ID) + if err != nil { + problem(w, 503, "subscription unavailable") + return + } + if b.ID != "" { + current, e := s.syncSubscription(r.Context(), b.ID) + if e != nil { + problem(w, 502, "could not verify subscription; retry") + return + } + if current != nil && current.Status != "canceled" && current.Status != "incomplete_expired" { + problem(w, 409, "a subscription already exists; manage it in billing") + return + } + } + id := "subscription:" + tokenHash(strconv.FormatBool(s.stripeLive())+":"+u.ID+":"+key) + now := time.Now().Unix() + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + problem(w, 500, "could not prepare subscription") + return + } + defer tx.Rollback() + _, err = tx.ExecContext(r.Context(), `DELETE FROM subscription_checkout_pending WHERE user_id=? AND livemode=? AND order_id IN (SELECT id FROM subscription_orders WHERE created_at<=?)`, u.ID, s.stripeLive(), now-23*3600) + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscription_orders(id,user_id,livemode,amount_cents,credits,currency,created_at) VALUES(?,?,?,?,?,'usd',?) ON CONFLICT(id) DO NOTHING`, id, u.ID, s.stripeLive(), packAmountCents, packCredits, now) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscription_checkout_pending(user_id,livemode,order_id) VALUES(?,?,?) ON CONFLICT(user_id,livemode) DO NOTHING`, u.ID, s.stripeLive(), id) + } + var created int64 + var sessionID, checkoutURL string + if err == nil { + err = tx.QueryRowContext(r.Context(), `SELECT o.id,o.created_at,COALESCE(o.session_id,''),o.checkout_url FROM subscription_checkout_pending p JOIN subscription_orders o ON o.id=p.order_id WHERE p.user_id=? AND p.livemode=?`, u.ID, s.stripeLive()).Scan(&id, &created, &sessionID, &checkoutURL) + } + if err != nil || tx.Commit() != nil { + problem(w, 500, "could not prepare subscription") + return + } + if now-created >= 23*3600 { + problem(w, 409, "this checkout expired; start a new subscription checkout") + return + } + if sessionID != "" && checkoutURL != "" { + respond(w, 200, map[string]string{"url": checkoutURL}) + return + } + form := url.Values{ + "mode": {"subscription"}, "payment_method_types[0]": {"card"}, "adaptive_pricing[enabled]": {"false"}, + "client_reference_id": {u.ID}, "metadata[cosift_order_id]": {id}, "subscription_data[metadata][cosift_order_id]": {id}, + "line_items[0][price_data][currency]": {"usd"}, "line_items[0][price_data][unit_amount]": {"500"}, "line_items[0][price_data][recurring][interval]": {"month"}, "line_items[0][price_data][recurring][interval_count]": {"1"}, + "line_items[0][price_data][product_data][name]": {"Cosift monthly credits"}, "line_items[0][price_data][product_data][description]": {"50,000 credits per paid month. Unused credits carry over. Search 1, Answer 2, Research 3 credits per extra request."}, "line_items[0][quantity]": {"1"}, + "expires_at": {strconv.FormatInt(created+23*3600, 10)}, "success_url": {s.cfg.PublicURL + "/?payment=success"}, "cancel_url": {s.cfg.PublicURL + "/?payment=cancelled"}, + } + var session stripe.CheckoutSession + if s.stripeRequest(r.Context(), "POST", "/v1/checkout/sessions", form, id, &session) != nil || !strings.HasPrefix(session.ID, "cs_") || session.Livemode != s.stripeLive() || !validCheckoutURL(session.URL) { + problem(w, 502, "subscription checkout unavailable; retry this purchase") + return + } + res, err := s.db.ExecContext(r.Context(), `UPDATE subscription_orders SET session_id=?,checkout_url=? WHERE id=? AND (session_id IS NULL OR session_id=?)`, session.ID, session.URL, id, session.ID) + if err != nil { + problem(w, 500, "could not save checkout; retry") + return + } + n, _ := res.RowsAffected() + if n != 1 { + problem(w, 409, "checkout session mismatch") + return + } + respond(w, 200, map[string]string{"url": session.URL}) +} + +func (s *Server) portal(w http.ResponseWriter, r *http.Request, u User) { + if !s.paymentsEnabled() || !strings.HasPrefix(s.cfg.StripePortalConfigurationID, "bpc_") { + problem(w, 503, "billing management is not configured") + return + } + var in struct{} + if decode(r, &in) != nil { + problem(w, 400, "expected an empty billing request") + return + } + if !s.allow("portal:"+u.ID, 10, time.Minute) { + problem(w, 429, "too many billing requests") + return + } + b, err := s.accountSubscription(r.Context(), u.ID) + if err != nil || b.CustomerID == "" { + problem(w, 409, "no subscription to manage") + return + } + var session struct { + URL string `json:"url"` + } + form := url.Values{"customer": {b.CustomerID}, "configuration": {s.cfg.StripePortalConfigurationID}, "return_url": {s.cfg.PublicURL + "/?payment=manage"}} + if s.stripeRequest(r.Context(), "POST", "/v1/billing_portal/sessions", form, "", &session) != nil { + problem(w, 502, "billing portal unavailable") + return + } + parsed, err := url.Parse(session.URL) + if err != nil || parsed.Scheme != "https" || parsed.Host != "billing.stripe.com" || parsed.User != nil { + problem(w, 502, "billing portal unavailable") + return + } + respond(w, 200, map[string]string{"url": session.URL}) +} + +func (s *Server) syncSubscription(ctx context.Context, id string) (*billingSubscription, error) { + s.billingMu.Lock() + defer s.billingMu.Unlock() + if !strings.HasPrefix(id, "sub_") || !stripeIDPattern.MatchString(id) { + return nil, errors.New("invalid subscription id") + } + var sub stripe.Subscription + if err := s.stripeRequest(ctx, "GET", "/v1/subscriptions/"+id, nil, "", &sub); err != nil { + return nil, err + } + if sub.ID != id || sub.Livemode != s.stripeLive() { + return nil, errors.New("subscription environment mismatch") + } + orderID := sub.Metadata["cosift_order_id"] + if orderID == "" { + var known int + err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM billing_subscriptions WHERE id=?`, id).Scan(&known) + if err != nil { + return nil, err + } + if known == 0 { + return nil, nil + } + return nil, errors.New("subscription lost its order binding") + } + var user, existingSub, existingCustomer string + var live bool + var amount, credits, created int64 + var currency string + err := s.db.QueryRowContext(ctx, `SELECT user_id,livemode,amount_cents,credits,currency,created_at,COALESCE(subscription_id,''),customer_id FROM subscription_orders WHERE id=?`, orderID).Scan(&user, &live, &amount, &credits, ¤cy, &created, &existingSub, &existingCustomer) + if err != nil { + return nil, err + } + if live != s.stripeLive() || sub.Customer == nil || sub.Customer.ID == "" || (existingSub != "" && existingSub != id) || (existingCustomer != "" && existingCustomer != sub.Customer.ID) || sub.Items == nil || sub.Items.HasMore || len(sub.Items.Data) != 1 { + return nil, errors.New("subscription does not match order") + } + item := sub.Items.Data[0] + if item == nil || item.ID == "" || item.Quantity != 1 || item.Price == nil || !strings.HasPrefix(item.Price.ID, "price_") || item.Price.UnitAmount != amount || string(item.Price.Currency) != currency || item.Price.Recurring == nil || string(item.Price.Recurring.Interval) != "month" || item.Price.Recurring.IntervalCount != 1 || item.CurrentPeriodEnd <= 0 { + return nil, errors.New("subscription price does not match monthly plan") + } + b := billingSubscription{ID: id, UserID: user, OrderID: orderID, CustomerID: sub.Customer.ID, LiveMode: live, Status: string(sub.Status), CancelAtPeriodEnd: sub.CancelAtPeriodEnd, CurrentPeriodEnd: item.CurrentPeriodEnd, PriceID: item.Price.ID, Created: created} + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + var oldPrice string + err = tx.QueryRowContext(ctx, `SELECT price_id FROM billing_subscriptions WHERE id=?`, id).Scan(&oldPrice) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if oldPrice != "" && oldPrice != b.PriceID { + return nil, errors.New("subscription price changed outside Cosift") + } + if _, err = tx.ExecContext(ctx, `UPDATE subscription_orders SET subscription_id=?,customer_id=? WHERE id=?`, id, b.CustomerID, orderID); err != nil { + return nil, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO billing_subscriptions(id,user_id,order_id,customer_id,livemode,status,cancel_at_period_end,current_period_end,price_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?) +ON CONFLICT(id) DO UPDATE SET status=excluded.status,cancel_at_period_end=excluded.cancel_at_period_end,current_period_end=excluded.current_period_end`, id, user, orderID, b.CustomerID, live, b.Status, b.CancelAtPeriodEnd, b.CurrentPeriodEnd, b.PriceID, created) + if err != nil { + return nil, err + } + if err = tx.QueryRowContext(ctx, `SELECT paid_until FROM billing_subscriptions WHERE id=?`, id).Scan(&b.PaidUntil); err != nil { + return nil, err + } + if b.Status == "canceled" || b.Status == "incomplete_expired" { + if _, err = tx.ExecContext(ctx, `DELETE FROM subscription_checkout_pending WHERE order_id=?`, orderID); err != nil { + return nil, err + } + } + if err = tx.Commit(); err != nil { + return nil, err + } + return &b, nil +} + +func (s *Server) subscriptionEvent(ctx context.Context, event stripeEvent) error { + var sub stripe.Subscription + if json.Unmarshal(event.Data.Object, &sub) != nil { + return errors.New("invalid subscription event") + } + if sub.Metadata["cosift_order_id"] == "" { + var n int + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM billing_subscriptions WHERE id=?`, sub.ID).Scan(&n); err != nil { + return err + } + if n == 0 { + return nil + } + } + _, err := s.syncSubscription(ctx, sub.ID) + return err +} + +func (s *Server) completeSubscriptionCheckout(ctx context.Context, event stripeEvent) error { + var session stripe.CheckoutSession + if json.Unmarshal(event.Data.Object, &session) != nil { + return errors.New("invalid subscription checkout") + } + order := session.Metadata["cosift_order_id"] + if order == "" { + return nil + } + if session.Mode != "subscription" || session.Status != "complete" || session.Subscription == nil || session.Customer == nil || session.Livemode != s.stripeLive() { + return errors.New("invalid subscription checkout") + } + var user, existing string + var live bool + if err := s.db.QueryRowContext(ctx, `SELECT user_id,livemode,COALESCE(session_id,'') FROM subscription_orders WHERE id=?`, order).Scan(&user, &live, &existing); err != nil { + return err + } + if user != session.ClientReferenceID || live != s.stripeLive() || (existing != "" && existing != session.ID) { + return errors.New("subscription checkout ownership mismatch") + } + if _, err := s.db.ExecContext(ctx, `UPDATE subscription_orders SET session_id=? WHERE id=? AND (session_id IS NULL OR session_id=?)`, session.ID, order, session.ID); err != nil { + return err + } + b, err := s.syncSubscription(ctx, session.Subscription.ID) + if err != nil { + return err + } + if b == nil || b.OrderID != order || b.CustomerID != session.Customer.ID { + return errors.New("subscription checkout binding mismatch") + } + // invoice.paid is the sole subscription credit authority. + return nil +} diff --git a/internal/community/subscriptions_checkout_test.go b/internal/community/subscriptions_checkout_test.go new file mode 100644 index 0000000..6b6744c --- /dev/null +++ b/internal/community/subscriptions_checkout_test.go @@ -0,0 +1,141 @@ +package community + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestSubscriptionCheckoutOwnsPlanAndReusesPendingSession(t *testing.T) { + s, cookie, u := stripeTestServer(t) + expect(t, request(t, s, "POST", "/api/payments/checkout", map[string]string{"kind": "topup", "idempotency_key": "topup-without-membership"}, cookie), 403) + var rows int + s.db.QueryRow(`SELECT count(*) FROM payment_checkouts`).Scan(&rows) + if rows != 0 { + t.Fatal("blocked top-up created an order") + } + calls := 0 + var order string + s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + calls++ + if r.Method != "POST" || r.URL.Path != "/v1/checkout/sessions" { + t.Fatal("unexpected Stripe request") + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + for field, want := range map[string]string{"mode": "subscription", "client_reference_id": u.ID, "line_items[0][price_data][currency]": "usd", "line_items[0][price_data][unit_amount]": "500", "line_items[0][price_data][recurring][interval]": "month", "line_items[0][price_data][recurring][interval_count]": "1", "line_items[0][quantity]": "1", "success_url": s.cfg.PublicURL + "/?payment=success", "cancel_url": s.cfg.PublicURL + "/?payment=cancelled"} { + if r.Form.Get(field) != want { + t.Errorf("%s=%q", field, r.Form.Get(field)) + } + } + order = r.Form.Get("metadata[cosift_order_id]") + if order == "" || order != r.Header.Get("Idempotency-Key") || order != r.Form.Get("subscription_data[metadata][cosift_order_id]") { + t.Fatal("subscription order is not bound") + } + if r.Form.Get("payment_intent_data[metadata][cosift_order_id]") != "" { + t.Fatal("subscription used unsupported PaymentIntent form") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"cs_subscription_new","url":"https://checkout.stripe.com/c/pay/cs_subscription_new","livemode":false}`))}, nil + }) + for _, key := range []string{"monthly-subscription-001", "monthly-subscription-001", "different-browser-tab-002"} { + expect(t, request(t, s, "POST", "/api/payments/checkout", map[string]string{"kind": "subscription", "idempotency_key": key}, cookie), 200) + } + if calls != 1 { + t.Fatalf("duplicate sessions=%d", calls) + } + if paymentBalance(t, s, u) != 0 { + t.Fatal("creating checkout minted credits") + } + var saved string + if err := s.db.QueryRow(`SELECT order_id FROM subscription_checkout_pending WHERE user_id=?`, u.ID).Scan(&saved); err != nil || saved != order { + t.Fatal("pending checkout reservation missing") + } +} + +func TestSubscriptionCheckoutTimeoutRetriesStableProviderOrder(t *testing.T) { + s, cookie, _ := stripeTestServer(t) + calls := 0 + first := "" + s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + calls++ + key := r.Header.Get("Idempotency-Key") + if first == "" { + first = key + } else if key != first { + t.Fatal("retry changed order") + } + if calls == 1 { + return nil, fmt.Errorf("timeout after provider accepted request") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"id":"cs_subscription_retry","url":"https://checkout.stripe.com/c/pay/cs_subscription_retry","livemode":false}`))}, nil + }) + for _, code := range []int{502, 200} { + expect(t, request(t, s, "POST", "/api/payments/checkout", map[string]string{"kind": "subscription", "idempotency_key": "subscription-timeout-retry"}, cookie), code) + } +} + +func TestSubscriptionCheckoutCompletionNeverGrantsMonthlyCredits(t *testing.T) { + f := newSubscriptionReviewFixture(t) + s := f.server + object := map[string]any{"id": "cs_review", "mode": "subscription", "status": "complete", "payment_status": "paid", "livemode": false, "customer": "cus_review", "subscription": "sub_review", "client_reference_id": f.user.ID, "metadata": map[string]string{"cosift_order_id": "subscription-order-review"}} + expect(t, deliver(s, "evt_subscription_checkout", "checkout.session.completed", object), 200) + if paymentBalance(t, s, f.user) != 0 { + t.Fatal("subscription checkout granted credits before invoice") + } + w := request(t, s, "GET", "/api/credits", nil, f.cookie) + expect(t, w, 200) + var info map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if info["can_top_up"] != false || info["monthly_free_credits"] != float64(1000) || info["free_requests_per_minute"] != float64(60) { + t.Fatal("unpaid subscription changed free plan or enabled top-ups") + } + if info["subscription"].(map[string]any)["active"] != false { + t.Fatal("unpaid active provider status unlocked paid access") + } +} + +func TestSubscriptionPortalUsesOnlyBoundCustomerAndDedicatedConfiguration(t *testing.T) { + f := newSubscriptionReviewFixture(t) + s := f.server + expect(t, deliver(s, "evt_portal_sub", "customer.subscription.created", f.subscription), 200) + s.cfg.StripePortalConfigurationID = "bpc_cosift_private_config" + s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + if r.Method != "POST" || r.URL.Path != "/v1/billing_portal/sessions" { + t.Fatal("unexpected portal request") + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("customer") != "cus_review" || r.Form.Get("configuration") != s.cfg.StripePortalConfigurationID || r.Form.Get("return_url") != s.cfg.PublicURL+"/?payment=manage" { + t.Fatal("portal crossed customer/configuration boundary") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"url":"https://billing.stripe.com/p/session/test"}`))}, nil + }) + expect(t, request(t, s, "POST", "/api/payments/portal", map[string]any{}, nil), 401) + expect(t, request(t, s, "POST", "/api/payments/portal", map[string]string{"customer": "cus_someone_else"}, f.cookie), 400) + expect(t, request(t, s, "POST", "/api/payments/portal", map[string]any{}, f.cookie), 200) + s.cfg.StripePortalConfigurationID = "" + expect(t, request(t, s, "POST", "/api/payments/portal", map[string]any{}, f.cookie), 503) +} + +func TestSubscriptionActiveAndExpiredPaidPeriodGateTopups(t *testing.T) { + f := newSubscriptionReviewFixture(t) + s := f.server + expect(t, deliver(s, "evt_initial_paid", "invoice.paid", f.invoices["in_review"]), 200) + w := request(t, s, "GET", "/api/credits", nil, f.cookie) + expect(t, w, 200) + var out map[string]any + json.Unmarshal(w.Body.Bytes(), &out) + if out["can_top_up"] != true { + t.Fatal("paid active subscription did not unlock top-ups") + } + s.db.Exec(`UPDATE billing_subscriptions SET paid_until=?`, time.Now().Unix()-1) + expect(t, request(t, s, "POST", "/api/payments/checkout", map[string]string{"kind": "topup", "idempotency_key": "expired-paid-period-topup"}, f.cookie), 403) +} diff --git a/internal/community/subscriptions_review_test.go b/internal/community/subscriptions_review_test.go new file mode 100644 index 0000000..349f80b --- /dev/null +++ b/internal/community/subscriptions_review_test.go @@ -0,0 +1,354 @@ +package community + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + stripe "github.com/stripe/stripe-go/v86" +) + +type subscriptionReviewFixture struct { + server *Server + cookie *http.Cookie + user User + subscription map[string]any + invoices map[string]map[string]any + price map[string]any + paymentLookups int + mu sync.Mutex +} + +func reviewClone(in map[string]any) map[string]any { + b, _ := json.Marshal(in) + var out map[string]any + _ = json.Unmarshal(b, &out) + return out +} + +func newSubscriptionReviewFixture(t *testing.T) *subscriptionReviewFixture { + t.Helper() + s, cookie, user := stripeTestServer(t) + now := time.Now().Unix() + end := time.Now().AddDate(0, 1, 0).Unix() + price := map[string]any{"id": "price_review", "object": "price", "active": true, "currency": "usd", "unit_amount": 500, "product": "prod_review", "type": "recurring", "recurring": map[string]any{"interval": "month", "interval_count": 1}} + subscription := map[string]any{"id": "sub_review", "object": "subscription", "livemode": false, "customer": "cus_review", "status": "active", "cancel_at_period_end": false, "metadata": map[string]any{"cosift_order_id": "subscription-order-review"}, "latest_invoice": "in_review", "items": map[string]any{"object": "list", "has_more": false, "data": []any{map[string]any{"id": "si_review", "object": "subscription_item", "quantity": 1, "current_period_start": now - 60, "current_period_end": end, "price": price}}}} + line := map[string]any{"id": "il_review", "object": "line_item", "amount": 500, "currency": "usd", "quantity": 1, "period": map[string]any{"start": now - 60, "end": end}, "parent": map[string]any{"type": "subscription_item_details", "subscription_item_details": map[string]any{"subscription": "sub_review", "subscription_item": "si_review", "proration": false}}, "pricing": map[string]any{"type": "price_details", "price_details": map[string]any{"price": "price_review", "product": "prod_review"}}} + invoice := map[string]any{"id": "in_review", "object": "invoice", "livemode": false, "customer": "cus_review", "status": "paid", "currency": "usd", "total": 500, "subtotal": 500, "amount_due": 500, "amount_paid": 500, "amount_paid_off_stripe": 0, "amount_remaining": 0, "billing_reason": "subscription_cycle", "parent": map[string]any{"type": "subscription_details", "subscription_details": map[string]any{"subscription": "sub_review", "metadata": map[string]any{"cosift_order_id": "subscription-order-review"}}}, "lines": map[string]any{"object": "list", "has_more": false, "data": []any{line}}, "payments": map[string]any{"object": "list", "has_more": false, "data": []any{map[string]any{"id": "inpay_review", "invoice": "in_review", "amount_paid": 500, "currency": "usd", "status": "paid", "payment": map[string]any{"type": "payment_intent", "payment_intent": "pi_review"}}}}} + if _, err := s.db.Exec(`INSERT INTO subscription_orders(id,user_id,livemode,amount_cents,credits,currency,created_at,session_id,checkout_url,subscription_id,customer_id) VALUES(?,?,0,500,50000,'usd',?,'cs_review','https://checkout.stripe.com/c/pay/cs_review','sub_review','cus_review')`, "subscription-order-review", user.ID, now); err != nil { + t.Fatal(err) + } + f := &subscriptionReviewFixture{server: s, cookie: cookie, user: user, subscription: subscription, invoices: map[string]map[string]any{"in_review": invoice}, price: price} + s.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + f.mu.Lock() + defer f.mu.Unlock() + var body any + switch { + case r.Method == "GET" && r.URL.Path == "/v1/subscriptions/sub_review": + body = f.subscription + case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/v1/invoices/"): + id, _ := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/v1/invoices/")) + body = f.invoices[id] + case r.Method == "GET" && r.URL.Path == "/v1/prices/price_review": + body = f.price + case r.Method == "GET" && r.URL.Path == "/v1/invoice_payments": + if r.URL.Query().Get("payment[payment_intent]") != "pi_review" || r.URL.Query().Get("payment[type]") != "payment_intent" { + t.Errorf("refund used incorrect current-schema mapping filter: %s", r.URL.RawQuery) + } + f.paymentLookups++ + body = map[string]any{"object": "list", "has_more": false, "data": []any{map[string]any{"id": "inpay_review", "invoice": "in_review", "amount_paid": 500, "currency": "usd", "status": "paid", "livemode": false, "payment": map[string]any{"type": "payment_intent", "payment_intent": "pi_review"}}}} + default: + return nil, fmt.Errorf("unexpected Stripe request %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Stripe-Version") != stripe.APIVersion { + t.Error("Stripe objects were not normalized to the installed API version") + } + if body == nil { + return nil, fmt.Errorf("missing fixture for %s", r.URL.Path) + } + data, _ := json.Marshal(body) + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(data))), Request: r}, nil + }) + return f +} + +func reviewInvoiceLine(invoice map[string]any) map[string]any { + return invoice["lines"].(map[string]any)["data"].([]any)[0].(map[string]any) +} + +func TestSubscriptionReviewInvoicePaidExactlyOnceAndRestart(t *testing.T) { + f := newSubscriptionReviewFixture(t) + s := f.server + for _, id := range []string{"evt_review_paid", "evt_review_paid", "evt_review_paid_other"} { + expect(t, deliver(s, id, "invoice.paid", f.invoices["in_review"]), 200) + } + if got := paymentBalance(t, s, f.user); got != 50000 { + t.Fatalf("duplicate invoice minted credits: %d", got) + } + reopened, err := Open(s.cfg) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + reopened.paymentClient = s.paymentClient + expect(t, deliver(reopened, "evt_review_after_restart", "invoice.paid", f.invoices["in_review"]), 200) + if got := paymentBalance(t, reopened, f.user); got != 50000 { + t.Fatalf("restart minted credits: %d", got) + } + renewal := reviewClone(f.invoices["in_review"]) + renewal["id"] = "in_review_renewal" + period := reviewInvoiceLine(renewal)["period"].(map[string]any) + period["start"] = period["end"] + period["end"] = time.Now().AddDate(0, 2, 0).Unix() + f.invoices["in_review_renewal"] = renewal + expect(t, deliver(s, "evt_review_renewal", "invoice.paid", renewal), 200) + expect(t, deliver(s, "evt_review_old_again", "invoice.paid", f.invoices["in_review"]), 200) + if got := paymentBalance(t, s, f.user); got != 100000 { + t.Fatalf("out-of-order recurring invoice grants=%d", got) + } +} + +func TestSubscriptionReviewInvalidPaidInvoicesNeverMintCredits(t *testing.T) { + cases := []struct { + name string + mutate func(*subscriptionReviewFixture) + }{ + {"unpaid", func(f *subscriptionReviewFixture) { f.invoices["in_review"]["status"] = "open" }}, + {"partial", func(f *subscriptionReviewFixture) { + f.invoices["in_review"]["amount_paid"] = 499 + f.invoices["in_review"]["amount_remaining"] = 1 + }}, + {"off Stripe", func(f *subscriptionReviewFixture) { f.invoices["in_review"]["amount_paid_off_stripe"] = 500 }}, + {"currency", func(f *subscriptionReviewFixture) { f.invoices["in_review"]["currency"] = "eur" }}, + {"customer", func(f *subscriptionReviewFixture) { f.invoices["in_review"]["customer"] = "cus_another_account" }}, + {"line quantity", func(f *subscriptionReviewFixture) { reviewInvoiceLine(f.invoices["in_review"])["quantity"] = 2 }}, + {"line price", func(f *subscriptionReviewFixture) { + reviewInvoiceLine(f.invoices["in_review"])["pricing"].(map[string]any)["price_details"].(map[string]any)["price"] = "price_unrelated" + }}, + {"line currency", func(f *subscriptionReviewFixture) { reviewInvoiceLine(f.invoices["in_review"])["currency"] = "eur" }}, + {"missing subscription item", func(f *subscriptionReviewFixture) { + delete(reviewInvoiceLine(f.invoices["in_review"])["parent"].(map[string]any)["subscription_item_details"].(map[string]any), "subscription_item") + }}, + {"subscription owner", func(f *subscriptionReviewFixture) { + f.subscription["customer"] = "cus_another_account" + }}, + {"subscription price identity", func(f *subscriptionReviewFixture) { + delete(f.price, "id") + }}, + {"subscription item identity", func(f *subscriptionReviewFixture) { + delete(f.subscription["items"].(map[string]any)["data"].([]any)[0].(map[string]any), "id") + }}, + {"proration", func(f *subscriptionReviewFixture) { + reviewInvoiceLine(f.invoices["in_review"])["parent"].(map[string]any)["subscription_item_details"].(map[string]any)["proration"] = true + }}, + {"manual", func(f *subscriptionReviewFixture) { f.invoices["in_review"]["billing_reason"] = "manual" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newSubscriptionReviewFixture(t) + tc.mutate(f) + w := deliver(f.server, "evt_review_invalid", "invoice.paid", f.invoices["in_review"]) + if got := paymentBalance(t, f.server, f.user); got != 0 { + t.Fatalf("invalid invoice credited%d status%d: %s", got, w.Code, w.Body) + } + }) + } +} + +func TestSubscriptionReviewInvoiceRefundUsesCurrentPaymentMapping(t *testing.T) { + f := newSubscriptionReviewFixture(t) + s := f.server + expect(t, deliver(s, "evt_review_paid", "invoice.paid", f.invoices["in_review"]), 200) + charge := map[string]any{"id": "ch_review", "object": "charge", "customer": "cus_review", "payment_intent": "pi_review", "amount": 500, "amount_refunded": 100, "currency": "usd", "livemode": false, "metadata": map[string]any{}} + for _, step := range []struct { + refunded int + balance int64 + }{{100, 40000}, {100, 40000}, {50, 40000}, {500, 0}} { + charge["amount_refunded"] = step.refunded + expect(t, deliver(s, fmt.Sprintf("evt_review_refund_%d", step.refunded), "charge.refunded", charge), 200) + if got := paymentBalance(t, s, f.user); got != step.balance { + t.Fatalf("refund reconciliation=%d want%d", got, step.balance) + } + } + expect(t, deliver(s, "evt_review_paid_after_refund", "invoice.paid", f.invoices["in_review"]), 200) + if got := paymentBalance(t, s, f.user); got != 0 { + t.Fatalf("late paid event restored refunded credits%d", got) + } + if f.paymentLookups == 0 { + t.Fatal("refund relied on removed charge.invoice instead of current payment mapping") + } +} + +func TestSubscriptionReviewCanceledSubscriberCannotStartTopup(t *testing.T) { + f := newSubscriptionReviewFixture(t) + s := f.server + expect(t, deliver(s, "evt_review_paid", "invoice.paid", f.invoices["in_review"]), 200) + if _, err := s.requireTopup(context.Background(), f.user.ID); err != nil { + t.Fatal("paid active subscription denied", err) + } + // Stripe's fresh state is authoritative even before a cancellation webhook. + previousActive := reviewClone(f.subscription) + f.subscription["status"] = "canceled" + denied := request(t, s, "POST", "/api/payments/checkout", map[string]any{"kind": "topup", "idempotency_key": "review-inactive-topup-001"}, f.cookie) + if denied.Code < 400 { + t.Fatalf("canceled subscriber started topup: %d %s", denied.Code, denied.Body) + } + var checkouts int + if err := s.db.QueryRow(`SELECT count(*) FROM payment_checkouts`).Scan(&checkouts); err != nil || checkouts != 0 { + t.Fatalf("inactive checkout persisted: %d %v", checkouts, err) + } + expect(t, deliver(s, "evt_review_cancel", "customer.subscription.deleted", f.subscription), 200) + expect(t, deliver(s, "evt_review_stale_active", "customer.subscription.updated", previousActive), 200) + b, err := s.accountSubscription(context.Background(), f.user.ID) + if err != nil || b.active() { + t.Fatalf("stale event reactivated canceled subscription: %+v %v", b, err) + } + // Payment completed for an order created while active must still be honored. + seedOrder(t, s, f.user, "review-paid-before-cancel") + expect(t, deliver(s, "evt_review_paid_topup", "checkout.session.completed", paidObject(f.user, "review-paid-before-cancel")), 200) + if got := paymentBalance(t, s, f.user); got != 100000 { + t.Fatalf("already-paid topup was lost after cancellation: %d", got) + } +} + +func TestSubscriptionReviewUnrelatedSubscriptionEventsAreIgnored(t *testing.T) { + f := newSubscriptionReviewFixture(t) + for _, event := range []string{"customer.subscription.updated", "customer.subscription.deleted"} { + object := map[string]any{"id": "sub_pilot_unrelated", "object": "subscription", "livemode": false, "status": "active", "metadata": map[string]any{"pilot_order_id": "another-product-order"}} + expect(t, deliver(f.server, "evt_review_unrelated_"+event, event, object), 200) + } + if got := paymentBalance(t, f.server, f.user); got != 0 { + t.Fatalf("another product changed Cosift credit balance%d", got) + } +} + +func TestSubscriptionReviewConcurrentInvoiceDelivery(t *testing.T) { + f := newSubscriptionReviewFixture(t) + var wg sync.WaitGroup + for i := range 12 { + wg.Go(func() { + w := deliver(f.server, fmt.Sprintf("evt_review_concurrent_%d", i), "invoice.paid", f.invoices["in_review"]) + if w.Code != 200 { + t.Errorf("concurrent invoice status %d: %s", w.Code, w.Body) + } + }) + } + wg.Wait() + if got := paymentBalance(t, f.server, f.user); got != 50000 { + t.Fatalf("concurrent invoice minted %d credits", got) + } + var invoiceCount, ledgerCount int + if err := f.server.db.QueryRow(`SELECT count(*) FROM subscription_invoices`).Scan(&invoiceCount); err != nil { + t.Fatal(err) + } + if err := f.server.db.QueryRow(`SELECT count(*) FROM credit_ledger WHERE reason='stripe_purchase'`).Scan(&ledgerCount); err != nil { + t.Fatal(err) + } + if invoiceCount != 1 || ledgerCount != 1 { + t.Fatalf("concurrent durable records: invoices %d, grants %d", invoiceCount, ledgerCount) + } +} + +func TestSubscriptionReviewRefundBeforeInvoiceRetriesWithoutMinting(t *testing.T) { + f := newSubscriptionReviewFixture(t) + charge := map[string]any{"id": "ch_review", "object": "charge", "customer": "cus_review", "payment_intent": "pi_review", "amount": 500, "amount_refunded": 500, "currency": "usd", "livemode": false, "metadata": map[string]any{}} + first := deliver(f.server, "evt_review_early_refund", "charge.refunded", charge) + if first.Code < 500 { + t.Fatalf("early refund was acknowledged without durable reconciliation: %d", first.Code) + } + if got := paymentBalance(t, f.server, f.user); got != 0 { + t.Fatalf("early refund changed ledger %d", got) + } + expect(t, deliver(f.server, "evt_review_later_paid", "invoice.paid", f.invoices["in_review"]), 200) + expect(t, deliver(f.server, "evt_review_early_refund", "charge.refunded", charge), 200) + if got := paymentBalance(t, f.server, f.user); got != 0 { + t.Fatalf("retried refund left incorrect ledger %d", got) + } + if _, err := f.server.requireTopup(context.Background(), f.user.ID); err == nil { + t.Fatal("fully refunded subscription permitted a top-up") + } +} + +func TestSubscriptionReviewRefundCustomerMismatchNeverDebits(t *testing.T) { + f := newSubscriptionReviewFixture(t) + expect(t, deliver(f.server, "evt_review_paid", "invoice.paid", f.invoices["in_review"]), 200) + charge := map[string]any{"id": "ch_review", "object": "charge", "customer": "cus_another_account", "payment_intent": "pi_review", "amount": 500, "amount_refunded": 500, "currency": "usd", "livemode": false, "metadata": map[string]any{}} + w := deliver(f.server, "evt_review_refund_other_customer", "charge.refunded", charge) + if w.Code < 400 { + t.Fatalf("mismatched customer refund accepted: %d", w.Code) + } + if got := paymentBalance(t, f.server, f.user); got != 50000 { + t.Fatalf("another customer's refund debited %d", got) + } +} + +func TestSubscriptionReviewUnrelatedInvoiceAndRefundAreIgnored(t *testing.T) { + f := newSubscriptionReviewFixture(t) + unrelated := reviewClone(f.invoices["in_review"]) + unrelated["id"] = "in_pilot_unrelated" + unrelated["customer"] = "cus_pilot_unrelated" + unrelated["parent"] = map[string]any{"type": "subscription_details", "subscription_details": map[string]any{"subscription": "sub_pilot_unrelated", "metadata": map[string]any{"pilot_order_id": "another-product-order"}}} + expect(t, deliver(f.server, "evt_review_pilot_paid", "invoice.paid", unrelated), 200) + expect(t, deliver(f.server, "evt_review_pilot_failed", "invoice.payment_failed", unrelated), 200) + f.server.paymentClient.Transport = pageTransport(func(r *http.Request) (*http.Response, error) { + var body any + switch r.URL.Path { + case "/v1/invoice_payments": + body = map[string]any{"object": "list", "has_more": false, "data": []any{map[string]any{"id": "inpay_pilot", "invoice": "in_pilot_unrelated", "amount_paid": 500, "currency": "usd", "status": "paid", "livemode": false, "payment": map[string]any{"type": "payment_intent", "payment_intent": "pi_pilot_unrelated"}}}} + case "/v1/invoices/in_pilot_unrelated": + body = unrelated + default: + t.Errorf("unrelated payment reached Cosift subscription: %s", r.URL.Path) + return nil, fmt.Errorf("unexpected request") + } + data, _ := json.Marshal(body) + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(data))), Request: r}, nil + }) + charge := map[string]any{"id": "ch_pilot_unrelated", "object": "charge", "customer": "cus_pilot_unrelated", "payment_intent": "pi_pilot_unrelated", "amount": 500, "amount_refunded": 500, "currency": "usd", "livemode": false, "metadata": map[string]any{"pilot_order_id": "another-product-order"}} + expect(t, deliver(f.server, "evt_review_pilot_refund", "charge.refunded", charge), 200) + if got := paymentBalance(t, f.server, f.user); got != 0 { + t.Fatalf("another product changed Cosift credit balance %d", got) + } +} + +func TestSubscriptionReviewCanonicalInvoiceAndFailedRenewal(t *testing.T) { + f := newSubscriptionReviewFixture(t) + // A stale open snapshot does not override Stripe's current paid invoice. + snapshot := reviewClone(f.invoices["in_review"]) + snapshot["status"] = "open" + snapshot["amount_paid"] = 0 + expect(t, deliver(f.server, "evt_review_normalized_paid", "invoice.paid", snapshot), 200) + if got := paymentBalance(t, f.server, f.user); got != 50000 { + t.Fatalf("canonical paid invoice was not credited: %d", got) + } + failed := reviewClone(f.invoices["in_review"]) + failed["id"] = "in_review_failed_renewal" + failed["status"] = "open" + failed["amount_paid"] = 0 + failed["amount_remaining"] = 500 + f.invoices["in_review_failed_renewal"] = failed + f.subscription["status"] = "past_due" + expect(t, deliver(f.server, "evt_review_failed_renewal", "invoice.payment_failed", failed), 200) + // A misleading paid snapshot still cannot mint for the canonical unpaid invoice. + falsePaid := reviewClone(failed) + falsePaid["status"] = "paid" + falsePaid["amount_paid"] = 500 + falsePaid["amount_remaining"] = 0 + w := deliver(f.server, "evt_review_stale_paid", "invoice.paid", falsePaid) + if w.Code < 400 { + t.Fatalf("snapshot overrode canonical unpaid invoice: %d", w.Code) + } + if got := paymentBalance(t, f.server, f.user); got != 50000 { + t.Fatalf("failed renewal altered purchased carryover: %d", got) + } + if _, err := f.server.requireTopup(context.Background(), f.user.ID); err == nil { + t.Fatal("past-due subscription permitted a top-up") + } +} diff --git a/internal/community/web/app.js b/internal/community/web/app.js index 8beef73..383dac8 100644 --- a/internal/community/web/app.js +++ b/internal/community/web/app.js @@ -25,6 +25,7 @@ function resetAccount(nextUser = null) { currentMode = "search"; selected = new Set(); checkoutKey = undefined; + subscriptionCheckoutKey = undefined; guestUntil = 0; for (const id of ["results", "saved-list", "contribution-list", "topics", "suggestions", "shared-list", "shared-result"]) $(id).replaceChildren(); @@ -35,6 +36,10 @@ function resetAccount(nextUser = null) { $("monthly-credits").hidden = true; $("buy-credits").hidden = true; $("buy-credits").disabled = false; + $("subscribe-credits").hidden = true; + $("subscribe-credits").disabled = false; + $("manage-subscription").hidden = true; + $("manage-subscription").disabled = false; $("payment-info").hidden = true; $("search-heading").hidden = true; $("search-empty").hidden = false; @@ -278,6 +283,8 @@ async function refreshCredits() { $("credit-balance").hidden = !user; $("monthly-credits").hidden = true; $("buy-credits").hidden = true; + $("subscribe-credits").hidden = true; + $("manage-subscription").hidden = true; $("payment-info").hidden = true; if (user) { const c = await api("credits"); @@ -286,8 +293,25 @@ async function refreshCredits() { $("billing-balance").textContent = Number(c.balance).toLocaleString(); $("billing-free").textContent = Number(c.monthly_free_credits || 1000).toLocaleString(); $("billing-mode-status").textContent = c.payments_enabled - ? c.payment_mode === "test" ? "Test checkout · no real charges. Test credits are for this test environment only." : "Secure checkout with Stripe. One-time payment, no subscription." - : "Credit purchases are coming soon. Your free monthly credits are available now."; + ? c.payment_mode === "test" ? "Test checkout · no real charges. Test credits are for this test environment only." : "Secure billing with Stripe. Manage your subscription and payment method here." + : "Paid plans are coming soon. Your free monthly credits are available now."; + const subscription = c.subscription || {status: "none", active: false}; + const renewal = subscription.current_period_end ? date(subscription.current_period_end) : ""; + $("billing-subscription-status").textContent = subscription.active + ? subscription.cancel_at_period_end ? `Subscription ends ${renewal}. Unused credits stay in your account.` : `Subscription active${renewal ? ` · renews ${renewal}` : ""}.` + : subscription.status === "past_due" || subscription.status === "unpaid" ? "Payment needs attention. Update your payment method to restore your subscription and top-ups." + : subscription.status === "incomplete" ? "Subscription payment is pending. Complete payment before buying top-ups." + : "You’re on Free. Keep 1,000 free credits every month; no subscription required."; + $("billing-topup-status").textContent = c.can_top_up + ? "Add credits whenever you need them. This is a one-time payment." + : "Top-ups unlock with an active paid subscription."; + $("manage-subscription").hidden = !c.portal_available; + if (c.subscription_plan) { + const plan = c.subscription_plan; + $("billing-plan-price").textContent = new Intl.NumberFormat("en-US", {style: "currency", currency: plan.currency}).format(plan.amount_cents / 100); + $("billing-plan-credits").textContent = Number(plan.credits).toLocaleString(); + $("subscribe-credits").hidden = !c.payments_enabled || !["none", "canceled", "incomplete_expired"].includes(subscription.status); + } if (c.monthly) { $("monthly-credits").hidden = false; $("credit-month").textContent = `This month · ${c.monthly.month} (UTC)`; @@ -298,29 +322,47 @@ async function refreshCredits() { const price = new Intl.NumberFormat("en-US", {style: "currency", currency: pack.currency}).format(pack.amount_cents / 100); $("billing-pack-price").textContent = price; $("billing-pack-credits").textContent = Number(pack.credits).toLocaleString(); - $("buy-credits").textContent = `Buy ${pack.credits.toLocaleString()} credits · ${price}`; - $("buy-credits").hidden = !c.payments_enabled; + $("buy-credits").textContent = `Top up ${pack.credits.toLocaleString()} credits · ${price}`; + $("buy-credits").hidden = !c.payments_enabled || !c.can_top_up; $("payment-info").hidden = false; $("payment-info").textContent = "Extra requests: Search 1 credit · Answer 2 credits · Research 3 credits. Existing rate caps apply."; } } } -let checkoutKey; -$("buy-credits").onclick = async () => { - const button = $("buy-credits"); +let checkoutKey, subscriptionCheckoutKey; +async function startCheckout(kind) { + const button = $(kind === "subscription" ? "subscribe-credits" : "buy-credits"); + if (button.disabled) return; button.disabled = true; - checkoutKey ||= crypto.randomUUID(); + const key = kind === "subscription" ? subscriptionCheckoutKey ||= crypto.randomUUID() : checkoutKey ||= crypto.randomUUID(); try { - const checkout = await api("payments/checkout", "POST", {idempotency_key: checkoutKey}); + const checkout = await api("payments/checkout", "POST", {kind, idempotency_key: key}); const destination = new URL(checkout.url); if (destination.protocol !== "https:" || destination.host !== "checkout.stripe.com" || destination.username || destination.password) throw new Error("Invalid checkout destination."); location.assign(destination.href); } catch (e) { - if (e.status === 409) checkoutKey = undefined; + if (e.status === 409) { + if (kind === "subscription") subscriptionCheckoutKey = undefined; + else checkoutKey = undefined; + } notify(e.message, true); button.disabled = false; } +} +$("buy-credits").onclick = () => startCheckout("topup"); +$("subscribe-credits").onclick = () => startCheckout("subscription"); +$("manage-subscription").onclick = async () => { + const button = $("manage-subscription"); + if (button.disabled) return; + button.disabled = true; + try { + const portal = await api("payments/portal", "POST", {}); + const destination = new URL(portal.url); + if (destination.protocol !== "https:" || destination.host !== "billing.stripe.com" || destination.username || destination.password) + throw new Error("Invalid billing portal destination."); + location.assign(destination.href); + } catch (e) { notify(e.message, true); button.disabled = false; } }; async function showPaymentReturn() { const result = new URLSearchParams(location.search).get("payment"); diff --git a/internal/community/web/index.html b/internal/community/web/index.html index 015bde2..2b5ce14 100644 --- a/internal/community/web/index.html +++ b/internal/community/web/index.html @@ -118,7 +118,7 @@

Choose your interests

- +
-

-

Coverage is growing. Contribute useful pages to help fill the gaps.

+
+

Coverage is growing. Contribute useful pages to help fill the gaps.

+
Usage limits

+