diff --git a/.ilana/architecture.md b/.ilana/architecture.md index 1a3cc6f..f0521ae 100644 --- a/.ilana/architecture.md +++ b/.ilana/architecture.md @@ -461,8 +461,8 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u ## Data Retention & GDPR Tooling (v0.46; design decision DEC-205) -- `tenants.retention_days` (nullable INTEGER, migration 000025): per-tenant retention window override. NULL means "use `database.DefaultRetentionDays`" (90), not "retain forever" — set via `mailx set-retention -tenant -days `, read via `mailx show-retention -tenant `. -- `database.PurgeExpiredMessages`: one tenant-joined query finds every message older than `COALESCE(tenants.retention_days, 90)` days, then hard-deletes them in a transaction — recipients/delivery_attempts/events cascade via existing FKs; `broadcast_recipients.message_id` (which has no FK) is explicitly nulled first. Runs as the `retention-purge` component (hourly ticker, same shape as `idempotency-cleanup`) and via `mailx purge-expired` for manual/cron use. Callers must also call `storage.FileStore.Delete(id)` for each purged id — the database function only owns the DB side. +- `tenants.retention_days` (nullable INTEGER, migration 000025): per-tenant retention window override. NULL means "use the default window", not "retain forever": the flat `database.DefaultRetentionDays` (90) when billing is not configured, or the tenant plan's RetentionDays (7/30/90) when plan enforcement is on (DEC-224) — set via `mailx set-retention -tenant -days `, read via `mailx show-retention -tenant `. +- `database.PurgeExpiredMessages`: one tenant-joined query finds every message older than `COALESCE(tenants.retention_days, )` days, then hard-deletes them in a transaction — recipients/delivery_attempts/events cascade via existing FKs; `broadcast_recipients.message_id` (which has no FK) is explicitly nulled first. Runs as the `retention-purge` component (hourly ticker, same shape as `idempotency-cleanup`) and via `mailx purge-expired` for manual/cron use. Callers must also call `storage.FileStore.Delete(id)` for each purged id — the database function only owns the DB side. - GDPR subject requests are operator-only CLI, not an API endpoint (matches v0.19's tenant/key-management precedent): `mailx gdpr-export -tenant -email` (read-only: contact record, audience memberships, recipient history) and `mailx gdpr-delete -tenant -email -confirm` (deletes the contact row + matching recipient rows only — never the parent message, other recipients on it, or suppressions). - Recipient-address matching (both retention-adjacent GDPR lookups) decodes bracket/bare/display-name forms via `net/mail.ParseAddress` before running the result through `contact.Normalize` — `contact.Normalize` alone rejects display-name input, so it can't be called directly on a raw stored recipient address. @@ -493,3 +493,11 @@ Redis queue polling (200ms..2s), not blocking primitives (multi-condition wake-u - Password reset (DEC-213): migration 000029 adds `password_reset_tokens` (human_id FK CASCADE, `token_hash` unique-indexed, `expires_at`, `used_at` nullable, partial index on unresolved tokens per human). `Service.ForgotPassword`/`ResetPassword`: 5-minute single-use tokens (`PasswordResetTokenTTL`), same anti-enumeration response shape as `Login`, one collapsed `ErrPasswordResetTokenInvalid` for any unknown/expired/used/race-lost token. `database.ResetPassword` is one transaction: consume-token (RowsAffected-checked), update `password_hash`, revoke every refresh token the account has. Email delivery reuses `api.SubmissionAcceptor` (the v0.42 accept-a-message primitive) via a new `humanauth.Mailer` interface, configured by `MAILX_SYSTEM_TENANT_ID`/`MAILX_SYSTEM_FROM_ADDRESS`/`MAILX_DASHBOARD_BASE_URL`; unset means no mailer (token still created, logged not sent) rather than a startup failure. New routes `POST /v1/auth/forgot-password`/`reset-password`, public, behind their own `passwordResetIPLimitMiddleware` bucket (`Policy.PasswordResetIPRate`/`PasswordResetIPBurst`, default 1/60 rps burst 3 — much tighter than login's, since forgot-password sends a real email per call). - Organization invitations (DEC-216): migration 000030 adds `org_invitations` (tenant_id FK CASCADE, invited_by FK CASCADE, `normalized_email`/`raw_email`, `token_hash` unique-indexed, `expires_at`, `accepted_at` nullable, partial index on `(tenant_id, normalized_email) WHERE accepted_at IS NULL`) plus plain nullable URL columns `tenants.logo_url` and `humans.avatar_url` (no upload pipeline — operator decision). `Service.InviteToOrganization(ctx, inviterHumanID, tenantID, email)`: owner-only (`database.IsTenantOwner` re-checked server-side, `ErrNotOrgOwner` otherwise — no broader RBAC), 5-hour single-use tokens (`OrgInvitationTTL`), same `humanauth.Mailer`/`WithDashboardBaseURL` delivery wiring as password reset, degrades the same way when no mailer is configured. `Service.AcceptOrgInvitation(ctx, rawToken, existingHumanID, signupName, signupPassword)` is a single combined accept path (operator decision, not separate signup-then-join calls): with an existing session, the invitation's email must match that account's own (`ErrOrgInvitationEmailMismatch` otherwise) and `database.AcceptOrgInvitationForExistingHuman` atomically consumes the token and inserts `tenant_members` (`ON CONFLICT DO NOTHING`); with no session, `database.AcceptOrgInvitationWithSignup` atomically consumes the token, creates the human account (ALWAYS using the invitation's own stored email, never client-supplied), and inserts membership, all in one transaction. Both failure paths collapse to `ErrOrgInvitationInvalid`. New routes: `POST /v1/orgs/{id}/invites` (owner-only, behind `humanAuthMiddleware` then a new per-human `orgInviteLimitMiddleware` — `Policy.OrgInviteRate`/`OrgInviteBurst`, default 1/30 rps burst 10, keyed by the inviting human's ID rather than IP since the caller is already authenticated) and `POST /v1/orgs/invites/accept` (deliberately NOT behind `humanAuthMiddleware` — the invitee may have no account yet; reads an optional bearer token directly). - Known limitation / explicitly deferred (not built): Paystack/billing, a `plan` field, email verification, OAuth/social login, MFA, and any RBAC beyond owner/member. The frontend's `{name, slug}` org-create contract is accepted; `slug` is currently a no-op input (tenants has no slug column yet). Avatar/logo are URL-only fields with no upload/hosting pipeline. + +## Billing & Plans (v0.47 phase 2; design decisions DEC-221..225) + +- Plans (`internal/billing/plans.go`, `billing.Plans`/`PlanFor`, unknown -> free; `billing.Unlimited` = 0, the ratelimit.Policy "zero = off" convention): free / plus ($6) / pro ($24), limits in milestones.md. Stored on `tenants.plan` (CHECK free|plus|pro, default free), `plan_status` (active|lapsed), `plan_current_period_end`, `paystack_customer_code` (migration 000031). +- Single enforcement switch: `database.DB.EnablePlanEnforcement()`, on iff `MAILX_PAYSTACK_SECRET_KEY` is set. Off = self-hosted = unlimited, billing routes unregistered. Plan-limit refusals wrap `database.ErrPlanLimit`; API maps them to 429 `daily_send_limit_reached` (volume) or 403 `plan_limit_reached` (caps/features). +- Enforcement points: `admitSend` (daily UTC-day message count), `domainHandler.handleCreate` (non-deleted domains), `humanauth.InviteToOrganization` (early) + `AcceptOrgInvitationForExistingHuman`/`WithSignup` (authoritative, tenant row `FOR UPDATE`), `broadcastHandler.handleCreate`, `webhookHandler.handleCreate`, retention purge default. +- Paystack: `POST /v1/billing/checkout` (owner-only, body `{tenant_id, plan}`, Initialize Transaction in USD cents with metadata `{tenant_id, plan}`, returns `authorization_url`); `GET /v1/billing/subscription?tenant_id=` (any member); `POST /v1/billing/webhook` (public, x-paystack-signature HMAC-SHA512 verified, charge.success applies plan for 30 days, `billing_payments.reference` PK makes it idempotent). `plan-lapse` hourly component downgrades expired paid plans to free/lapsed; no auto-renewal (RSK-044). +- Config: `MAILX_PAYSTACK_SECRET_KEY` (enables billing + enforcement), `MAILX_PAYSTACK_PUBLIC_KEY` (frontend only, unused server-side), optional `MAILX_PAYSTACK_CALLBACK_URL`, optional `MAILX_PAYSTACK_BASE_URL` (tests/tooling). diff --git a/.ilana/decisions.md b/.ilana/decisions.md index 6141bb8..4940a85 100644 --- a/.ilana/decisions.md +++ b/.ilana/decisions.md @@ -225,3 +225,10 @@ Process decisions above (DEC-001..DEC-007) belong to the v0.23 FLEET run and sta - DEC-218 [org invitations, second Greptile pass — self-caught regression]: fixed a P1 finding on DEC-217's own dedup fix (PR #23): `CreateOrgInvitation` invalidated the prior pending invitation and committed the new one BEFORE attempting to send the new invitation's email. A failed send (mailer outage, transient network error) left the invitee with neither a working old link (already expired) nor a delivered new one (never sent) — a strict regression versus having no dedup logic at all. Fixed by splitting invalidation out of insertion entirely: `CreateOrgInvitation` now only inserts, and a new `SupersedeOtherPendingOrgInvitations` is called from `InviteToOrganization` ONLY after `mailer.SendSystemEmail` returns successfully — a send failure now returns an error with the prior invitation (if any) still fully valid, and no `CreateOrgInvitation` call succeeds without the caller already having confirmed a mailer and dashboard URL are configured (reordered those checks ahead of token creation too, so a misconfigured deployment never creates an unsendable invitation row in the first place). Proven by a new `TestFailedResendDoesNotInvalidateTheWorkingLink`, using a `fakeMailer.failNext` toggle added for this purpose. All verified: gofmt/go vet/go build clean, full `go test ./...` and `go test -race ./...` clean, Docker rebuild+boot smoke clean. - DEC-219 [org invitations, third Greptile pass — self-caught regression]: fixed a P1 finding on DEC-218's own fix (PR #23): `SupersedeOtherPendingOrgInvitations` excluded only the caller's own invitation ID (`id != keepID`), so two invitations sent to the same address at nearly the same instant would each try to supersede the OTHER after both had already been delivered — whichever UPDATE happened to run last won, invalidating the link that had JUST been emailed. Both requests reported success; neither delivered link worked. Fixed by superseding only rows created STRICTLY BEFORE the keeper's own `created_at`, not merely "not this row" — this makes the operation commutative: the newest invitation always survives regardless of which concurrent request's UPDATE runs last, and a strictly-older comparison can never have two rows mutually supersede each other (if A is older than B, only A can be superseded by B; B can never be superseded by A). Proven by a new `TestConcurrentResendsNeverInvalidateBothLinks` (5 concurrent `InviteToOrganization` calls to the same address, asserts exactly 1 of the 5 emailed links remains valid afterward — not 0, not 2). All verified: gofmt/go vet/go build clean, full `go test ./...` and `go test -race ./...` clean, Docker rebuild+boot smoke clean. - DEC-220 [org invitations, fourth Greptile pass]: fixed 2 P1/P2 findings on DEC-219's fix (PR #23). (1) "Equal timestamps leave duplicate links" (P2): the strict `created_at < keepCreatedAt` comparison has no defined winner when two rows share the exact same microsecond-precision timestamp (Postgres can produce this for two near-simultaneous inserts) — both rows would then see themselves as "not older" and neither superseded the other. Fixed by comparing the full `(created_at, id)` tuple lexicographically instead of `created_at` alone: `id` (a crypto-random `newID()`) breaks any timestamp tie, giving a genuine total order with exactly one winner regardless of which concurrent request's query runs first. (2) "Concurrent sends race in test" (P1): the new `TestConcurrentResendsNeverInvalidateBothLinks` fired 5 goroutines against one shared `fakeMailer`, whose `SendSystemEmail` appended to a plain slice with no synchronization — a real data race under `go test -race`, and one that could also silently under-count recorded calls even when it didn't trip the race detector. Fixed by adding a `sync.Mutex` to `fakeMailer`, held for the whole append. Verified: gofmt/go vet/go build clean, full `go test ./...` and `go test -race ./...` clean (re-ran the invitation tests 3x with `-race` to check for flakiness, all stable), Docker rebuild+boot smoke clean. This closes the org-invitations review cycle for this PR: DEC-217 (initial fixes) → DEC-218 (failed-send regression) → DEC-219 (concurrent-resend regression) → DEC-220 (tie-break + test race) — four consecutive passes, each catching something genuinely new in the prior pass's own fix, none stale re-postings (confirmed each time by comparing comment timestamps against the prior fix commit's landing time). +- DEC-221 [v0.47 phase 2 billing]: plan enforcement has ONE switch, `database.DB.EnablePlanEnforcement()` (an `atomic.Bool` on DB, default off), turned on by cmd/mailx exactly when `MAILX_PAYSTACK_SECRET_KEY` is set (`buildBillingConfig`, and `connectForAdmin` for the admin CLI). All six enforcement points go through DB methods (`CheckDailySendLimit`, `CheckDomainLimit`, `CheckMemberLimit`/`lockMemberCap`, `CheckFeature`, the retention purge query) that return nil without touching the DB when the switch is off. Why: every tenant gets `plan = 'free'` from migration 000031, so gating on "billing configured" rather than on the plan column is what keeps self-hosted deployments at their exact pre-billing unlimited behavior. Tests `TestPlanChecksInertWhenEnforcementDisabled`, `TestRetentionDefaultFlatWhenNotEnforced`, `TestBillingRoutesAbsentWhenNotConfigured` pin this. +- DEC-222 [v0.47 phase 2 billing]: daily send volume = count of the tenant's `messages` rows with `created_at` in the current UTC calendar day, counted bounded (`LIMIT plan.DailySends`, the CountTenantQueued pattern, served by idx_messages_tenant_created). Checked first in `admitSend` (before and independent of abuse controls), so it covers POST /v1/emails, batch, and SMTP submission (all go through acceptOne). Refusal is 429 `daily_send_limit_reached` with Retry-After = seconds to UTC midnight. It is a check, not a reservation: concurrent requests can overshoot by their in-flight count (accepted, soft cap). Broadcast expansion does NOT pass through admitSend and is not counted against the daily cap (RSK-045). +- DEC-223 [v0.47 phase 2 billing]: team-member cap is checked at BOTH invite-send time (`CheckMemberLimit`, early UX only) and accept time. The accept-time check (`lockMemberCap`) runs inside both accept transactions after the invitation is consumed, takes `SELECT ... FROM tenants WHERE id = $1 FOR UPDATE`, then counts members; a refusal rolls back so the invitation stays unconsumed and is usable after an upgrade. The row lock serializes concurrent accepts for one tenant, so racing accepts cannot put an org over its cap (`TestMemberCapConcurrentAccepts`: 8 racers, Plus cap 5 -> exactly 4 succeed). An invitee who is already a member is never refused. Domain cap has no such lock (two concurrent creates can exceed it by one); accepted as low-impact. +- DEC-224 [v0.47 phase 2 billing]: retention default follows the plan only under enforcement. The purge query now resolves `COALESCE(retention_days, CASE WHEN enforced THEN plan days (free 7 / plus 30 / pro 90) ELSE DefaultRetentionDays END)`; an explicit `retention_days` still wins. `DB.DefaultRetentionDaysFor(plan)` is used by `mailx show-retention`/`set-retention` output. A tenant lapsing from Plus/Pro to Free therefore has older data purged at the next hourly run. +- DEC-225 [v0.47 phase 2 billing]: Paystack integration is Initialize Transaction + webhook only (no Paystack Subscriptions/plan codes). `POST /v1/billing/webhook` is public and authenticated solely by `x-paystack-signature` = hex HMAC-SHA512(secret key, raw body), verified with `hmac.Equal` before any parsing; failure is 401. A verified `charge.success` is applied only if status=success, currency=USD, amount >= plan price, metadata names a tenant and a paid plan; it sets plan/active/period_end = now+30d. Replays are neutralized by `billing_payments.reference` PRIMARY KEY inserted in the same transaction as the plan update (duplicate -> rollback, 200 `already_applied`). Unknown events and signed-but-unusable payloads get 200 (no Paystack retry); only our storage errors are 5xx. Checkout/subscription take the org as `tenant_id` (body/query) because a human may own several orgs; checkout is owner-only, subscription any member (non-member gets 404, no enumeration). +- DEC-226 [org invitations, CI caught what Greptile's trial limit ended before finding]: Greptile's free-trial credit limit was exhausted after PR #23 merged (no more automated review available going forward - manual review is now this project's primary line of defense, alongside CI). CI's own `-race` run on PR #24 caught a genuine, deeper bug in DEC-219/220's own fix: `TestConcurrentResendsNeverInvalidateBothLinks` failed intermittently with 2 survivors instead of 1, reproducing locally at roughly 50% under `go test -count=25`. Root cause: the `(created_at, id)` tuple comparison in `SupersedeOtherPendingOrgInvitations` assumes a smaller tuple means "already committed, therefore visible to a later query" - true only if inserts for the same address are serialized, which they were not. PostgreSQL's `now()` is fixed at a transaction's BEGIN, not its commit, so two genuinely concurrent autocommit INSERTs can commit in a DIFFERENT order than their `created_at` values suggest; a row with a small timestamp can still become visible to a later query AFTER that query's snapshot was already taken, meaning nothing ever supersedes it. Fixed properly this time with two changes together (verified each is independently necessary): (1) `CreateOrgInvitation` now holds `pg_advisory_xact_lock(hashtextextended(tenant_id||':'||normalized_email, 0))` for the duration of its own transaction, fully serializing concurrent inserts for the same address (the lock is scoped to a short transaction that commits well before the slower `SendSystemEmail` network call that follows - never held across it, preserving DEC-218's fix). (2) `created_at` is now set explicitly via `clock_timestamp()` in the INSERT rather than left to the column's `now()` default - critical, because a transaction that waited on the advisory lock would otherwise still capture an EARLIER timestamp (from its own BEGIN) than one that acquired the lock and committed first, silently reintroducing the exact ordering violation the lock exists to prevent. The advisory lock alone was proven insufficient by testing: re-ran the stress test 25x after adding only the lock and it still failed roughly half the time; only after also switching to `clock_timestamp()` did 40/40 stress runs (and the full `-race` suite) pass cleanly. This is the fifth fix-on-a-fix in this feature's review history (DEC-217→218→219→220→226), and the first one Greptile never got to review - a reminder that "manual review = read the diff and reason about it" is not equivalent to "manual review = actually stress-test the concurrency claim under `-count=N` before trusting it," especially now that automated review isn't available as a backstop. Verified: gofmt/go vet/go build clean, full `go test ./...` and `go test -race ./...` clean, `TestConcurrentResendsNeverInvalidateBothLinks` run 40x consecutively with `-race` with zero failures, Docker rebuild+boot smoke clean. +- DEC-227 [billing plans, CodeRabbit review pass]: Greptile's trial is exhausted (see DEC-226); CodeRabbit is now active on this repo and reviewed PR #24, catching 3 real findings, 2 of them data-integrity-critical. (1) **Data loss on billing enablement or plan lapse** (the most severe): with plan enforcement on, a NULL `tenants.retention_days` falls back to the tenant's PLAN window (Free = 7 days) instead of the flat `DefaultRetentionDays` (90). An operator turning on `MAILX_PAYSTACK_SECRET_KEY` for the first time on an existing deployment would silently shrink every pre-existing tenant's retention window, and the next hourly `retention-purge` run would irreversibly hard-delete any terminal message between 7 and 90 days old; the same happens to a Plus/Pro tenant the instant `plan-lapse` downgrades it (a renewal running even one hour late triggers it). Fixed two ways: migration 000031 now backfills `retention_days = 90` for every tenant that has no explicit value BEFORE the plan columns' semantics can apply (pins pre-existing tenants at today's effective window; a brand-new tenant created after billing is enabled has no messages yet, so its plan's window applying from day one is correct, not a regression) - and `DowngradeLapsedPlans` now pins `retention_days` to the lapsing plan's own window (`COALESCE(retention_days, CASE plan WHEN 'plus' THEN 30 WHEN 'pro' THEN 90 END)`) before clearing `plan`, so a lapse only ever changes billing state, never retention behavior. (2) **Renewal loses remaining paid days** (major): `ApplyPlanPayment` overwrote `plan_current_period_end` to `now + 30 days` regardless of any remaining time on the current period, so an owner renewing 5 days early paid for 30 days but only received 25. Fixed by changing the parameter from an absolute `periodEnd` to a `period time.Duration`, and extending from the LATER of `now()` and the existing `plan_current_period_end` when the tenant is renewing the SAME plan while still active; a plan CHANGE (upgrade/downgrade) or a renewal after the plan had already lapsed still starts a fresh period from now, since carrying over time priced under a different plan has no well-defined meaning. (3) **Signed-but-unusable webhook payloads answered 400 instead of 200** (minor): Paystack can send non-object `metadata` (e.g. `0` or `""`) for a transaction MailX's own checkout never created (a payment page or another integration on the same Paystack account); `Metadata`'s plain-struct JSON decoding failed on those, and `handleWebhook`'s contract requires unrecognized/unusable-but-validly-signed payloads to be acknowledged 200 (Paystack retries forever on anything else). Fixed with a custom `Metadata.UnmarshalJSON` that treats a decode failure as an empty (not erroring) `Metadata` - the handler already ignores an empty `TenantID`. All three fixes covered by new/updated tests (`TestParseEventToleratesNonObjectMetadata`; `TestApplyPlanPaymentAndReplay` extended with same-plan-extends and different-plan-fresh-period cases; `TestDowngradeLapsedPlans` extended to assert the pinned retention window). Verified: gofmt/go vet/go build clean, full `go test ./...` and `go test -race ./...` clean, migration 000031 round-trip re-validated with the new backfill statement, Docker rebuild+boot smoke clean. diff --git a/.ilana/ledger.md b/.ilana/ledger.md index 149349b..d54b519 100644 --- a/.ilana/ledger.md +++ b/.ilana/ledger.md @@ -317,3 +317,12 @@ Greptile's re-review caught a second regression, this time in DEC-218's own fix: ## 2026-09-25 | org invitations fourth Greptile pass | GATE PASS Final review pass on PR #23's org-invitations work: 2 more findings, both on the concurrency fix from the immediately prior pass. A P2 (two invitations sharing the same microsecond-precision created_at would neither supersede the other, since the comparison was strict-less-than on a value that isn't guaranteed unique) fixed by comparing the (created_at, id) tuple instead of created_at alone, giving a true total order. A P1 (the new concurrent regression test's shared fakeMailer had an unsynchronized slice append, a genuine data race under -race) fixed with a mutex. Re-ran the invitation test suite 3x with -race to confirm no flakiness before considering this settled. This was the fourth consecutive Greptile pass on the same feature, each one catching a real defect in the previous pass's own fix rather than repeating stale findings - a reminder that a "fix" for a concurrency bug needs to be checked for the SAME class of bug it was written to prevent. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-220), state.json (DEC counter). + +## 2026-09-25 | v0.47 phase 2 billing & plans | GATE PASS +Added Free/Plus/Pro plans, Paystack checkout + signed webhook, plan-lapse ticker, and plan enforcement at six points, all inert unless MAILX_PAYSTACK_SECRET_KEY is set (DEC-221..225, RSK-044/045, migration 000031). Evidence: gofmt/go vet clean; `go test -race ./...` clean with MAILX_TEST_DATABASE_URL + REDIS_ADDR set (0 skips in database/api/humanauth); 000031 down/up round-trip on a disposable database; `docker compose up -d --build` boots with no ERROR logs and logs billing_disabled. + +## 2026-09-25 | org invitations: real fix for the concurrent-resend race (Greptile trial exhausted) | GATE PASS +Greptile's free trial hit its credit limit after PR #23, so PR #24 (billing) got no automated review - only Commit Policy/Vuln Scan/Build+Test ran. Did a deliberate manual line-by-line review of the billing agent's work in its place (webhook signature verification, authorization checks, race-safety of the member-cap lock, plan/price server-side authority) before pushing, since there's no longer an automated safety net. That manual review passed, but CI's own -race run then caught something neither Greptile nor manual reading had: TestConcurrentResendsNeverInvalidateBothLinks - my own regression test from the DEC-219/220 org-invitation fixes - failed intermittently in CI. Reproduced locally (about 50% failure rate under -count=25), diagnosed the actual root cause (PostgreSQL now() is fixed at transaction BEGIN, not commit, so concurrent autocommit inserts can commit out of timestamp order - the exact assumption the DEC-219 tuple-comparison fix depended on), and fixed it properly with an advisory-lock-serialized insert PLUS clock_timestamp() instead of the column's now() default - proved the lock alone was insufficient by testing it in isolation first (still ~50% failure), only trusting the combined fix after 40 consecutive clean stress runs. This is a concrete lesson for working without Greptile: reading a concurrency fix and reasoning it's correct is not the same as stress-testing it under -count=N before trusting it, and that gap is now entirely on us to catch. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-226), state.json (DEC counter). + +## 2026-09-25 | billing plans: CodeRabbit review pass (first reviewer since Greptile's trial ran out) | GATE PASS +Greptile's free-trial credit limit was hit right after PR #23 merged - it can no longer review this repo. CodeRabbit turned out to be configured on the repo too and reviewed PR #24 (billing) in Greptile's place, catching 3 real findings the earlier manual review had missed, 2 of them serious data-integrity bugs: enabling billing on an existing deployment (or a paid plan simply lapsing) could silently shrink a tenant's retention window from 90 days down to Free's 7, and the very next hourly retention-purge run would irreversibly hard-delete anything in that gap - fixed by backfilling retention_days for pre-existing tenants in the migration itself and by pinning the lapsing plan's window explicitly before a lapse clears the plan column, so billing state and retention behavior are now fully decoupled. Also fixed: a plan renewal was overwriting the remaining paid period instead of extending it (an owner renewing early lost days they'd already paid for), and a webhook whose metadata wasn't a JSON object (a real Paystack scenario for transactions MailX didn't create) was answered 400 instead of the required 200, which would make Paystack retry forever. This confirms the plan from the last session: without Greptile, review discipline has to come from us directly, and it's working - CodeRabbit filled the gap this time, but the org-invitation race from the previous entry proves CI's own -race run is just as important a backstop as any external reviewer. Verified: gofmt/go vet/go build clean, full go test ./... and go test -race ./... clean, migration round-trip re-validated, Docker rebuild+boot smoke clean. Ilana updated: decisions.md (DEC-227), state.json (DEC/RSK counters). diff --git a/.ilana/milestones.md b/.ilana/milestones.md index 87f21a2..0edda0f 100644 --- a/.ilana/milestones.md +++ b/.ilana/milestones.md @@ -99,3 +99,10 @@ explicitly deferred to a later phase — v0.47 as a whole is NOT complete. - Tests: `internal/database/humans_test.go` (7 cases, real Postgres) and `internal/humanauth/service_test.go` (7 cases, real Postgres), covering duplicate-email rejection, case-insensitive login, refresh rotation, reuse-of-revoked-token session-wide revocation, logout, atomic org creation (including the failure-leaves-no-orphan case), membership-scoped listing, and JWT round-trip/tamper/expiry. - Follow-up: password reset (migration 000029, DEC-213/214/215) — 5-minute single-use tokens, delivered through MailX's own outbound pipeline, dedicated rate limit, all-refresh-tokens-revoked-on-reset. - Follow-up: organization invitations (migration 000030, DEC-216) — invite by email only (no invite-code flow), owner-only sending, 5-hour single-use tokens, a single combined accept endpoint (existing-session or no-account-yet signup, both atomic), org logo/inviter avatar as plain nullable URL fields, own-pipeline email delivery, dedicated per-human rate limit. 5 new tests in `internal/humanauth/org_invitation_test.go`. + +## v0.47 phase 2 — Billing & Plans (Free/Plus/Pro + Paystack) — COMPLETE (MVP scope; no auto-renewal, see RSK-044) +- Migration 000031: `tenants.plan/plan_status/plan_current_period_end/paystack_customer_code`, `billing_payments` (reference PK for webhook idempotency). +- `internal/billing`: plan table (free 500/day, 5 domains, 1 member, 7d, no broadcasts/webhooks; plus $6 10k/15/5/30d; pro $24 100k/unlimited/unlimited/90d), Paystack Initialize Transaction client, HMAC-SHA512 webhook verification. +- Routes (only when `MAILX_PAYSTACK_SECRET_KEY` set): POST /v1/billing/checkout, GET /v1/billing/subscription, POST /v1/billing/webhook; `plan-lapse` hourly component. +- Enforcement wired into admitSend (daily volume), domain create, invite send + both accept transactions (member cap, row-locked), broadcast create, webhook create, retention purge default. All inert when billing is not configured (DEC-221..225). +- Tests: `internal/billing/billing_test.go`, `internal/database/plans_test.go` (incl. concurrent-accept race), `internal/api/billing_handler_test.go`, `internal/humanauth` member-cap test. Full `go test -race ./...` clean against real Postgres+Redis (0 skips in database/api/humanauth); migration down/up round-trip on a disposable DB; Docker rebuild+boot clean. diff --git a/.ilana/risks.md b/.ilana/risks.md index 256bc68..cfe0b13 100644 --- a/.ilana/risks.md +++ b/.ilana/risks.md @@ -52,3 +52,5 @@ - RSK-041 [low, v0.46 PR review]: `internal/database.findRecipientsByAddress` (GDPR export/erasure subject matching) streams and Go-decodes EVERY recipient row for the tenant to find matches for one address — on a tenant with a large recipient history this is a full scan that could exceed the operator CLI's fixed context deadline. Left as-is deliberately for this PR-review-fix pass: it is an operator-only CLI path (`mailx gdpr-export`/`gdpr-delete`), not a hot request path, and a real fix (an indexed normalized-address column on `recipients`, populated at insert time) touches `messages.go`'s INSERT path — a bigger, separate change not undertaken here given the session's cost ceiling. Revisit if GDPR requests need to run routinely against large tenants, or if the CLI's context deadline is ever hit in practice. - RSK-042 [low, v0.47 phase 1 PR review, decided with the human operator]: `POST /v1/auth/signup` returns a distinct `email_taken` (409) for an already-registered address, while `POST /v1/auth/login` deliberately returns the same generic error for both "wrong password" and "unknown email" (anti-enumeration). This is a real, acknowledged inconsistency — an attacker can use signup instead of login to probe which addresses have accounts. Not fixed: the correct fix is to make signup never synchronously confirm existence (respond identically either way, and notify the real account owner by email that someone tried to sign up with their address) — MailX has no human-facing email-sending capability yet to deliver that notification, and changing signup's response shape would break the already-established `Mailx_fe` mock contract (`{name,email,password} -> session`) this phase was built to match. Mitigated, not eliminated, by the IP-keyed rate limit added in the same PR review pass (`authIPLimitMiddleware`, `MAILX_LIMIT_AUTH_IP_RPS`/`_BURST`), which bounds how fast an attacker can enumerate via signup. Revisit once MailX has an email-verification/notification flow for human accounts (a natural fit for a later auth phase, alongside password reset). - RSK-043 [low, v0.47 phase 1 PR review]: `internal/broadcast/expander.go`'s GDPR-erasure disk cleanup (see DEC-210's `ErrRecipientErased` path) retries 3 times on failure, but a disk error that survives all 3 (a genuine, non-transient FS problem, not just a momentary blip) still leaves the raw message file orphaned on disk with no database row left to ever find or retry it - the `broadcast_recipients` row that would normally drive a retry is already gone (that's what triggered the cleanup in the first place). Logged at Error level and counted via `BroadcastExpansionBatch("erasure_cleanup","error")` so an operator can alert on it, but not auto-healing. A fully durable fix needs a reconciliation sweep (e.g. periodically diff on-disk message directories against `messages` rows and delete orphans), which is a separate, larger change not undertaken here. Revisit if this metric/log ever fires in practice. +- RSK-044 [high, v0.47 phase 2]: paid plans do NOT auto-renew. MVP bills by one-off Paystack Initialize Transaction; the hourly `plan-lapse` component downgrades any paid tenant whose `plan_current_period_end` has passed to `free`/`lapsed`, and the owner must check out again every 30 days. No renewal reminder email exists. Fix: Paystack Subscriptions (needs operator-side plan codes) or charge_authorization with the saved authorization. +- RSK-045 [medium, v0.47 phase 2]: plan-enforcement gaps. (1) Broadcast expansion creates messages without passing admitSend, so broadcasts do not count toward the daily send cap. (2) The daily cap and the domain cap are checks, not reservations; concurrent requests can overshoot slightly. (3) The system tenant (`MAILX_SYSTEM_TENANT_ID`) is subject to its own plan once billing is enabled; operators must set it to pro (`UPDATE tenants SET plan='pro' ...`; there is no CLI for plan changes yet, and plan-lapse would downgrade it if a period end is set) or password-reset/invite mail stops at 500/day. (4) Paystack USD charging requires the Paystack account to be approved for USD; not verifiable from the repo. diff --git a/.ilana/state.json b/.ilana/state.json index 2ea9988..70c417c 100644 --- a/.ilana/state.json +++ b/.ilana/state.json @@ -17,8 +17,8 @@ "TC": 0, "DEF": 27, "CR": 24, - "RSK": 43, - "DEC": 220, + "RSK": 45, + "DEC": 227, "MET": 72, "ETH": 1 }, @@ -34,10 +34,10 @@ "G8": 1 }, "mailx": { - "last_completed_milestone": "v0.46", - "ilana_current_through": "v0.47 (partial)", + "last_completed_milestone": "v0.47 phase 2 (billing & plans MVP)", + "ilana_current_through": "v0.47 phase 2", "current_milestone": "v0.47 Human Accounts & Organizations", - "current_milestone_status": "v0.47 phase 1 complete (human auth + org membership) plus password reset and organization invitations (both 5-min/5-hour single-use tokens, own-pipeline delivery, dedicated rate limits); billing/plans, OAuth, MFA still deferred", - "next_milestone": "v0.47 phase 2 (billing/plans, dashboard backend) or v0.48 (TBD)" + "current_milestone_status": "v0.47 phase 1 (human auth, orgs, password reset, invitations) and phase 2 (Free/Plus/Pro plans + Paystack one-off checkout, enforcement gated on MAILX_PAYSTACK_SECRET_KEY) complete; auto-renewal (RSK-044), OAuth, MFA deferred", + "next_milestone": "v0.47 phase 3 (plan auto-renewal, dashboard backend) or v0.48 (TBD)" } } diff --git a/cmd/mailx/apikeys.go b/cmd/mailx/apikeys.go index 947e8cf..7bb47b1 100644 --- a/cmd/mailx/apikeys.go +++ b/cmd/mailx/apikeys.go @@ -28,7 +28,12 @@ func connectForAdmin(ctx context.Context) (*database.DB, error) { if dsn == "" { return nil, fmt.Errorf("DATABASE_URL is not set") } - return database.Open(ctx, database.Config{DSN: dsn}) + db, err := database.Open(ctx, database.Config{DSN: dsn}) + if err != nil { + return nil, err + } + enablePlanEnforcementFromEnv(db) + return db, nil } func cmdCreateTenant(args []string, output io.Writer) error { diff --git a/cmd/mailx/billingconfig.go b/cmd/mailx/billingconfig.go new file mode 100644 index 0000000..9601f81 --- /dev/null +++ b/cmd/mailx/billingconfig.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "os" + "time" + + "github.com/Ferousco-dev/mailx/internal/api" + "github.com/Ferousco-dev/mailx/internal/billing" + "github.com/Ferousco-dev/mailx/internal/database" +) + +// buildBillingConfig wires Paystack billing from MAILX_PAYSTACK_SECRET_KEY. +// Unset (the self-hosted default) returns nil: billing routes are not +// registered and plan enforcement stays OFF, so a self-hosted tenant keeps +// its pre-billing unlimited behavior (DEC-221). When set, it also turns on +// plan enforcement on db — the single switch every enforcement point reads. +// +// MAILX_PAYSTACK_PUBLIC_KEY is for the dashboard frontend only; MailX never +// needs it server-side. MAILX_PAYSTACK_CALLBACK_URL (optional) is where +// Paystack returns the customer after checkout. MAILX_PAYSTACK_BASE_URL +// (optional) overrides the API root for testing. +func buildBillingConfig(db *database.DB) (*api.BillingConfig, error) { + secret := os.Getenv("MAILX_PAYSTACK_SECRET_KEY") + if secret == "" { + return nil, nil + } + ps, err := billing.NewPaystack(secret, os.Getenv("MAILX_PAYSTACK_BASE_URL")) + if err != nil { + return nil, err + } + db.EnablePlanEnforcement() + return &api.BillingConfig{Paystack: ps, CallbackURL: os.Getenv("MAILX_PAYSTACK_CALLBACK_URL")}, nil +} + +// enablePlanEnforcementFromEnv is the admin-CLI equivalent (no routes): it +// keeps `mailx purge-retention`/`show-retention` consistent with the server. +func enablePlanEnforcementFromEnv(db *database.DB) { + if os.Getenv("MAILX_PAYSTACK_SECRET_KEY") != "" { + db.EnablePlanEnforcement() + } +} + +// planLapseInterval is how often lapsed paid plans are downgraded. +const planLapseInterval = time.Hour + +// runPlanLapse downgrades paid tenants whose period has ended. MVP LIMITATION +// (RSK-044): Plus/Pro do NOT auto-renew. No recurring charge is attempted; +// the owner must run checkout again each 30-day cycle or drop to Free. +func runPlanLapse(ctx context.Context, db *database.DB, o obs) error { + ticker := time.NewTicker(planLapseInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + n, err := db.DowngradeLapsedPlans(ctx, time.Now().UTC()) + if err != nil { + o.log.Warn("plan_lapse_failed", "error", err.Error()) + continue + } + if n > 0 { + o.log.Info("plan_lapse", "downgraded", n) + } + } + } +} diff --git a/cmd/mailx/serve.go b/cmd/mailx/serve.go index 5374fd4..85f5742 100644 --- a/cmd/mailx/serve.go +++ b/cmd/mailx/serve.go @@ -129,12 +129,20 @@ func runFull() error { if err != nil { return err } + billingCfg, err := buildBillingConfig(db) + if err != nil { + return err + } + if billingCfg == nil { + o.log.Info("billing_disabled", "hint", "MAILX_PAYSTACK_SECRET_KEY not set: billing routes off, plan limits not enforced") + } apiServer, err := api.NewServer(api.Config{ Addr: httpAddr(), DB: db, Store: store, Auth: authSvc, Webhooks: webhookRuntime.service, DKIM: dkimSvc, SPF: spfSvc, DMARC: dmarcSvc, BIMI: bimiSvc, MessageIDDomain: ident.Name(), Abuse: abuse.apiControls(o), TrackingSecret: trackingSecret(), TrackingBaseURL: os.Getenv("MAILX_TRACKING_BASE_URL"), Feedback: fbCfg, HumanAuth: humanAuthSvc, + Billing: billingCfg, Ready: ready.Check, Logger: o.log, Metrics: o.metrics, }) @@ -165,6 +173,9 @@ func runFull() error { o.logged("idempotency-cleanup", func(ctx context.Context) error { return runIdempotencyCleanup(ctx, db, o) }), o.logged("retention-purge", func(ctx context.Context) error { return runRetentionPurge(ctx, db, store, o) }), } + if billingCfg != nil { + components = append(components, o.logged("plan-lapse", func(ctx context.Context) error { return runPlanLapse(ctx, db, o) })) + } if addr := observabilityAddr(); addr != "" { op := observability.NewServer(addr, observability.OperatorMux(o.metrics, ready)) components = append(components, o.logged("observability", op.Run)) diff --git a/internal/api/abuse.go b/internal/api/abuse.go index fe70935..3b3c9e4 100644 --- a/internal/api/abuse.go +++ b/internal/api/abuse.go @@ -346,6 +346,14 @@ func routeClass(r *http.Request) string { // so a refusal by an earlier check never spends recipient tokens. It returns nil to // proceed. On any refusal the caller must release the idempotency claim. func (h *emailHandler) admitSend(ctx context.Context, tenantID string, deliverable int) *apiError { + // Plan daily volume (DEC-222) first: it is independent of abuse + // controls and inert unless plan enforcement is enabled (DEC-221). + if err := h.db.CheckDailySendLimit(ctx, tenantID, 1); err != nil { + if aerr := planLimitAPIError(err, true); aerr != nil { + return aerr + } + return newError(ErrInternal, "internal_error", "failed to check the plan's daily limit") + } a := h.abuse if a == nil { return nil diff --git a/internal/api/billing_handler.go b/internal/api/billing_handler.go new file mode 100644 index 0000000..bd32396 --- /dev/null +++ b/internal/api/billing_handler.go @@ -0,0 +1,221 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/Ferousco-dev/mailx/internal/billing" + "github.com/Ferousco-dev/mailx/internal/database" +) + +// BillingConfig enables /v1/billing/* (v0.47 phase 2, Paystack). Nil — the +// self-hosted default when MAILX_PAYSTACK_SECRET_KEY is unset — leaves the +// routes unregistered. Plan ENFORCEMENT is switched separately and solely by +// database.DB.EnablePlanEnforcement (DEC-221), which cmd/mailx turns on +// exactly when it builds a non-nil BillingConfig. +type BillingConfig struct { + Paystack *billing.Paystack + CallbackURL string +} + +// planPeriod is how long one successful charge keeps a paid plan active. +const planPeriod = 30 * 24 * time.Hour + +// maxWebhookBody bounds the Paystack webhook body read. +const maxWebhookBody = 1 << 20 + +type billingHandler struct { + db *database.DB + cfg *BillingConfig + now func() time.Time + log *slog.Logger +} + +// planLimitAPIError maps a database.ErrPlanLimit to the API error shape; +// nil for any other error. status 429 is used for volume limits (same shape +// as the existing abuse controls), 403 for capability/cap limits. +func planLimitAPIError(err error, volume bool) *apiError { + if !errors.Is(err, database.ErrPlanLimit) { + return nil + } + msg := err.Error() + if i := strings.Index(msg, database.ErrPlanLimit.Error()); i >= 0 { + msg = msg[i:] // drop internal wrapping prefixes + } + msg += "; upgrade your plan to continue" + if volume { + e := newError(ErrRateLimited, "daily_send_limit_reached", msg) + e.RetryAfter = secondsUntilUTCMidnight(time.Now().UTC()) + return e + } + return newError(ErrForbidden, "plan_limit_reached", msg) +} + +func secondsUntilUTCMidnight(now time.Time) int { + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + s := int(next.Sub(now).Seconds()) + if s < 1 { + s = 1 + } + return s +} + +type checkoutRequest struct { + TenantID string `json:"tenant_id"` + Plan string `json:"plan"` +} + +// handleCheckout starts a Paystack checkout for an org owner. +func (h *billingHandler) handleCheckout(w http.ResponseWriter, r *http.Request) { + humanID, ok := humanIDFromContext(r.Context()) + if !ok { + writeError(w, r, newError(ErrAuthentication, "invalid_access_token", "missing or invalid access token")) + return + } + if !acceptsJSONContentType(r.Header.Get("Content-Type")) { + writeError(w, r, newError(ErrUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json")) + return + } + var req checkoutRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil { + writeError(w, r, newError(ErrInvalidRequest, "invalid_json", "request body is not valid JSON")) + return + } + if req.TenantID == "" { + writeError(w, r, newError(ErrValidation, "invalid_tenant", "tenant_id is required")) + return + } + if !billing.IsPaid(req.Plan) { + writeError(w, r, newError(ErrValidation, "invalid_plan", `plan must be "plus" or "pro"`)) + return + } + isOwner, err := h.db.IsTenantOwner(r.Context(), req.TenantID, humanID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to check organization ownership")) + return + } + if !isOwner { + writeError(w, r, newError(ErrForbidden, "not_org_owner", "only an organization owner can change the plan")) + return + } + owner, err := h.db.GetHuman(r.Context(), humanID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to load account")) + return + } + res, err := h.cfg.Paystack.InitializeTransaction(r.Context(), owner.Email, billing.PlanFor(req.Plan), + billing.Metadata{TenantID: req.TenantID, Plan: req.Plan}, h.cfg.CallbackURL) + if err != nil { + h.log.Error("billing_checkout_failed", "error", err.Error()) + writeError(w, r, newError(ErrTemporarilyUnavailable, "payment_provider_unavailable", "the payment provider could not start checkout; retry later")) + return + } + writeJSON(w, http.StatusOK, map[string]string{"authorization_url": res.AuthorizationURL, "reference": res.Reference}) +} + +type subscriptionResponse struct { + TenantID string `json:"tenant_id"` + Plan string `json:"plan"` + Status string `json:"status"` + CurrentPeriodEnd *string `json:"current_period_end"` +} + +// handleSubscription returns an org's plan to any of its members. +func (h *billingHandler) handleSubscription(w http.ResponseWriter, r *http.Request) { + humanID, ok := humanIDFromContext(r.Context()) + if !ok { + writeError(w, r, newError(ErrAuthentication, "invalid_access_token", "missing or invalid access token")) + return + } + tenantID := r.URL.Query().Get("tenant_id") + if tenantID == "" { + writeError(w, r, newError(ErrValidation, "invalid_tenant", "tenant_id query parameter is required")) + return + } + member, err := h.db.IsTenantMember(r.Context(), tenantID, humanID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to check organization membership")) + return + } + if !member { + // Same answer for "no such org" and "not yours": no enumeration. + writeError(w, r, newError(ErrNotFoundType, "organization_not_found", "organization not found")) + return + } + tp, err := h.db.GetTenantPlan(r.Context(), tenantID) + if err != nil { + writeError(w, r, newError(ErrInternal, "internal_error", "failed to load plan")) + return + } + resp := subscriptionResponse{TenantID: tenantID, Plan: tp.Plan, Status: tp.Status} + if tp.CurrentPeriodEnd != nil { + s := tp.CurrentPeriodEnd.UTC().Format(time.RFC3339) + resp.CurrentPeriodEnd = &s + } + writeJSON(w, http.StatusOK, resp) +} + +// handleWebhook is Paystack's public callback. Security boundary: nothing is +// parsed or applied until x-paystack-signature verifies (HMAC-SHA512 of the +// raw body with the secret key); failures are 401. Replays are neutralized by +// billing_payments' primary key on the transaction reference. Anything +// Paystack need not retry (unknown events, signed-but-unusable payloads, +// replays) is answered 200; only our own storage failures are 5xx. +func (h *billingHandler) handleWebhook(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBody)) + if err != nil { + writeError(w, r, newError(ErrPayloadTooLarge, "body_too_large", "webhook body is too large")) + return + } + if err := h.cfg.Paystack.VerifySignature(raw, r.Header.Get("x-paystack-signature")); err != nil { + h.log.Warn("billing_webhook_bad_signature") + writeError(w, r, newError(ErrAuthentication, "invalid_signature", "webhook signature is invalid")) + return + } + ev, err := billing.ParseEvent(raw) + if err != nil { + writeError(w, r, newError(ErrInvalidRequest, "malformed_webhook", "webhook body is malformed")) + return + } + ack := func(result string) { writeJSON(w, http.StatusOK, map[string]string{"status": result}) } + if ev.Event != "charge.success" { + h.log.Info("billing_webhook_ignored", "event", ev.Event) + ack("ignored") + return + } + d := ev.Data + plan := billing.PlanFor(d.Metadata.Plan) + switch { + case d.Reference == "", d.Metadata.TenantID == "", !billing.IsPaid(d.Metadata.Plan): + h.log.Warn("billing_webhook_unusable", "reason", "missing reference/tenant/plan") + ack("ignored") + return + case d.Status != "success", !strings.EqualFold(d.Currency, billing.Currency), d.Amount < plan.PriceUSDCents: + h.log.Warn("billing_webhook_unusable", "reason", "status/currency/amount mismatch", "reference", d.Reference) + ack("ignored") + return + } + err = h.db.ApplyPlanPayment(r.Context(), database.Payment{ + Reference: d.Reference, TenantID: d.Metadata.TenantID, Plan: plan.ID, + Amount: d.Amount, Currency: strings.ToUpper(d.Currency), CustomerCode: d.Customer.CustomerCode, + }, planPeriod) + switch { + case errors.Is(err, database.ErrPaymentAlreadyApplied): + h.log.Info("billing_webhook_replay", "reference", d.Reference) + ack("already_applied") + case errors.Is(err, database.ErrNotFound): + h.log.Warn("billing_webhook_unknown_tenant", "reference", d.Reference) + ack("ignored") + case err != nil: + h.log.Error("billing_webhook_apply_failed", "error", err.Error()) + writeError(w, r, newError(ErrInternal, "internal_error", "failed to apply payment")) + default: + h.log.Info("billing_plan_applied", "tenant_id", d.Metadata.TenantID, "plan", plan.ID) + ack("applied") + } +} diff --git a/internal/api/billing_handler_test.go b/internal/api/billing_handler_test.go new file mode 100644 index 0000000..55e6ce6 --- /dev/null +++ b/internal/api/billing_handler_test.go @@ -0,0 +1,483 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Ferousco-dev/mailx/internal/auth" + "github.com/Ferousco-dev/mailx/internal/billing" + "github.com/Ferousco-dev/mailx/internal/database" + "github.com/Ferousco-dev/mailx/internal/humanauth" + "github.com/Ferousco-dev/mailx/internal/storage" +) + +const testPaystackSecret = "sk_test_billing_secret" + +type fakePaystack struct { + mu sync.Mutex + reqs []map[string]any +} + +func (f *fakePaystack) server(t *testing.T) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/transaction/initialize" || r.Header.Get("Authorization") != "Bearer "+testPaystackSecret { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"status":false,"message":"bad"}`)) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.mu.Lock() + f.reqs = append(f.reqs, body) + f.mu.Unlock() + _, _ = w.Write([]byte(`{"status":true,"message":"ok","data":{"authorization_url":"https://checkout.paystack.com/abc","reference":"ref_abc"}}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +type billingAPI struct { + mux http.Handler + db *database.DB + svc *humanauth.Service + ps *billing.Paystack + fp *fakePaystack +} + +func newBillingAPI(t *testing.T) billingAPI { + t.Helper() + db := newTestDB(t) + store, err := storage.NewFileStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + svc, err := humanauth.NewService(db, []byte("test-secret-at-least-32-bytes-long!!")) + if err != nil { + t.Fatal(err) + } + fp := &fakePaystack{} + ps, err := billing.NewPaystack(testPaystackSecret, fp.server(t).URL) + if err != nil { + t.Fatal(err) + } + db.EnablePlanEnforcement() + mux := newMux(newEmailHandler(db, store), auth.NewService(db, nil), func() error { return nil }, + routeServices{humanAuth: svc, billing: &BillingConfig{Paystack: ps}}) + return billingAPI{mux: mux, db: db, svc: svc, ps: ps, fp: fp} +} + +func (b billingAPI) do(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + _ = json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + b.mux.ServeHTTP(rec, req) + return rec +} + +func (b billingAPI) webhook(t *testing.T, raw []byte, sig string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("POST", "/v1/billing/webhook", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + if sig != "" { + req.Header.Set("x-paystack-signature", sig) + } + rec := httptest.NewRecorder() + b.mux.ServeHTTP(rec, req) + return rec +} + +func chargeSuccess(tenantID, plan, ref string, amount int64) []byte { + b, _ := json.Marshal(map[string]any{ + "event": "charge.success", + "data": map[string]any{ + "reference": ref, "status": "success", "amount": amount, "currency": "USD", + "metadata": map[string]any{"tenant_id": tenantID, "plan": plan}, + "customer": map[string]any{"customer_code": "CUS_x"}, + }, + }) + return b +} + +func TestBillingCheckoutOwnerOnly(t *testing.T) { + b := newBillingAPI(t) + ctx := context.Background() + owner, err := b.svc.SignUp(ctx, "Ada", "ada@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + org, err := b.svc.CreateOrganization(ctx, owner.Human.ID, "Acme", "") + if err != nil { + t.Fatal(err) + } + other, err := b.svc.SignUp(ctx, "Eve", "eve@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + + if rec := b.do(t, "POST", "/v1/billing/checkout", "", map[string]string{"tenant_id": org.ID, "plan": "plus"}); rec.Code != http.StatusUnauthorized { + t.Fatalf("no token: %d", rec.Code) + } + if rec := b.do(t, "POST", "/v1/billing/checkout", other.AccessToken, map[string]string{"tenant_id": org.ID, "plan": "plus"}); rec.Code != http.StatusForbidden { + t.Fatalf("non-owner: %d %s", rec.Code, rec.Body) + } + if rec := b.do(t, "POST", "/v1/billing/checkout", owner.AccessToken, map[string]string{"tenant_id": org.ID, "plan": "free"}); rec.Code != http.StatusUnprocessableEntity && rec.Code != http.StatusBadRequest { + t.Fatalf("free plan checkout: %d", rec.Code) + } + rec := b.do(t, "POST", "/v1/billing/checkout", owner.AccessToken, map[string]string{"tenant_id": org.ID, "plan": "pro"}) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "https://checkout.paystack.com/abc") { + t.Fatalf("owner checkout: %d %s", rec.Code, rec.Body) + } + if len(b.fp.reqs) != 1 { + t.Fatalf("paystack calls = %d, want 1 (non-owner must never reach Paystack)", len(b.fp.reqs)) + } + got := b.fp.reqs[0] + meta, _ := got["metadata"].(map[string]any) + if got["email"] != "ada@example.com" || got["currency"] != "USD" || got["amount"] != float64(2400) || meta["tenant_id"] != org.ID || meta["plan"] != "pro" { + t.Fatalf("unexpected initialize body %+v", got) + } +} + +func TestBillingCheckoutRejectsInvalidRequestsBeforePaystack(t *testing.T) { + b := newBillingAPI(t) + owner, err := b.svc.SignUp(context.Background(), "Ada", "ada@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + org, err := b.svc.CreateOrganization(context.Background(), owner.Human.ID, "Acme", "") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, body, contentType string + wantStatus int + wantCode string + }{ + {"missing tenant", `{"plan":"plus"}`, "application/json", http.StatusUnprocessableEntity, "invalid_tenant"}, + {"unknown plan", `{"tenant_id":"` + org.ID + `","plan":"enterprise"}`, "application/json", http.StatusUnprocessableEntity, "invalid_plan"}, + {"malformed JSON", `{"tenant_id":`, "application/json", http.StatusBadRequest, "invalid_json"}, + {"wrong content type", `{"tenant_id":"` + org.ID + `","plan":"plus"}`, "text/plain", http.StatusUnsupportedMediaType, "unsupported_media_type"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/v1/billing/checkout", strings.NewReader(tc.body)) + req.Header.Set("Authorization", "Bearer "+owner.AccessToken) + req.Header.Set("Content-Type", tc.contentType) + rec := httptest.NewRecorder() + b.mux.ServeHTTP(rec, req) + if rec.Code != tc.wantStatus || !strings.Contains(rec.Body.String(), `"code":"`+tc.wantCode+`"`) { + t.Fatalf("status = %d, body = %s; want %d %s", rec.Code, rec.Body, tc.wantStatus, tc.wantCode) + } + b.fp.mu.Lock() + calls := len(b.fp.reqs) + b.fp.mu.Unlock() + if calls != 0 { + t.Fatalf("invalid checkout made %d Paystack calls", calls) + } + }) + } +} + +func TestBillingWebhookSignatureAndReplay(t *testing.T) { + b := newBillingAPI(t) + ctx := context.Background() + tn := newTestTenant(t, b.db) + body := chargeSuccess(tn.ID, "plus", "ref-1", 600) + + cases := []struct { + name string + sig string + }{ + {"missing", ""}, + {"not hex", "zzzz"}, + {"wrong key", mustSign(t, "sk_other", body)}, + {"signature of different body", b.ps.Sign(chargeSuccess(tn.ID, "pro", "ref-1", 2400))}, + } + for _, c := range cases { + if rec := b.webhook(t, body, c.sig); rec.Code != http.StatusUnauthorized { + t.Fatalf("%s: got %d, want 401", c.name, rec.Code) + } + } + if tp, _ := b.db.GetTenantPlan(ctx, tn.ID); tp.Plan != "free" { + t.Fatalf("unverified webhook changed the plan: %+v", tp) + } + + if rec := b.webhook(t, body, b.ps.Sign(body)); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "applied") { + t.Fatalf("valid: %d %s", rec.Code, rec.Body) + } + tp, _ := b.db.GetTenantPlan(ctx, tn.ID) + if tp.Plan != "plus" || tp.Status != "active" || tp.CurrentPeriodEnd == nil { + t.Fatalf("plan not applied: %+v", tp) + } + firstEnd := *tp.CurrentPeriodEnd + + if rec := b.webhook(t, body, b.ps.Sign(body)); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "already_applied") { + t.Fatalf("replay: %d %s", rec.Code, rec.Body) + } + if tp, _ := b.db.GetTenantPlan(ctx, tn.ID); !tp.CurrentPeriodEnd.Equal(firstEnd) { + t.Fatal("replay extended the period") + } + + malformed := []byte(`{"event":`) + if rec := b.webhook(t, malformed, b.ps.Sign(malformed)); rec.Code != http.StatusBadRequest { + t.Fatalf("malformed signed body: %d", rec.Code) + } + // Signed but underpaid / wrong currency / unknown event: acknowledged, not applied. + under := chargeSuccess(tn.ID, "pro", "ref-under", 600) + if rec := b.webhook(t, under, b.ps.Sign(under)); rec.Code != http.StatusOK { + t.Fatalf("underpaid: %d", rec.Code) + } + unknown := []byte(`{"event":"subscription.create","data":{}}`) + if rec := b.webhook(t, unknown, b.ps.Sign(unknown)); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ignored") { + t.Fatalf("unknown event: %d %s", rec.Code, rec.Body) + } + if tp, _ := b.db.GetTenantPlan(ctx, tn.ID); tp.Plan != "plus" { + t.Fatalf("underpaid pro charge changed plan: %+v", tp) + } +} + +func TestBillingWebhookRejectsUnusableSignedCharges(t *testing.T) { + b := newBillingAPI(t) + tn := newTestTenant(t, b.db) + for _, tc := range []struct { + name string + change func(map[string]any) + }{ + {"missing reference", func(d map[string]any) { d["reference"] = "" }}, + {"missing tenant", func(d map[string]any) { d["metadata"].(map[string]any)["tenant_id"] = "" }}, + {"free plan", func(d map[string]any) { d["metadata"].(map[string]any)["plan"] = "free" }}, + {"unknown plan", func(d map[string]any) { d["metadata"].(map[string]any)["plan"] = "enterprise" }}, + {"failed charge", func(d map[string]any) { d["status"] = "failed" }}, + {"wrong currency", func(d map[string]any) { d["currency"] = "NGN" }}, + {"underpaid", func(d map[string]any) { d["amount"] = 599 }}, + {"unknown tenant", func(d map[string]any) { d["metadata"].(map[string]any)["tenant_id"] = "missing-tenant" }}, + } { + t.Run(tc.name, func(t *testing.T) { + data := map[string]any{ + "reference": "ref-" + tc.name, "status": "success", "amount": 600, + "currency": "USD", "metadata": map[string]any{"tenant_id": tn.ID, "plan": "plus"}, + } + tc.change(data) + raw, err := json.Marshal(map[string]any{"event": "charge.success", "data": data}) + if err != nil { + t.Fatal(err) + } + rec := b.webhook(t, raw, b.ps.Sign(raw)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"status":"ignored"`) { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body) + } + tp, err := b.db.GetTenantPlan(context.Background(), tn.ID) + if err != nil || tp.Plan != billing.PlanFree { + t.Fatalf("unusable charge changed plan: %+v, %v", tp, err) + } + }) + } +} + +func TestBillingWebhookRejectsOversizedBodyAndSignedMissingEvent(t *testing.T) { + b := newBillingAPI(t) + raw := bytes.Repeat([]byte("x"), maxWebhookBody+1) + if rec := b.webhook(t, raw, b.ps.Sign(raw)); rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized body: %d %s", rec.Code, rec.Body) + } + raw = []byte(`{"data":{}}`) + if rec := b.webhook(t, raw, b.ps.Sign(raw)); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "malformed_webhook") { + t.Fatalf("signed body without event: %d %s", rec.Code, rec.Body) + } +} + +func TestPlanLimitAPIError(t *testing.T) { + if got := planLimitAPIError(errors.New("storage unavailable"), true); got != nil { + t.Fatalf("non-plan error converted to plan refusal: %+v", got) + } + wrapped := fmt.Errorf("database: %w: the free plan allows 500 emails per day", database.ErrPlanLimit) + volume := planLimitAPIError(wrapped, true) + if volume == nil || volume.Type != ErrRateLimited || volume.Code != "daily_send_limit_reached" || + volume.RetryAfter < 1 || volume.RetryAfter > 24*60*60 || + strings.Contains(volume.Message, "database:") || !strings.Contains(volume.Message, "upgrade your plan") { + t.Fatalf("volume refusal = %+v", volume) + } + cap := planLimitAPIError(wrapped, false) + if cap == nil || cap.Type != ErrForbidden || cap.Code != "plan_limit_reached" || cap.RetryAfter != 0 { + t.Fatalf("cap refusal = %+v", cap) + } +} + +func TestSecondsUntilUTCMidnight(t *testing.T) { + for _, tc := range []struct { + at time.Time + want int + }{ + {time.Date(2026, time.September, 25, 0, 0, 0, 0, time.UTC), 86400}, + {time.Date(2026, time.September, 25, 12, 0, 0, 0, time.UTC), 43200}, + {time.Date(2026, time.September, 25, 23, 59, 59, 500000000, time.UTC), 1}, + } { + if got := secondsUntilUTCMidnight(tc.at); got != tc.want { + t.Errorf("secondsUntilUTCMidnight(%s) = %d, want %d", tc.at, got, tc.want) + } + } +} + +func mustSign(t *testing.T, key string, body []byte) string { + t.Helper() + p, err := billing.NewPaystack(key, "") + if err != nil { + t.Fatal(err) + } + return p.Sign(body) +} + +func TestBillingSubscriptionMembersOnly(t *testing.T) { + b := newBillingAPI(t) + ctx := context.Background() + owner, _ := b.svc.SignUp(ctx, "Ada", "ada@example.com", "hunter22hunter") + org, _ := b.svc.CreateOrganization(ctx, owner.Human.ID, "Acme", "") + other, _ := b.svc.SignUp(ctx, "Eve", "eve@example.com", "hunter22hunter") + rec := b.do(t, "GET", "/v1/billing/subscription?tenant_id="+org.ID, owner.AccessToken, nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"plan":"free"`) { + t.Fatalf("member: %d %s", rec.Code, rec.Body) + } + if rec := b.do(t, "GET", "/v1/billing/subscription?tenant_id="+org.ID, other.AccessToken, nil); rec.Code != http.StatusNotFound { + t.Fatalf("non-member: %d", rec.Code) + } +} + +func TestBillingSubscriptionShowsActivePaidPeriod(t *testing.T) { + b := newBillingAPI(t) + ctx := context.Background() + owner, err := b.svc.SignUp(ctx, "Ada", "ada@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + org, err := b.svc.CreateOrganization(ctx, owner.Human.ID, "Acme", "") + if err != nil { + t.Fatal(err) + } + const period = 30 * 24 * time.Hour + before := time.Now().UTC() + if err := b.db.ApplyPlanPayment(ctx, database.Payment{Reference: "ref-active", TenantID: org.ID, Plan: billing.PlanPlus, Amount: 600, Currency: "USD"}, period); err != nil { + t.Fatal(err) + } + rec := b.do(t, http.MethodGet, "/v1/billing/subscription?tenant_id="+org.ID, owner.AccessToken, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body) + } + var got subscriptionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.TenantID != org.ID || got.Plan != billing.PlanPlus || got.Status != "active" || got.CurrentPeriodEnd == nil { + t.Fatalf("subscription = %+v", got) + } + gotEnd, err := time.Parse(time.RFC3339, *got.CurrentPeriodEnd) + if err != nil { + t.Fatal(err) + } + if d := gotEnd.Sub(before.Add(period)); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("current_period_end = %v, want ~= now+%v (before=%v)", gotEnd, period, before) + } +} + +// Self-hosted: with no BillingConfig the routes do not exist and nothing is enforced. +func TestBillingRoutesAbsentWhenNotConfigured(t *testing.T) { + mux, db, _ := setupMux(t) + if db.PlanEnforcementEnabled() { + t.Fatal("enforcement must default to off") + } + for _, p := range []string{"/v1/billing/webhook", "/v1/billing/checkout"} { + req := httptest.NewRequest("POST", p, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + t.Fatalf("%s reachable without billing configured", p) + } + } +} + +func TestPlanEnforcementAtAPI(t *testing.T) { + mux, db, tenantID := setupSendMux(t) // example.com verified: 1 domain + ctx := context.Background() + send := func() int { + return doJSON(t, mux, "POST", "/v1/emails", map[string]any{"from": "a@example.com", "to": []string{"x@dest.example"}, "subject": "s", "text": "t"}).Code + } + // Off: webhooks allowed on free, sends allowed. + if c := send(); c != http.StatusAccepted { + t.Fatalf("send with enforcement off: %d", c) + } + db.EnablePlanEnforcement() + + // Webhooks: free refused, plus allowed. + wh := map[string]any{"url": "http://127.0.0.1:9876/h", "events": []string{"email.failed"}} + if rec := doJSON(t, mux, "POST", "/v1/webhooks", wh); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "plan_limit_reached") { + t.Fatalf("free webhook: %d %s", rec.Code, rec.Body) + } + // Domains: free cap 5 (1 already). + for i := 0; i < 4; i++ { + if rec := doJSON(t, mux, "POST", "/v1/domains", map[string]any{"name": "d" + string(rune('a'+i)) + ".example.org"}); rec.Code != http.StatusCreated { + t.Fatalf("domain %d under cap: %d %s", i, rec.Code, rec.Body) + } + } + if rec := doJSON(t, mux, "POST", "/v1/domains", map[string]any{"name": "over.example.org"}); rec.Code != http.StatusForbidden { + t.Fatalf("6th domain: %d %s", rec.Code, rec.Body) + } + // Daily volume: fill today to the free cap, then the next send is 429. + if err := fillToday(ctx, db, tenantID, 500); err != nil { + t.Fatal(err) + } + rec := doJSON(t, mux, "POST", "/v1/emails", map[string]any{"from": "a@example.com", "to": []string{"x@dest.example"}, "subject": "s", "text": "t"}) + if rec.Code != http.StatusTooManyRequests || !strings.Contains(rec.Body.String(), "daily_send_limit_reached") || rec.Header().Get("Retry-After") == "" { + t.Fatalf("over daily cap: %d %s", rec.Code, rec.Body) + } + if err := db.ApplyPlanPayment(ctx, database.Payment{Reference: "r", TenantID: tenantID, Plan: "plus", Amount: 600, Currency: "USD"}, 24*time.Hour); err != nil { + t.Fatal(err) + } + if c := send(); c != http.StatusAccepted { + t.Fatalf("plus send: %d", c) + } + // Past the plan gate (this harness's webhook service then rejects the + // non-HTTPS URL on its own validation, which is fine here). + if rec := doJSON(t, mux, "POST", "/v1/webhooks", wh); strings.Contains(rec.Body.String(), "plan_limit_reached") { + t.Fatalf("plus webhook: %d %s", rec.Code, rec.Body) + } +} + +// fillToday inserts messages until the tenant has n created today. +func fillToday(ctx context.Context, db *database.DB, tenantID string, n int) error { + for i := 0; i < n; i++ { + id := fmt.Sprintf("fill%028d", i) + if _, err := db.InsertMessage(ctx, database.NewMessage{ + ID: id, TenantID: tenantID, MailFrom: "", FromHeader: "a@example.com", + Subject: "s", MessageIDHeader: "<" + id + "@mailx.local>", + Recipients: []database.RecipientInput{{Address: ""}}, + }); err != nil { + return err + } + } + return nil +} + +func TestBroadcastPlanGate(t *testing.T) { + a := newDKIMAPI(t) + f := setupBroadcastReady(t, a, "acme") + a.db.EnablePlanEnforcement() + rec := doJSON(t, f.h, "POST", "/v1/broadcasts", f.body(nil)) + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "plan_limit_reached") { + t.Fatalf("free broadcast: %d %s", rec.Code, rec.Body) + } +} diff --git a/internal/api/broadcast_handler.go b/internal/api/broadcast_handler.go index 31b6fb0..5214185 100644 --- a/internal/api/broadcast_handler.go +++ b/internal/api/broadcast_handler.go @@ -108,6 +108,14 @@ func (h *broadcastHandler) handleCreate(w http.ResponseWriter, r *http.Request) } tenantID := tenantFromContext(r.Context()) + if err := h.db.CheckFeature(r.Context(), tenantID, "broadcasts"); err != nil { + if aerr := planLimitAPIError(err, false); aerr != nil { + writeError(w, r, aerr) + return + } + writeError(w, r, newError(ErrInternal, "internal_error", "failed to check plan limits")) + return + } // Ownership: both resources must belong to THIS tenant, validated // authoritatively here (not merely because auth passed) — see diff --git a/internal/api/domain_handler.go b/internal/api/domain_handler.go index 5fd6838..088f4ff 100644 --- a/internal/api/domain_handler.go +++ b/internal/api/domain_handler.go @@ -14,7 +14,10 @@ import ( maildomain "github.com/Ferousco-dev/mailx/internal/domain" ) -type domainHandler struct{ service *maildomain.Service } +type domainHandler struct { + service *maildomain.Service + db *database.DB // for the plan domain cap; nil skips it +} func newDomainHandler(service *maildomain.Service) *domainHandler { return &domainHandler{service: service} @@ -65,6 +68,16 @@ func (h *domainHandler) handleCreate(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(ErrInvalidRequest, "malformed_json", "request body is not valid JSON")) return } + if h.db != nil { + if err := h.db.CheckDomainLimit(r.Context(), tenantFromContext(r.Context())); err != nil { + if aerr := planLimitAPIError(err, false); aerr != nil { + writeError(w, r, aerr) + return + } + writeError(w, r, newError(ErrInternal, "internal_error", "failed to check plan limits")) + return + } + } created, err := h.service.Create(r.Context(), tenantFromContext(r.Context()), req.Name) switch { case errors.Is(err, maildomain.ErrInvalidName): diff --git a/internal/api/humanauth_handler.go b/internal/api/humanauth_handler.go index be198bd..33f77da 100644 --- a/internal/api/humanauth_handler.go +++ b/internal/api/humanauth_handler.go @@ -299,6 +299,10 @@ func (h *humanAuthHandler) handleCreateInvite(w http.ResponseWriter, r *http.Req writeError(w, r, newError(ErrForbidden, "not_org_owner", "only an organization owner can send invitations")) return } + if aerr := planLimitAPIError(err, false); aerr != nil { + writeError(w, r, aerr) + return + } slog.Default().Error("org_invite_failed", "error", err.Error()) writeError(w, r, newError(ErrValidation, "invalid_invitation", err.Error())) return @@ -343,6 +347,8 @@ func (h *humanAuthHandler) handleAcceptInvite(w http.ResponseWriter, r *http.Req result, err := h.svc.AcceptOrgInvitation(r.Context(), req.Token, existingHumanID, req.Name, req.Password) if err != nil { switch { + case errors.Is(err, database.ErrPlanLimit): + writeError(w, r, planLimitAPIError(err, false)) case errors.Is(err, humanauth.ErrOrgInvitationInvalid): writeError(w, r, newError(ErrValidation, "invalid_invitation_token", "this invitation is invalid or has expired")) case errors.Is(err, humanauth.ErrOrgInvitationEmailMismatch): diff --git a/internal/api/openapi.go b/internal/api/openapi.go index e1359cd..e1f3a59 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -167,6 +167,52 @@ const openAPISpec = `{ } } }, + "/billing/checkout": { + "post": { + "summary": "Start a Paystack checkout for a paid plan", + "description": "MailX Cloud only: absent (404) unless the deployment configures MAILX_PAYSTACK_SECRET_KEY. Requires a human access token (HumanAuth) belonging to an OWNER of tenant_id (403 not_org_owner otherwise). Calls Paystack Initialize Transaction for the plan's USD price and returns the hosted authorization_url to redirect to. The plan becomes active only when Paystack's verified charge.success webhook arrives, for 30 days. Plans do NOT auto-renew yet: the owner must check out again each cycle, or the org drops to free when the period ends.", + "security": [{"HumanAuth": []}], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["tenant_id", "plan"], "properties": {"tenant_id": {"type": "string"}, "plan": {"type": "string", "enum": ["plus", "pro"]}}}}}}, + "responses": { + "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"authorization_url": {"type": "string"}, "reference": {"type": "string"}}}}}}, + "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, + "403": {"description": "Caller is not an owner of this organization", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, + "422": {"description": "Missing tenant_id or plan is not plus/pro", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, + "503": {"description": "Paystack could not start the checkout", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} + } + } + }, + "/billing/subscription": { + "get": { + "summary": "Get an organization's plan", + "description": "MailX Cloud only (see /billing/checkout). Requires a human access token (HumanAuth) of any member of tenant_id; a non-member gets 404, the same as a nonexistent organization.", + "security": [{"HumanAuth": []}], + "parameters": [ + {"name": "tenant_id", "in": "query", "required": true, "schema": {"type": "string"}, "description": "Organization (tenant) ID."} + ], + "responses": { + "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "object", "properties": {"tenant_id": {"type": "string"}, "plan": {"type": "string", "enum": ["free", "plus", "pro"]}, "status": {"type": "string", "enum": ["active", "lapsed"]}, "current_period_end": {"type": "string", "format": "date-time", "nullable": true}}}}}}, + "401": {"description": "Missing or invalid access token", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, + "404": {"description": "Organization not found or caller is not a member", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} + } + } + }, + "/billing/webhook": { + "post": { + "summary": "Paystack webhook receiver", + "description": "MailX Cloud only (see /billing/checkout). Called by Paystack, not by clients. Public, but authenticated by the x-paystack-signature header: hex HMAC-SHA512 of the raw body keyed with the Paystack secret key; anything that does not verify is 401 and changes nothing. A verified charge.success whose metadata names a tenant and a paid plan, with status success, currency USD and an amount at least the plan price, sets that plan active for 30 days. Each Paystack transaction reference is applied at most once, so replays change nothing. Other event types and unusable payloads are acknowledged with 200 and ignored.", + "security": [], + "parameters": [ + {"name": "x-paystack-signature", "in": "header", "required": true, "schema": {"type": "string"}} + ], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}, + "responses": { + "200": {"description": "Acknowledged (status: applied, already_applied or ignored)", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": "string"}}}}}}, + "400": {"description": "Signed body is not valid JSON", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}}, + "401": {"description": "Signature missing or invalid", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/APIError"}}}} + } + } + }, "/emails": { "post": { "summary": "Send an email", diff --git a/internal/api/routes.go b/internal/api/routes.go index c747773..d7a6a2b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -2,6 +2,7 @@ package api import ( "errors" + "log/slog" "net/http" "time" @@ -28,6 +29,8 @@ type routeServices struct { feedback *feedbackHandler // nil disables the ingestion route track *trackHandler // nil disables /track routes humanAuth *humanauth.Service // nil disables /v1/auth/* and /v1/orgs + billing *BillingConfig // nil disables /v1/billing/* + log *slog.Logger } // newMux registers every /v1 route plus health checks. Handlers stay @@ -65,6 +68,7 @@ func newMux(h *emailHandler, authSvc authService, readiness func() error, extras bimiHandler.service = extras[0].bimi } domains := newDomainHandler(domainService) + domains.db = h.db webhooks := &webhookHandler{service: webhookService, db: h.db} v1 := http.NewServeMux() @@ -195,6 +199,21 @@ func newMux(h *emailHandler, authSvc authService, readiness func() error, extras // the no-account-yet path runs a full bcrypt hash per call. mux.Handle("POST /v1/orgs/invites/accept", chain(http.HandlerFunc(ha.handleAcceptInvite), orgInviteAcceptIPLimitMiddleware(abuse))) } + if len(extras) > 0 && extras[0].billing != nil { + lg := extras[0].log + if lg == nil { + lg = observability.Discard() + } + bh := &billingHandler{db: h.db, cfg: extras[0].billing, now: func() time.Time { return time.Now().UTC() }, log: lg} + // Public: Paystack calls it directly. Authenticated solely by its + // HMAC-SHA512 signature inside the handler (DEC-225). + mux.HandleFunc("POST /v1/billing/webhook", bh.handleWebhook) + if extras[0].humanAuth != nil { + billingAuthenticated := humanAuthMiddleware(extras[0].humanAuth) + mux.Handle("POST /v1/billing/checkout", billingAuthenticated(http.HandlerFunc(bh.handleCheckout))) + mux.Handle("GET /v1/billing/subscription", billingAuthenticated(http.HandlerFunc(bh.handleSubscription))) + } + } if len(extras) > 0 && extras[0].feedback != nil { // Deliberately NOT under /v1 and NOT authenticateMiddleware: this is the // operator-only feedback ingestion boundary (see feedback_handler.go), diff --git a/internal/api/server.go b/internal/api/server.go index d6cc6f5..6fc4ca8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -72,6 +72,9 @@ type Config struct { // accounts/organizations). Nil disables those routes entirely; existing // API-key-authenticated /v1 routes are unaffected either way. HumanAuth *humanauth.Service + // Billing enables /v1/billing/* (v0.47 phase 2). Nil (self-hosted, no + // MAILX_PAYSTACK_SECRET_KEY) leaves those routes unregistered. + Billing *BillingConfig // Logger and Metrics are optional; nil disables the corresponding // observation without changing request handling. Logger *slog.Logger @@ -148,7 +151,7 @@ func NewServer(cfg Config) (*Server, error) { if len(cfg.TrackingSecret) > 0 { trackH = &trackHandler{db: cfg.DB, secret: cfg.TrackingSecret} } - mux := newMux(h, cfg.Auth, readiness, routeServices{abuse: cfg.Abuse, domains: domainService, webhooks: cfg.Webhooks, dkim: cfg.DKIM, spf: cfg.SPF, dmarc: cfg.DMARC, bimi: cfg.BIMI, metrics: cfg.Metrics, feedback: fbHandler, track: trackH, humanAuth: cfg.HumanAuth}) + mux := newMux(h, cfg.Auth, readiness, routeServices{abuse: cfg.Abuse, domains: domainService, webhooks: cfg.Webhooks, dkim: cfg.DKIM, spf: cfg.SPF, dmarc: cfg.DMARC, bimi: cfg.BIMI, metrics: cfg.Metrics, feedback: fbHandler, track: trackH, humanAuth: cfg.HumanAuth, billing: cfg.Billing, log: cfg.Logger}) log := cfg.Logger if log == nil { log = observability.Discard() diff --git a/internal/api/webhook_handler.go b/internal/api/webhook_handler.go index b6f7a98..02f1cf6 100644 --- a/internal/api/webhook_handler.go +++ b/internal/api/webhook_handler.go @@ -72,6 +72,14 @@ func (h *webhookHandler) handleCreate(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(ErrInvalidRequest, "malformed_json", "request body is not valid JSON")) return } + if err := h.db.CheckFeature(r.Context(), tenantFromContext(r.Context()), "webhooks"); err != nil { + if aerr := planLimitAPIError(err, false); aerr != nil { + writeError(w, r, aerr) + return + } + writeError(w, r, newError(ErrInternal, "internal_error", "failed to check plan limits")) + return + } created, err := h.service.Create(r.Context(), tenantFromContext(r.Context()), req.URL, req.Events) if err != nil { if errors.Is(err, webhook.ErrDNSUnavailable) { diff --git a/internal/billing/billing_test.go b/internal/billing/billing_test.go new file mode 100644 index 0000000..135073b --- /dev/null +++ b/internal/billing/billing_test.go @@ -0,0 +1,251 @@ +package billing + +import ( + "context" + "crypto/hmac" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestPlanTable(t *testing.T) { + cases := []struct { + id string + price int64 + daily, domains, members, retention int + broadcasts, webhooks bool + }{ + {"free", 0, 500, 5, 1, 7, false, false}, + {"plus", 600, 10_000, 15, 5, 30, true, true}, + {"pro", 2400, 100_000, Unlimited, Unlimited, 90, true, true}, + } + for _, c := range cases { + p := PlanFor(c.id) + if p.ID != c.id || p.PriceUSDCents != c.price || p.DailySends != c.daily || p.Domains != c.domains || + p.Members != c.members || p.RetentionDays != c.retention || p.Broadcasts != c.broadcasts || p.Webhooks != c.webhooks { + t.Fatalf("%s: got %+v", c.id, p) + } + } + for _, id := range []string{"", "enterprise", "FREE"} { + if PlanFor(id).ID != PlanFree { + t.Fatalf("PlanFor(%q) should default to free", id) + } + } + if IsPaid("free") || !IsPaid("plus") || !IsPaid("pro") || IsPaid("bogus") { + t.Fatal("IsPaid wrong") + } + if !Within(4, 5) || Within(5, 5) || !Within(1_000_000, Unlimited) { + t.Fatal("Within wrong") + } +} + +func TestVerifySignature(t *testing.T) { + p, err := NewPaystack("sk_test_x", "") + if err != nil { + t.Fatal(err) + } + body := []byte(`{"event":"charge.success"}`) + if err := p.VerifySignature(body, p.Sign(body)); err != nil { + t.Fatalf("valid signature rejected: %v", err) + } + other, _ := NewPaystack("sk_test_y", "") + for name, sig := range map[string]string{ + "empty": "", + "garbage": "not-hex", + "short": "abcd", + "other key": other.Sign(body), + "tampered body": p.Sign([]byte(`{"event":"charge.failed"}`)), + } { + if err := p.VerifySignature(body, sig); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("%s: want ErrInvalidSignature, got %v", name, err) + } + } + if _, err := NewPaystack(" ", ""); err == nil { + t.Fatal("empty secret must be refused") + } +} + +// TestParseEventToleratesNonObjectMetadata is a regression test for a +// CodeRabbit finding (PR #24): Paystack sends non-object metadata (e.g. 0 +// or "") for a transaction MailX's checkout never created (a payment page +// or another integration on the same account). That must decode to an +// empty Metadata, not fail ParseEvent - handleWebhook must still be able +// to acknowledge such an event 200 rather than answering 400 and making +// Paystack retry an event that can never become recognizable. +func TestParseEventToleratesNonObjectMetadata(t *testing.T) { + for name, body := range map[string]string{ + "integer metadata": `{"event":"charge.success","data":{"metadata":0}}`, + "string metadata": `{"event":"charge.success","data":{"metadata":""}}`, + "null metadata": `{"event":"charge.success","data":{"metadata":null}}`, + "array metadata": `{"event":"charge.success","data":{"metadata":[]}}`, + } { + ev, err := ParseEvent([]byte(body)) + if err != nil { + t.Fatalf("%s: expected no error, got %v", name, err) + } + if ev.Data.Metadata != (Metadata{}) { + t.Fatalf("%s: expected empty Metadata, got %+v", name, ev.Data.Metadata) + } + } + // A genuine object still decodes normally. + ev, err := ParseEvent([]byte(`{"event":"charge.success","data":{"metadata":{"tenant_id":"t1","plan":"plus"}}}`)) + if err != nil { + t.Fatal(err) + } + if ev.Data.Metadata != (Metadata{TenantID: "t1", Plan: "plus"}) { + t.Fatalf("expected real metadata to decode, got %+v", ev.Data.Metadata) + } +} + +func TestInitializeTransactionRequest(t *testing.T) { + for _, tc := range []struct { + name, callback string + wantCallback bool + }{ + {name: "with callback", callback: "https://mailx.example/billing/return", wantCallback: true}, + {name: "without callback"}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/transaction/initialize" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer sk_test_secret" || r.Header.Get("Content-Type") != "application/json" { + t.Errorf("unexpected request headers: %v", r.Header) + } + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + want := map[string]string{ + "email": `"owner@example.com"`, "amount": "600", "currency": `"USD"`, + "metadata": `{"tenant_id":"tenant-1","plan":"plus"}`, + } + if tc.wantCallback { + want["callback_url"] = `"https://mailx.example/billing/return"` + } + if len(body) != len(want) { + t.Errorf("request fields = %v, want %v", body, want) + } + for key, value := range want { + if string(body[key]) != value { + t.Errorf("%s = %s, want %s", key, body[key], value) + } + } + _, _ = io.WriteString(w, `{"status":true,"data":{"authorization_url":"https://checkout.paystack.com/abc","reference":"ref-123"}}`) + })) + defer srv.Close() + p, err := NewPaystack("sk_test_secret", srv.URL+"/") + if err != nil { + t.Fatal(err) + } + got, err := p.InitializeTransaction(context.Background(), "owner@example.com", PlanFor(PlanPlus), Metadata{TenantID: "tenant-1", Plan: PlanPlus}, tc.callback) + if err != nil { + t.Fatal(err) + } + if calls.Load() != 1 || got.AuthorizationURL != "https://checkout.paystack.com/abc" || got.Reference != "ref-123" { + t.Fatalf("calls = %d, result = %+v", calls.Load(), got) + } + if strings.Contains(p.String(), "sk_test_secret") { + t.Fatal("client string exposed secret key") + } + }) + } +} + +func TestInitializeTransactionRejectsFreePlanBeforeRequest(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) })) + defer srv.Close() + p, err := NewPaystack("sk_test_secret", srv.URL) + if err != nil { + t.Fatal(err) + } + result, err := p.InitializeTransaction(context.Background(), "owner@example.com", PlanFor(PlanFree), Metadata{}, "") + if err == nil || calls.Load() != 0 || result != (InitializeResult{}) { + t.Fatalf("free checkout: calls = %d, result = %+v, error = %v", calls.Load(), result, err) + } +} + +func TestInitializeTransactionProviderFailures(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + wantInError string + }{ + {"HTTP failure", http.StatusBadGateway, `{"status":false,"message":"unavailable"}`, "HTTP 502"}, + {"HTTP failure with success body", http.StatusBadGateway, `{"status":true,"data":{"authorization_url":"https://checkout.example","reference":"ref-123"}}`, "HTTP 502"}, + {"provider refusal", http.StatusOK, `{"status":false,"message":"declined"}`, "declined"}, + {"missing checkout URL", http.StatusOK, `{"status":true,"data":{"reference":"ref-123"}}`, "initialize failed"}, + {"malformed response", http.StatusOK, `{"status":`, "decode"}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + })) + defer srv.Close() + p, err := NewPaystack("sk_test_secret", srv.URL) + if err != nil { + t.Fatal(err) + } + result, err := p.InitializeTransaction(context.Background(), "owner@example.com", PlanFor(PlanPlus), Metadata{}, "") + if err == nil || !strings.Contains(err.Error(), tc.wantInError) || result != (InitializeResult{}) { + t.Fatalf("result = %+v, error = %v; want %q", result, err, tc.wantInError) + } + }) + } +} + +func TestParseEvent(t *testing.T) { + raw := []byte(`{"event":"charge.success","data":{"reference":"ref-123","status":"success","amount":600,"currency":"USD","metadata":{"tenant_id":"tenant-1","plan":"plus"},"customer":{"customer_code":"CUS_1"}}}`) + event, err := ParseEvent(raw) + if err != nil { + t.Fatal(err) + } + if event.Event != "charge.success" || event.Data.Reference != "ref-123" || event.Data.Status != "success" || event.Data.Amount != 600 || event.Data.Currency != "USD" || event.Data.Metadata != (Metadata{TenantID: "tenant-1", Plan: PlanPlus}) || event.Data.Customer.CustomerCode != "CUS_1" { + t.Fatalf("parsed event = %+v", event) + } + for _, tc := range []struct{ name, body string }{ + {"invalid JSON", `{"event":`}, + {"missing event", `{"data":{}}`}, + {"empty event", `{"event":""}`}, + {"wrong event type", `{"event":123}`}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := ParseEvent([]byte(tc.body)); err == nil { + t.Fatalf("ParseEvent(%s) accepted malformed event", tc.body) + } + }) + } +} + +func TestSignatureMatchesHMACSHA512OfRawBody(t *testing.T) { + p, err := NewPaystack("sk_test_secret", "") + if err != nil { + t.Fatal(err) + } + body := []byte(" {\"event\":\"charge.success\"}\n") + mac := hmac.New(sha512.New, []byte("sk_test_secret")) + _, _ = mac.Write(body) + want := hex.EncodeToString(mac.Sum(nil)) + if got := p.Sign(body); got != want { + t.Fatalf("signature = %s, want %s", got, want) + } + if err := p.VerifySignature(body, " \n"+strings.ToUpper(want)+"\t"); err != nil { + t.Fatalf("uppercase signature with surrounding whitespace rejected: %v", err) + } + if err := p.VerifySignature([]byte(strings.TrimSpace(string(body))), want); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("signature accepted body with different whitespace: %v", err) + } +} diff --git a/internal/billing/paystack.go b/internal/billing/paystack.go new file mode 100644 index 0000000..0a613dc --- /dev/null +++ b/internal/billing/paystack.go @@ -0,0 +1,179 @@ +package billing + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// DefaultPaystackBaseURL is Paystack's API root. +const DefaultPaystackBaseURL = "https://api.paystack.co" + +// Currency is the only currency MailX bills in (operator decision). +const Currency = "USD" + +// ErrInvalidSignature means a webhook body's x-paystack-signature did not +// verify. Callers must answer 401 and apply nothing. +var ErrInvalidSignature = errors.New("billing: invalid paystack signature") + +// Paystack is a minimal Paystack client: Initialize Transaction and webhook +// signature verification. The secret key never leaves this struct. +type Paystack struct { + secretKey string + baseURL string + http *http.Client +} + +// NewPaystack returns a client for secretKey. baseURL "" selects the real +// Paystack API; tests point it at an httptest server. +func NewPaystack(secretKey, baseURL string) (*Paystack, error) { + if strings.TrimSpace(secretKey) == "" { + return nil, errors.New("billing: paystack secret key is empty") + } + if baseURL == "" { + baseURL = DefaultPaystackBaseURL + } + return &Paystack{secretKey: secretKey, baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{Timeout: 15 * time.Second}}, nil +} + +// String never prints the secret key. +func (p *Paystack) String() string { return "billing.Paystack{baseURL:" + p.baseURL + "}" } + +// Metadata is what MailX attaches to every transaction and reads back from +// the verified webhook. +type Metadata struct { + TenantID string `json:"tenant_id"` + Plan string `json:"plan"` +} + +// UnmarshalJSON tolerates metadata that isn't a JSON object (Paystack sends +// non-object metadata, e.g. 0 or "", for transactions this checkout never +// created - a payment page or another integration on the same account). +// Such a transaction decodes to an empty Metadata rather than failing the +// whole webhook parse: the handler already ignores an empty TenantID +// (CodeRabbit, PR #24) - a signed event MailX cannot recognize must still +// be acknowledged 200, not answered 400 (which makes Paystack retry +// forever for an event that will never become recognizable). +func (m *Metadata) UnmarshalJSON(b []byte) error { + type plain Metadata + var p plain + if err := json.Unmarshal(b, &p); err != nil { + *m = Metadata{} + return nil + } + *m = Metadata(p) + return nil +} + +// InitializeResult is the part of Paystack's response the frontend needs. +type InitializeResult struct { + AuthorizationURL string + Reference string +} + +// InitializeTransaction calls POST /transaction/initialize for plan's price +// in USD cents, returning the hosted checkout URL. +func (p *Paystack) InitializeTransaction(ctx context.Context, email string, plan Plan, meta Metadata, callbackURL string) (InitializeResult, error) { + if plan.PriceUSDCents <= 0 { + return InitializeResult{}, fmt.Errorf("billing: plan %q is not purchasable", plan.ID) + } + body := map[string]any{ + "email": email, + "amount": plan.PriceUSDCents, + "currency": Currency, + "metadata": meta, + } + if callbackURL != "" { + body["callback_url"] = callbackURL + } + raw, err := json.Marshal(body) + if err != nil { + return InitializeResult{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/transaction/initialize", bytes.NewReader(raw)) + if err != nil { + return InitializeResult{}, err + } + req.Header.Set("Authorization", "Bearer "+p.secretKey) + req.Header.Set("Content-Type", "application/json") + resp, err := p.http.Do(req) + if err != nil { + return InitializeResult{}, fmt.Errorf("billing: paystack initialize: %w", err) + } + defer resp.Body.Close() + var out struct { + Status bool `json:"status"` + Message string `json:"message"` + Data struct { + AuthorizationURL string `json:"authorization_url"` + Reference string `json:"reference"` + } `json:"data"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil { + return InitializeResult{}, fmt.Errorf("billing: paystack initialize: decode (HTTP %d): %w", resp.StatusCode, err) + } + if resp.StatusCode != http.StatusOK || !out.Status || out.Data.AuthorizationURL == "" { + return InitializeResult{}, fmt.Errorf("billing: paystack initialize failed (HTTP %d): %s", resp.StatusCode, out.Message) + } + return InitializeResult{AuthorizationURL: out.Data.AuthorizationURL, Reference: out.Data.Reference}, nil +} + +// VerifySignature checks Paystack's documented webhook signature: +// hex(HMAC-SHA512(secretKey, rawBody)) in the x-paystack-signature header, +// compared in constant time. An empty or non-hex header never verifies. +func (p *Paystack) VerifySignature(rawBody []byte, header string) error { + got, err := hex.DecodeString(strings.TrimSpace(header)) + if err != nil || len(got) != sha512.Size { + return ErrInvalidSignature + } + mac := hmac.New(sha512.New, []byte(p.secretKey)) + mac.Write(rawBody) + if !hmac.Equal(got, mac.Sum(nil)) { + return ErrInvalidSignature + } + return nil +} + +// Sign computes the signature Paystack would send for body. Exported for +// tests and local tooling; production code only verifies. +func (p *Paystack) Sign(body []byte) string { + mac := hmac.New(sha512.New, []byte(p.secretKey)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +// Event is the subset of a Paystack webhook event MailX reads. +type Event struct { + Event string `json:"event"` + Data struct { + Reference string `json:"reference"` + Status string `json:"status"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + Metadata Metadata `json:"metadata"` + Customer struct { + CustomerCode string `json:"customer_code"` + } `json:"customer"` + } `json:"data"` +} + +// ParseEvent decodes a (verified) webhook body. +func ParseEvent(rawBody []byte) (Event, error) { + var e Event + if err := json.Unmarshal(rawBody, &e); err != nil { + return Event{}, fmt.Errorf("billing: malformed webhook body: %w", err) + } + if e.Event == "" { + return Event{}, errors.New("billing: webhook body has no event type") + } + return e, nil +} diff --git a/internal/billing/plans.go b/internal/billing/plans.go new file mode 100644 index 0000000..1807be6 --- /dev/null +++ b/internal/billing/plans.go @@ -0,0 +1,55 @@ +// Package billing holds MailX's plan table (Free/Plus/Pro) and the Paystack +// integration (Initialize Transaction + webhook signature verification). +// It deliberately imports nothing from internal/database so the database +// layer can use the plan table in its own enforcement queries. +package billing + +// Unlimited is the sentinel for "no cap" in a Plan's numeric limits. It +// follows internal/ratelimit.Policy's convention that a zero count means +// the control is off (see Policy.Validate's count helper). +const Unlimited = 0 + +// Plan is one billing tier's limits. A zero numeric limit means Unlimited. +type Plan struct { + ID string + PriceUSDCents int64 // 0 for free + DailySends int + Domains int + Members int + RetentionDays int + Broadcasts bool + Webhooks bool +} + +const ( + PlanFree = "free" + PlanPlus = "plus" + PlanPro = "pro" +) + +// Plans is the confirmed v0.47 phase 2 plan table. +var Plans = map[string]Plan{ + PlanFree: {ID: PlanFree, PriceUSDCents: 0, DailySends: 500, Domains: 5, Members: 1, RetentionDays: 7, Broadcasts: false, Webhooks: false}, + PlanPlus: {ID: PlanPlus, PriceUSDCents: 600, DailySends: 10_000, Domains: 15, Members: 5, RetentionDays: 30, Broadcasts: true, Webhooks: true}, + PlanPro: {ID: PlanPro, PriceUSDCents: 2400, DailySends: 100_000, Domains: Unlimited, Members: Unlimited, RetentionDays: 90, Broadcasts: true, Webhooks: true}, +} + +// PlanFor returns the plan with this id; unknown or empty ids are Free. +func PlanFor(id string) Plan { + if p, ok := Plans[id]; ok { + return p + } + return Plans[PlanFree] +} + +// IsPaid reports whether id names a purchasable plan. +func IsPaid(id string) bool { + p, ok := Plans[id] + return ok && p.PriceUSDCents > 0 +} + +// Within reports whether a current count leaves room for one more under +// limit (Unlimited always does). +func Within(current, limit int) bool { + return limit == Unlimited || current < limit +} diff --git a/internal/database/database.go b/internal/database/database.go index 0276729..d6539e0 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "errors" "fmt" + "sync/atomic" "time" "github.com/jackc/pgx/v5" @@ -70,6 +71,10 @@ func (c Config) normalized() (Config, error) { type DB struct { pool *pgxpool.Pool + // planEnforcement is the single switch for billing-plan limits + // (DEC-221). Off by default: a self-hosted deployment with no + // MAILX_PAYSTACK_SECRET_KEY keeps its pre-billing unlimited behavior. + planEnforcement atomic.Bool } // Open builds a bounded connection pool and verifies connectivity. diff --git a/internal/database/migrations/000031_billing_plans.down.sql b/internal/database/migrations/000031_billing_plans.down.sql new file mode 100644 index 0000000..a202b43 --- /dev/null +++ b/internal/database/migrations/000031_billing_plans.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS billing_payments; +DROP INDEX IF EXISTS idx_tenants_paid_period_end; +ALTER TABLE tenants + DROP COLUMN IF EXISTS paystack_customer_code, + DROP COLUMN IF EXISTS plan_current_period_end, + DROP COLUMN IF EXISTS plan_status, + DROP COLUMN IF EXISTS plan; diff --git a/internal/database/migrations/000031_billing_plans.up.sql b/internal/database/migrations/000031_billing_plans.up.sql new file mode 100644 index 0000000..31378f8 --- /dev/null +++ b/internal/database/migrations/000031_billing_plans.up.sql @@ -0,0 +1,39 @@ +-- v0.47 phase 2: billing plans (Free/Plus/Pro) and Paystack payments. +-- Every existing and new tenant defaults to 'free'. Plan LIMITS are only +-- enforced when the deployment configures MAILX_PAYSTACK_SECRET_KEY (see +-- DEC-221): a self-hosted instance keeps its pre-billing unlimited behavior +-- even though its tenants carry plan = 'free' here. +ALTER TABLE tenants + ADD COLUMN plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'plus', 'pro')), + ADD COLUMN plan_status TEXT NOT NULL DEFAULT 'active' CHECK (plan_status IN ('active', 'lapsed')), + ADD COLUMN plan_current_period_end TIMESTAMPTZ, + ADD COLUMN paystack_customer_code TEXT; + +-- Pin every PRE-EXISTING tenant's effective retention window at today's +-- flat default (90 days, DefaultRetentionDays) by making it explicit. A +-- NULL retention_days now falls back to the tenant's PLAN's window when +-- enforcement is on (Free = 7 days) instead of the flat 90 - without this +-- backfill, an operator turning on MAILX_PAYSTACK_SECRET_KEY for the first +-- time on an existing deployment would silently shrink every tenant's +-- retention window from 90 to 7 days, and the next hourly retention-purge +-- run would irreversibly hard-delete any terminal message between 7 and 90 +-- days old (data-loss finding, PR #24 review). A brand-new tenant created +-- after billing is enabled has no messages yet, so the plan's own window +-- applying to it from day one is correct, not a regression. +UPDATE tenants SET retention_days = 90 WHERE retention_days IS NULL; + +-- The lapse ticker scans only paid tenants. +CREATE INDEX idx_tenants_paid_period_end ON tenants (plan_current_period_end) WHERE plan <> 'free'; + +-- One row per Paystack transaction reference ever applied. The primary key +-- makes webhook application idempotent: a replayed (still validly signed) +-- charge.success can never extend a plan a second time. +CREATE TABLE billing_payments ( + reference TEXT PRIMARY KEY CHECK (length(reference) BETWEEN 1 AND 200), + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + plan TEXT NOT NULL CHECK (plan IN ('plus', 'pro')), + amount BIGINT NOT NULL, + currency TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_billing_payments_tenant ON billing_payments (tenant_id, created_at DESC); diff --git a/internal/database/org_invitations.go b/internal/database/org_invitations.go index 35bfb29..e278e96 100644 --- a/internal/database/org_invitations.go +++ b/internal/database/org_invitations.go @@ -49,21 +49,66 @@ func (db *DB) IsTenantOwner(ctx context.Context, tenantID, humanID string) (bool // earlier version invalidated the old link and committed the new one // before attempting delivery, so a failed send left the invitee with // NEITHER a working old link nor a delivered new one). +// +// The insert is wrapped in a transaction holding +// pg_advisory_xact_lock(hashtextextended(tenant_id||':'||email)) for its +// duration. This is NOT about mutual exclusion for its own sake - it's +// what makes SupersedeOtherPendingOrgInvitations' (created_at, id) tuple +// comparison actually correct under concurrency. That comparison assumes +// "smaller tuple" implies "already committed, therefore visible to a +// later query" - true only if inserts for the same address are +// serialized. Without this lock they are not: PostgreSQL's now() is +// captured at a transaction's START, not its commit, so two concurrent +// autocommit INSERTs can commit in a DIFFERENT order than their +// created_at values suggest (e.g. under connection-pool queueing). A CI +// run with 5 concurrent invites to one address caught this directly: a +// row with a small created_at committed late enough that a +// larger-created_at row's supersede call ran and completed BEFORE that +// small row was visible to it - so nothing ever superseded it, and 2 +// links ended up alive instead of 1. Serializing inserts for the same +// address via this lock restores the invariant SupersedeOtherPendingOrgInvitations +// depends on: for one address, created_at ordering now matches true +// commit ordering, because only one insert for that address can be +// in flight at a time. The lock is released at commit (xact-scoped), well +// before the slower SendSystemEmail network call that follows - it is +// never held across that. func (db *DB) CreateOrgInvitation(ctx context.Context, tenantID, invitedBy, email, tokenHash string, expiresAt time.Time) (OrgInvitation, error) { id, err := newID() if err != nil { return OrgInvitation{}, err } + tx, err := db.pool.Begin(ctx) + if err != nil { + return OrgInvitation{}, fmt.Errorf("database: begin create org invitation: %w", normalizeErr(err)) + } + defer func() { _ = tx.Rollback(ctx) }() + + normalized := normalizeEmail(email) + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, tenantID+":"+normalized); err != nil { + return OrgInvitation{}, fmt.Errorf("database: lock invitation address: %w", normalizeErr(err)) + } + + // created_at is set explicitly to clock_timestamp(), NOT left to the + // column's now() default: now()/transaction_timestamp() is fixed at + // this transaction's BEGIN, which happened before the advisory lock + // wait above - a transaction that waited on the lock would otherwise + // still get an EARLIER created_at than one that acquired the lock and + // committed first, undoing exactly the ordering guarantee the lock + // exists to provide. clock_timestamp() reflects the actual moment this + // statement runs, i.e. after the lock is held. var inv OrgInvitation - err = db.pool.QueryRow(ctx, ` - INSERT INTO org_invitations (id, tenant_id, invited_by, normalized_email, raw_email, token_hash, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + err = tx.QueryRow(ctx, ` + INSERT INTO org_invitations (id, tenant_id, invited_by, normalized_email, raw_email, token_hash, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, clock_timestamp()) RETURNING id, tenant_id, invited_by, normalized_email, raw_email, token_hash, expires_at, accepted_at, created_at`, - id, tenantID, invitedBy, normalizeEmail(email), email, tokenHash, expiresAt, + id, tenantID, invitedBy, normalized, email, tokenHash, expiresAt, ).Scan(&inv.ID, &inv.TenantID, &inv.InvitedBy, &inv.NormalizedEmail, &inv.RawEmail, &inv.TokenHash, &inv.ExpiresAt, &inv.AcceptedAt, &inv.CreatedAt) if err != nil { return OrgInvitation{}, normalizeErr(err) } + if err := tx.Commit(ctx); err != nil { + return OrgInvitation{}, fmt.Errorf("database: commit create org invitation: %w", normalizeErr(err)) + } return inv, nil } @@ -147,6 +192,9 @@ func (db *DB) AcceptOrgInvitationForExistingHuman(ctx context.Context, invitatio if tag.RowsAffected() == 0 { return ErrOrgInvitationConsumed } + if err := db.lockMemberCap(ctx, tx, tenantID, humanID); err != nil { + return err // rollback leaves the invitation unconsumed + } if _, err := tx.Exec(ctx, `INSERT INTO tenant_members (tenant_id, human_id, role) VALUES ($1, $2, 'member') ON CONFLICT DO NOTHING`, @@ -184,6 +232,10 @@ func (db *DB) AcceptOrgInvitationWithSignup(ctx context.Context, invitationID, t if tag.RowsAffected() == 0 { return Human{}, ErrOrgInvitationConsumed } + // Empty humanID: the new account cannot already be a member. + if err := db.lockMemberCap(ctx, tx, tenantID, ""); err != nil { + return Human{}, err + } humanID, err := newID() if err != nil { diff --git a/internal/database/plans.go b/internal/database/plans.go new file mode 100644 index 0000000..1ebc16a --- /dev/null +++ b/internal/database/plans.go @@ -0,0 +1,290 @@ +package database + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Ferousco-dev/mailx/internal/billing" + "github.com/jackc/pgx/v5" +) + +// ErrPlanLimit is returned (wrapped, with a human-readable reason) when an +// action would exceed the tenant's billing-plan limits. Only ever returned +// while plan enforcement is enabled. +var ErrPlanLimit = errors.New("plan limit reached") + +func planLimitf(format string, args ...any) error { + return fmt.Errorf("%w: "+format, append([]any{ErrPlanLimit}, args...)...) +} + +// EnablePlanEnforcement turns on billing-plan limits for every enforcement +// point (DEC-221). cmd/mailx calls it only when MAILX_PAYSTACK_SECRET_KEY is +// configured; it is never turned off at runtime. +func (db *DB) EnablePlanEnforcement() { db.planEnforcement.Store(true) } + +// PlanEnforcementEnabled reports whether plan limits apply. +func (db *DB) PlanEnforcementEnabled() bool { return db.planEnforcement.Load() } + +// TenantPlan is a tenant's billing state. +type TenantPlan struct { + Plan string + Status string + CurrentPeriodEnd *time.Time + PaystackCustomerCode *string +} + +// GetTenantPlan returns tenantID's billing state (ErrNotFound if absent). +func (db *DB) GetTenantPlan(ctx context.Context, tenantID string) (TenantPlan, error) { + var p TenantPlan + err := db.pool.QueryRow(ctx, + `SELECT plan, plan_status, plan_current_period_end, paystack_customer_code FROM tenants WHERE id = $1`, tenantID, + ).Scan(&p.Plan, &p.Status, &p.CurrentPeriodEnd, &p.PaystackCustomerCode) + if err != nil { + return TenantPlan{}, normalizeErr(err) + } + return p, nil +} + +// enforcedPlan returns the tenant's plan and true when enforcement is on, +// or (zero, false) without touching the database when it is off. +func (db *DB) enforcedPlan(ctx context.Context, tenantID string) (billing.Plan, bool, error) { + if !db.PlanEnforcementEnabled() { + return billing.Plan{}, false, nil + } + tp, err := db.GetTenantPlan(ctx, tenantID) + if err != nil { + return billing.Plan{}, false, fmt.Errorf("database: tenant plan: %w", err) + } + return billing.PlanFor(tp.Plan), true, nil +} + +// CheckDailySendLimit refuses (ErrPlanLimit) when accepting adding more +// messages would take the tenant past its plan's daily volume. "Daily" is +// the current UTC calendar day of messages.created_at. The count is bounded +// by the limit itself (same pattern as CountTenantQueued). It is a check, +// not a reservation: concurrent requests can overshoot by at most their own +// batch sizes (DEC-222). +func (db *DB) CheckDailySendLimit(ctx context.Context, tenantID string, adding int) error { + plan, on, err := db.enforcedPlan(ctx, tenantID) + if err != nil || !on || plan.DailySends == billing.Unlimited { + return err + } + var n int + err = db.pool.QueryRow(ctx, ` + SELECT count(*) FROM ( + SELECT 1 FROM messages + WHERE tenant_id = $1 AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' + LIMIT $2 + ) s`, tenantID, plan.DailySends).Scan(&n) + if err != nil { + return fmt.Errorf("database: count daily sends: %w", normalizeErr(err)) + } + if n+adding > plan.DailySends { + return planLimitf("the %s plan allows %d emails per day", plan.ID, plan.DailySends) + } + return nil +} + +// CheckDomainLimit refuses when the tenant already has its plan's number of +// (non-deleted) domains. +func (db *DB) CheckDomainLimit(ctx context.Context, tenantID string) error { + plan, on, err := db.enforcedPlan(ctx, tenantID) + if err != nil || !on || plan.Domains == billing.Unlimited { + return err + } + var n int + err = db.pool.QueryRow(ctx, ` + SELECT count(*) FROM ( + SELECT 1 FROM domains WHERE tenant_id = $1 AND deleted_at IS NULL LIMIT $2 + ) s`, tenantID, plan.Domains).Scan(&n) + if err != nil { + return fmt.Errorf("database: count domains: %w", normalizeErr(err)) + } + if !billing.Within(n, plan.Domains) { + return planLimitf("the %s plan allows %d sending domains", plan.ID, plan.Domains) + } + return nil +} + +// CheckMemberLimit is the early (invite-send time) member-cap check. The +// authoritative, race-free check runs inside the accept transactions +// (lockMemberCap); see DEC-223. +func (db *DB) CheckMemberLimit(ctx context.Context, tenantID string) error { + plan, on, err := db.enforcedPlan(ctx, tenantID) + if err != nil || !on || plan.Members == billing.Unlimited { + return err + } + var n int + if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM tenant_members WHERE tenant_id = $1`, tenantID).Scan(&n); err != nil { + return fmt.Errorf("database: count members: %w", normalizeErr(err)) + } + if !billing.Within(n, plan.Members) { + return planLimitf("the %s plan allows %d team members", plan.ID, plan.Members) + } + return nil +} + +// lockMemberCap runs inside an accept transaction before a tenant_members +// insert. It locks the tenant row (SELECT ... FOR UPDATE), so concurrent +// accepts for the same tenant serialize and cannot both pass the count, then +// refuses if humanID is not already a member and the org is at its cap. +func (db *DB) lockMemberCap(ctx context.Context, tx pgx.Tx, tenantID, humanID string) error { + if !db.PlanEnforcementEnabled() { + return nil + } + var planID string + if err := tx.QueryRow(ctx, `SELECT plan FROM tenants WHERE id = $1 FOR UPDATE`, tenantID).Scan(&planID); err != nil { + return fmt.Errorf("database: lock tenant for member cap: %w", normalizeErr(err)) + } + plan := billing.PlanFor(planID) + if plan.Members == billing.Unlimited { + return nil + } + var n int + var already bool + if err := tx.QueryRow(ctx, ` + SELECT count(*), coalesce(bool_or(human_id = $2), false) FROM tenant_members WHERE tenant_id = $1`, + tenantID, humanID).Scan(&n, &already); err != nil { + return fmt.Errorf("database: count members: %w", normalizeErr(err)) + } + if already { + return nil + } + if !billing.Within(n, plan.Members) { + return planLimitf("the %s plan allows %d team members", plan.ID, plan.Members) + } + return nil +} + +// CheckFeature refuses when the tenant's plan has the feature disabled. +// feature is "broadcasts" or "webhooks". +func (db *DB) CheckFeature(ctx context.Context, tenantID, feature string) error { + plan, on, err := db.enforcedPlan(ctx, tenantID) + if err != nil || !on { + return err + } + var allowed bool + switch feature { + case "broadcasts": + allowed = plan.Broadcasts + case "webhooks": + allowed = plan.Webhooks + default: + return fmt.Errorf("database: unknown plan feature %q", feature) + } + if !allowed { + return planLimitf("%s are not available on the %s plan", feature, plan.ID) + } + return nil +} + +// IsTenantMember reports whether humanID belongs to tenantID in any role. +func (db *DB) IsTenantMember(ctx context.Context, tenantID, humanID string) (bool, error) { + var ok bool + err := db.pool.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM tenant_members WHERE tenant_id = $1 AND human_id = $2)`, tenantID, humanID, + ).Scan(&ok) + if err != nil { + return false, fmt.Errorf("database: is tenant member: %w", normalizeErr(err)) + } + return ok, nil +} + +// ErrPaymentAlreadyApplied means this Paystack reference was already applied +// (a webhook replay or Paystack retry); nothing changed. +var ErrPaymentAlreadyApplied = errors.New("database: payment already applied") + +// Payment is one verified Paystack charge to apply. +type Payment struct { + Reference string + TenantID string + Plan string + Amount int64 + Currency string + CustomerCode string +} + +// ApplyPlanPayment atomically records the payment reference (primary key: +// a replay is ErrPaymentAlreadyApplied) and extends the tenant's plan by +// period. ErrNotFound if the tenant does not exist. +// +// A renewal of the SAME plan while still active extends from the LATER of +// now and the current plan_current_period_end, rather than overwriting it +// from now - otherwise an owner renewing a few days early would simply +// lose those remaining paid days (CodeRabbit, PR #24). A plan CHANGE +// (upgrade/downgrade) or a renewal after the plan had already lapsed +// starts a fresh period from now instead: carrying over remaining time +// priced under a DIFFERENT plan has no well-defined meaning here. +func (db *DB) ApplyPlanPayment(ctx context.Context, p Payment, period time.Duration) error { + if !billing.IsPaid(p.Plan) { + return fmt.Errorf("database: plan %q is not purchasable", p.Plan) + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("database: begin apply payment: %w", normalizeErr(err)) + } + defer func() { _ = tx.Rollback(ctx) }() + + var customer *string + if p.CustomerCode != "" { + customer = &p.CustomerCode + } + tag, err := tx.Exec(ctx, ` + UPDATE tenants SET plan = $2, plan_status = 'active', + plan_current_period_end = GREATEST(now(), + CASE WHEN plan = $2 AND plan_status = 'active' AND plan_current_period_end > now() + THEN plan_current_period_end ELSE now() END + ) + make_interval(secs => $3), + paystack_customer_code = COALESCE($4, paystack_customer_code) + WHERE id = $1`, p.TenantID, p.Plan, period.Seconds(), customer) + if err != nil { + return fmt.Errorf("database: apply plan: %w", normalizeErr(err)) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + tag, err = tx.Exec(ctx, ` + INSERT INTO billing_payments (reference, tenant_id, plan, amount, currency) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (reference) DO NOTHING`, + p.Reference, p.TenantID, p.Plan, p.Amount, p.Currency) + if err != nil { + return fmt.Errorf("database: record payment: %w", normalizeErr(err)) + } + if tag.RowsAffected() == 0 { + return ErrPaymentAlreadyApplied // rollback also undoes the UPDATE above + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("database: commit apply payment: %w", normalizeErr(err)) + } + return nil +} + +// DowngradeLapsedPlans moves every paid tenant whose period ended before now +// back to free with status 'lapsed', returning how many changed. MVP: no +// automatic renewal charge is attempted (RSK-044). +// +// Before clearing plan, it PINS retention_days explicitly to the lapsing +// plan's own window (COALESCE: only when the tenant has no existing +// explicit override, which always wins per this column's normal +// convention). Without this, a tenant that had retention_days = NULL +// (relying on their paid plan's 30/90-day default) would silently drop to +// Free's 7-day default the instant they lapse, and the next +// retention-purge run would irreversibly hard-delete anything between 7 +// days and their old window (data-loss finding, PR #24 review) - a +// renewal running even one hour late would be enough to trigger it. +// Pinning the window here means a lapse only ever changes billing state, +// never retention behavior. +func (db *DB) DowngradeLapsedPlans(ctx context.Context, now time.Time) (int64, error) { + tag, err := db.pool.Exec(ctx, ` + UPDATE tenants SET + retention_days = COALESCE(retention_days, + CASE plan WHEN 'plus' THEN 30 WHEN 'pro' THEN 90 END), + plan = 'free', plan_status = 'lapsed' + WHERE plan <> 'free' AND plan_current_period_end < $1`, now) + if err != nil { + return 0, fmt.Errorf("database: downgrade lapsed plans: %w", normalizeErr(err)) + } + return tag.RowsAffected(), nil +} diff --git a/internal/database/plans_test.go b/internal/database/plans_test.go new file mode 100644 index 0000000..9e40b85 --- /dev/null +++ b/internal/database/plans_test.go @@ -0,0 +1,433 @@ +package database + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/Ferousco-dev/mailx/internal/billing" +) + +func setPlan(t *testing.T, db *DB, tenantID, plan string) { + t.Helper() + if _, err := db.pool.Exec(context.Background(), `UPDATE tenants SET plan = $2 WHERE id = $1`, tenantID, plan); err != nil { + t.Fatal(err) + } +} + +func TestNewTenantDefaultsToFreePlan(t *testing.T) { + db := newTestDB(t) + tn := newTestTenant(t, db) + tp, err := db.GetTenantPlan(context.Background(), tn.ID) + if err != nil { + t.Fatal(err) + } + if tp.Plan != "free" || tp.Status != "active" || tp.CurrentPeriodEnd != nil || tp.PaystackCustomerCode != nil { + t.Fatalf("unexpected default plan state %+v", tp) + } +} + +// Self-hosted (enforcement off): every check is inert even far over the Free caps. +func TestPlanChecksInertWhenEnforcementDisabled(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + tn := newTestTenant(t, db) + for i := 0; i < 6; i++ { + if _, err := db.CreateDomain(ctx, tn.ID, fmt.Sprintf("d%d.example.com", i), fmt.Sprintf("tok%d", i)); err != nil { + t.Fatal(err) + } + } + if err := db.CheckDomainLimit(ctx, tn.ID); err != nil { + t.Fatalf("domain check fired with enforcement off: %v", err) + } + if err := db.CheckFeature(ctx, tn.ID, "broadcasts"); err != nil { + t.Fatalf("broadcast check fired with enforcement off: %v", err) + } + if err := db.CheckFeature(ctx, tn.ID, "webhooks"); err != nil { + t.Fatalf("webhook check fired with enforcement off: %v", err) + } + if err := db.CheckDailySendLimit(ctx, tn.ID, 1_000_000); err != nil { + t.Fatalf("daily check fired with enforcement off: %v", err) + } + if err := db.CheckMemberLimit(ctx, tn.ID); err != nil { + t.Fatalf("member check fired with enforcement off: %v", err) + } + if got := db.DefaultRetentionDaysFor("free"); got != DefaultRetentionDays { + t.Fatalf("retention default with enforcement off = %d, want %d", got, DefaultRetentionDays) + } +} + +func TestDomainLimit(t *testing.T) { + db := newTestDB(t) + db.EnablePlanEnforcement() + ctx := context.Background() + tn := newTestTenant(t, db) + for i := 0; i < 5; i++ { + if err := db.CheckDomainLimit(ctx, tn.ID); err != nil { + t.Fatalf("domain %d under cap refused: %v", i, err) + } + if _, err := db.CreateDomain(ctx, tn.ID, fmt.Sprintf("d%d.example.com", i), fmt.Sprintf("tok%d", i)); err != nil { + t.Fatal(err) + } + } + if err := db.CheckDomainLimit(ctx, tn.ID); !errors.Is(err, ErrPlanLimit) { + t.Fatalf("6th domain on free: want ErrPlanLimit, got %v", err) + } + setPlan(t, db, tn.ID, "plus") + if err := db.CheckDomainLimit(ctx, tn.ID); err != nil { + t.Fatalf("plus allows 15: %v", err) + } + setPlan(t, db, tn.ID, "pro") + if err := db.CheckDomainLimit(ctx, tn.ID); err != nil { + t.Fatalf("pro is unlimited: %v", err) + } +} + +func TestFeatureGates(t *testing.T) { + db := newTestDB(t) + db.EnablePlanEnforcement() + ctx := context.Background() + tn := newTestTenant(t, db) + for _, f := range []string{"broadcasts", "webhooks"} { + if err := db.CheckFeature(ctx, tn.ID, f); !errors.Is(err, ErrPlanLimit) { + t.Fatalf("free %s: want ErrPlanLimit, got %v", f, err) + } + } + setPlan(t, db, tn.ID, "plus") + for _, f := range []string{"broadcasts", "webhooks"} { + if err := db.CheckFeature(ctx, tn.ID, f); err != nil { + t.Fatalf("plus %s refused: %v", f, err) + } + } +} + +func TestDailySendLimit(t *testing.T) { + db := newTestDB(t) + db.EnablePlanEnforcement() + ctx := context.Background() + tn := newTestTenant(t, db) + // Seed 499 of today's messages in one statement (free cap 500). + msg, err := db.InsertMessage(ctx, sampleNewMessage(t, tn.ID)) + if err != nil { + t.Fatal(err) + } + if _, err := db.pool.Exec(ctx, ` + INSERT INTO messages (id, tenant_id, status, created_at, updated_at, mail_from, subject) + SELECT m.id || '-' || g, m.tenant_id, m.status, now(), now(), m.mail_from, m.subject + FROM messages m, generate_series(1, 498) g WHERE m.id = $1`, msg.ID); err != nil { + t.Fatalf("seed: %v", err) + } + if err := db.CheckDailySendLimit(ctx, tn.ID, 1); err != nil { + t.Fatalf("500th message refused: %v", err) + } + if err := db.CheckDailySendLimit(ctx, tn.ID, 2); !errors.Is(err, ErrPlanLimit) { + t.Fatalf("501st message: want ErrPlanLimit, got %v", err) + } + // Yesterday's messages do not count toward today. + if _, err := db.pool.Exec(ctx, `UPDATE messages SET created_at = now() - interval '2 days' WHERE tenant_id = $1`, tn.ID); err != nil { + t.Fatal(err) + } + if err := db.CheckDailySendLimit(ctx, tn.ID, 500); err != nil { + t.Fatalf("new day refused: %v", err) + } + setPlan(t, db, tn.ID, "plus") + if err := db.CheckDailySendLimit(ctx, tn.ID, 10_000); err != nil { + t.Fatalf("plus 10k refused: %v", err) + } + if err := db.CheckDailySendLimit(ctx, tn.ID, 10_001); !errors.Is(err, ErrPlanLimit) { + t.Fatalf("plus over 10k: want ErrPlanLimit, got %v", err) + } +} + +func newOwnedOrg(t *testing.T, db *DB, email string) (Human, Tenant) { + t.Helper() + h, err := db.CreateHuman(context.Background(), "Owner", email, "x") + if err != nil { + t.Fatal(err) + } + tn, err := db.CreateOrganization(context.Background(), h.ID, "Org "+email) + if err != nil { + t.Fatal(err) + } + return h, tn +} + +func TestMemberCapAtAccept(t *testing.T) { + db := newTestDB(t) + db.EnablePlanEnforcement() + ctx := context.Background() + owner, tn := newOwnedOrg(t, db, "owner@example.com") + now := time.Now().UTC() + inv, err := db.CreateOrgInvitation(ctx, tn.ID, owner.ID, "a@example.com", "hash-a", now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + // Free: owner only. + if err := db.CheckMemberLimit(ctx, tn.ID); !errors.Is(err, ErrPlanLimit) { + t.Fatalf("free invite-time check: want ErrPlanLimit, got %v", err) + } + if _, err := db.AcceptOrgInvitationWithSignup(ctx, inv.ID, tn.ID, "A", "a@example.com", "x", now); !errors.Is(err, ErrPlanLimit) { + t.Fatalf("free accept: want ErrPlanLimit, got %v", err) + } + // Refusal rolled back: the invitation is still usable after upgrading. + setPlan(t, db, tn.ID, "plus") + if err := db.CheckMemberLimit(ctx, tn.ID); err != nil { + t.Fatalf("plus invite-time check: %v", err) + } + if _, err := db.AcceptOrgInvitationWithSignup(ctx, inv.ID, tn.ID, "A", "a@example.com", "x", now); err != nil { + t.Fatalf("plus accept after upgrade: %v", err) + } + // Existing-human path at the cap is refused too. + if _, err := db.pool.Exec(ctx, `UPDATE tenants SET plan = 'free' WHERE id = $1`, tn.ID); err != nil { + t.Fatal(err) + } + b, err := db.CreateHuman(ctx, "B", "b@example.com", "x") + if err != nil { + t.Fatal(err) + } + invB, err := db.CreateOrgInvitation(ctx, tn.ID, owner.ID, "b@example.com", "hash-b", now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if err := db.AcceptOrgInvitationForExistingHuman(ctx, invB.ID, tn.ID, b.ID, now); !errors.Is(err, ErrPlanLimit) { + t.Fatalf("existing-human accept over cap: want ErrPlanLimit, got %v", err) + } +} + +// Concurrent accepts must not push an org over its cap: the tenant row lock +// serializes them (DEC-223). Plus = 5 members; owner + 4 slots, 8 racers. +func TestMemberCapConcurrentAccepts(t *testing.T) { + db := newTestDB(t) + db.EnablePlanEnforcement() + ctx := context.Background() + owner, tn := newOwnedOrg(t, db, "owner@example.com") + setPlan(t, db, tn.ID, "plus") + now := time.Now().UTC() + const racers = 8 + ids := make([]string, racers) + for i := range ids { + inv, err := db.CreateOrgInvitation(ctx, tn.ID, owner.ID, fmt.Sprintf("r%d@example.com", i), fmt.Sprintf("hash-%d", i), now.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + ids[i] = inv.ID + } + var wg sync.WaitGroup + results := make(chan error, racers) + start := make(chan struct{}) + for i := 0; i < racers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + _, err := db.AcceptOrgInvitationWithSignup(ctx, ids[i], tn.ID, "R", fmt.Sprintf("r%d@example.com", i), "x", now) + results <- err + }(i) + } + close(start) + wg.Wait() + close(results) + ok, limited := 0, 0 + for err := range results { + switch { + case err == nil: + ok++ + case errors.Is(err, ErrPlanLimit): + limited++ + default: + t.Fatalf("unexpected error: %v", err) + } + } + var members int + if err := db.pool.QueryRow(ctx, `SELECT count(*) FROM tenant_members WHERE tenant_id = $1`, tn.ID).Scan(&members); err != nil { + t.Fatal(err) + } + if ok != 4 || limited != 4 || members != 5 { + t.Fatalf("ok=%d limited=%d members=%d; want 4/4/5", ok, limited, members) + } +} + +func TestRetentionDefaultFollowsPlanWhenEnforced(t *testing.T) { + db := newTestDB(t) + db.EnablePlanEnforcement() + ctx := context.Background() + free := newTestTenant(t, db) + pro := newTestTenant(t, db) + setPlan(t, db, pro.ID, "pro") + fm, err := db.InsertMessage(ctx, sampleNewMessage(t, free.ID)) + if err != nil { + t.Fatal(err) + } + pm, err := db.InsertMessage(ctx, sampleNewMessage(t, pro.ID)) + if err != nil { + t.Fatal(err) + } + tenDaysAgo := time.Now().UTC().Add(-10 * 24 * time.Hour) + for _, id := range []string{fm.ID, pm.ID} { + backdateMessage(t, db, id, tenDaysAgo) + markMessageTerminal(t, db, id, "delivered") + } + purged, err := db.PurgeExpiredMessages(ctx, noopDeleteDisk) + if err != nil { + t.Fatal(err) + } + if len(purged) != 1 || purged[0] != fm.ID { + t.Fatalf("free (7d) message should be purged, pro (90d) kept; purged=%v", purged) + } + if db.DefaultRetentionDaysFor("plus") != billing.PlanFor("plus").RetentionDays { + t.Fatal("DefaultRetentionDaysFor ignores plan under enforcement") + } +} + +func TestRetentionDefaultFlatWhenNotEnforced(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + free := newTestTenant(t, db) + fm, err := db.InsertMessage(ctx, sampleNewMessage(t, free.ID)) + if err != nil { + t.Fatal(err) + } + backdateMessage(t, db, fm.ID, time.Now().UTC().Add(-10*24*time.Hour)) + markMessageTerminal(t, db, fm.ID, "delivered") + purged, err := db.PurgeExpiredMessages(ctx, noopDeleteDisk) + if err != nil { + t.Fatal(err) + } + if len(purged) != 0 { + t.Fatalf("self-hosted free tenant must keep the flat 90-day default; purged=%v", purged) + } +} + +func TestApplyPlanPaymentAndReplay(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + tn := newTestTenant(t, db) + const period = 30 * 24 * time.Hour + before := time.Now().UTC() + p := Payment{Reference: "ref-1", TenantID: tn.ID, Plan: "plus", Amount: 600, Currency: "USD", CustomerCode: "CUS_1"} + if err := db.ApplyPlanPayment(ctx, p, period); err != nil { + t.Fatal(err) + } + tp, _ := db.GetTenantPlan(ctx, tn.ID) + if tp.Plan != "plus" || tp.Status != "active" || tp.CurrentPeriodEnd == nil || tp.PaystackCustomerCode == nil { + t.Fatalf("unexpected plan after payment: %+v", tp) + } + firstEnd := *tp.CurrentPeriodEnd + if d := firstEnd.Sub(before.Add(period)); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("expected period_end ~= now+%v, got %v (before=%v)", period, firstEnd, before) + } + // Replay must change nothing. + if err := db.ApplyPlanPayment(ctx, p, 60*24*time.Hour); !errors.Is(err, ErrPaymentAlreadyApplied) { + t.Fatalf("replay: want ErrPaymentAlreadyApplied, got %v", err) + } + tp, _ = db.GetTenantPlan(ctx, tn.ID) + if !tp.CurrentPeriodEnd.Equal(firstEnd) { + t.Fatalf("replay extended the period to %v", tp.CurrentPeriodEnd) + } + // A genuine renewal of the SAME plan while still active EXTENDS from + // the existing period_end, not from now (CodeRabbit, PR #24) - an + // owner renewing early must not lose the remaining paid days. + p2 := Payment{Reference: "ref-2", TenantID: tn.ID, Plan: "plus", Amount: 600, Currency: "USD"} + if err := db.ApplyPlanPayment(ctx, p2, period); err != nil { + t.Fatal(err) + } + tp, _ = db.GetTenantPlan(ctx, tn.ID) + wantExtended := firstEnd.Add(period) + if d := tp.CurrentPeriodEnd.Sub(wantExtended); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("expected renewal to extend from the prior period_end (~%v), got %v", wantExtended, tp.CurrentPeriodEnd) + } + // A PLAN CHANGE (not the same plan) starts a fresh period from now, + // rather than carrying over time priced under the old plan. + beforeUpgrade := time.Now().UTC() + p3 := Payment{Reference: "ref-3", TenantID: tn.ID, Plan: "pro", Amount: 2400, Currency: "USD"} + if err := db.ApplyPlanPayment(ctx, p3, period); err != nil { + t.Fatal(err) + } + tp, _ = db.GetTenantPlan(ctx, tn.ID) + if tp.Plan != "pro" { + t.Fatalf("expected plan changed to pro, got %v", tp.Plan) + } + if d := tp.CurrentPeriodEnd.Sub(beforeUpgrade.Add(period)); d < -5*time.Second || d > 5*time.Second { + t.Fatalf("expected an upgrade to start a fresh period from now, got %v", tp.CurrentPeriodEnd) + } + + p.Reference, p.TenantID = "ref-unknown", "no-such-tenant" + if err := db.ApplyPlanPayment(ctx, p, period); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown tenant: want ErrNotFound, got %v", err) + } + p.TenantID, p.Plan = tn.ID, "free" + if err := db.ApplyPlanPayment(ctx, p, period); err == nil { + t.Fatal("free is not purchasable") + } +} + +func TestApplyPlanPaymentReferenceCannotBeReusedForAnotherTenant(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + first := newTestTenant(t, db) + second := newTestTenant(t, db) + const period = 30 * 24 * time.Hour + before := time.Now().UTC() + if err := db.ApplyPlanPayment(ctx, Payment{Reference: "shared-ref", TenantID: first.ID, Plan: billing.PlanPlus, Amount: 600, Currency: "USD"}, period); err != nil { + t.Fatal(err) + } + if err := db.ApplyPlanPayment(ctx, Payment{Reference: "shared-ref", TenantID: second.ID, Plan: billing.PlanPro, Amount: 2400, Currency: "USD"}, 60*24*time.Hour); !errors.Is(err, ErrPaymentAlreadyApplied) { + t.Fatalf("reused payment reference: want ErrPaymentAlreadyApplied, got %v", err) + } + firstPlan, err := db.GetTenantPlan(ctx, first.ID) + if err != nil { + t.Fatal(err) + } + secondPlan, err := db.GetTenantPlan(ctx, second.ID) + if err != nil { + t.Fatal(err) + } + if d := firstPlan.CurrentPeriodEnd.Sub(before.Add(period)); firstPlan.Plan != billing.PlanPlus || firstPlan.CurrentPeriodEnd == nil || d < -5*time.Second || d > 5*time.Second || + secondPlan.Plan != billing.PlanFree || secondPlan.CurrentPeriodEnd != nil { + t.Fatalf("replayed reference changed plan state: first=%+v second=%+v", firstPlan, secondPlan) + } +} + +func TestDowngradeLapsedPlans(t *testing.T) { + db := newTestDB(t) + ctx := context.Background() + lapsed := newTestTenant(t, db) + current := newTestTenant(t, db) + now := time.Now().UTC() + if err := db.ApplyPlanPayment(ctx, Payment{Reference: "a", TenantID: lapsed.ID, Plan: "pro", Amount: 2400, Currency: "USD"}, -time.Minute); err != nil { + t.Fatal(err) + } + if err := db.ApplyPlanPayment(ctx, Payment{Reference: "b", TenantID: current.ID, Plan: "plus", Amount: 600, Currency: "USD"}, time.Hour); err != nil { + t.Fatal(err) + } + n, err := db.DowngradeLapsedPlans(ctx, now) + if err != nil || n != 1 { + t.Fatalf("downgraded %d, err %v; want 1", n, err) + } + tp, _ := db.GetTenantPlan(ctx, lapsed.ID) + if tp.Plan != "free" || tp.Status != "lapsed" { + t.Fatalf("lapsed tenant: %+v", tp) + } + // The lapsed tenant's retention window must be pinned to its former + // plan's 90 days (pro), NOT silently shrunk to Free's 7 - a real + // data-loss bug the review caught (PR #24): without this, the next + // retention-purge run would hard-delete anything between 7 and 90 + // days old for every tenant whose paid plan just lapsed. + lp, err := db.GetTenant(ctx, lapsed.ID) + if err != nil { + t.Fatal(err) + } + if lp.RetentionDays == nil || *lp.RetentionDays != 90 { + t.Fatalf("expected retention_days pinned to 90 (pro) on lapse, got %v", lp.RetentionDays) + } + tp, _ = db.GetTenantPlan(ctx, current.ID) + if tp.Plan != "plus" || tp.Status != "active" { + t.Fatalf("current tenant must be untouched: %+v", tp) + } + if n, _ := db.DowngradeLapsedPlans(ctx, now); n != 0 { + t.Fatalf("second run downgraded %d; want 0 (idempotent)", n) + } +} diff --git a/internal/database/retention.go b/internal/database/retention.go index 7aa96e5..a5a759d 100644 --- a/internal/database/retention.go +++ b/internal/database/retention.go @@ -20,11 +20,25 @@ import ( "errors" "fmt" "time" + + "github.com/Ferousco-dev/mailx/internal/billing" ) -// DefaultRetentionDays applies to any tenant whose retention_days is NULL. +// DefaultRetentionDays applies to any tenant whose retention_days is NULL +// while plan enforcement is OFF (self-hosted). With enforcement on, a NULL +// retention_days falls back to the tenant's plan RetentionDays instead +// (DEC-224); an explicit retention_days always wins. const DefaultRetentionDays = 90 +// DefaultRetentionDaysFor is the effective default window for a tenant on +// planID, honoring whether plan enforcement is enabled. +func (db *DB) DefaultRetentionDaysFor(planID string) int { + if db.PlanEnforcementEnabled() { + return billing.PlanFor(planID).RetentionDays + } + return DefaultRetentionDays +} + // purgeBatchLimit bounds how many expired messages one PurgeExpiredMessages // call deletes: an unbounded backlog would otherwise collect an // arbitrarily large in-memory id slice and hold one long-running @@ -147,12 +161,14 @@ func (db *DB) collectPurgeableIDs(ctx context.Context, deleteDisk func(id string SELECT m.id, m.created_at FROM messages m JOIN tenants t ON t.id = m.tenant_id - WHERE m.created_at < now() - make_interval(days => COALESCE(t.retention_days, $1)) + WHERE m.created_at < now() - make_interval(days => COALESCE(t.retention_days, + CASE WHEN $5 THEN CASE t.plan WHEN 'free' THEN $6::int WHEN 'plus' THEN $7::int ELSE $8::int END ELSE $1 END)) AND m.status NOT IN ('queued', 'processing', 'retrying') AND (m.created_at, m.id) > ($2, $3) ORDER BY m.created_at, m.id LIMIT $4`, - DefaultRetentionDays, afterCreatedAt, afterID, pageSize, + DefaultRetentionDays, afterCreatedAt, afterID, pageSize, db.PlanEnforcementEnabled(), + billing.PlanFor(billing.PlanFree).RetentionDays, billing.PlanFor(billing.PlanPlus).RetentionDays, billing.PlanFor(billing.PlanPro).RetentionDays, ) if err != nil { return nil, fmt.Errorf("database: select expired messages: %w", normalizeErr(err)) diff --git a/internal/humanauth/org_invitation_test.go b/internal/humanauth/org_invitation_test.go index 831fd55..3fe8e09 100644 --- a/internal/humanauth/org_invitation_test.go +++ b/internal/humanauth/org_invitation_test.go @@ -23,6 +23,37 @@ func extractInviteToken(t *testing.T, text string) string { return raw } +func TestMemberCapEnforcedAtInviteAndAccept(t *testing.T) { + db := newTestDB(t) + mailer := &fakeMailer{} + svc, err := NewService(db, testSecret(), WithMailer(mailer), WithDashboardBaseURL("https://app.mailx.dev")) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + owner, err := svc.SignUp(ctx, "Ada", "ada@example.com", "hunter22hunter") + if err != nil { + t.Fatal(err) + } + tenant, err := svc.CreateOrganization(ctx, owner.Human.ID, "Acme Inc", "") + if err != nil { + t.Fatal(err) + } + // Enforcement off (self-hosted): a free org can still invite. + if err := svc.InviteToOrganization(ctx, owner.Human.ID, tenant.ID, "one@example.com"); err != nil { + t.Fatalf("self-hosted invite refused: %v", err) + } + db.EnablePlanEnforcement() + if err := svc.InviteToOrganization(ctx, owner.Human.ID, tenant.ID, "two@example.com"); !errors.Is(err, database.ErrPlanLimit) { + t.Fatalf("free org invite under enforcement: want ErrPlanLimit, got %v", err) + } + // An invite sent before enforcement cannot be accepted past the cap either. + raw := extractInviteToken(t, mailer.calls[0].text) + if _, err := svc.AcceptOrgInvitation(ctx, raw, "", "One", "hunter22hunter"); !errors.Is(err, database.ErrPlanLimit) { + t.Fatalf("accept over cap: want ErrPlanLimit, got %v", err) + } +} + func TestInviteOwnerOnly(t *testing.T) { db := newTestDB(t) mailer := &fakeMailer{} diff --git a/internal/humanauth/service.go b/internal/humanauth/service.go index 2a4283e..793ce14 100644 --- a/internal/humanauth/service.go +++ b/internal/humanauth/service.go @@ -455,6 +455,11 @@ func (s *Service) InviteToOrganization(ctx context.Context, inviterHumanID, tena if !isOwner { return ErrNotOrgOwner } + // Early UX check only; the authoritative member cap is enforced under a + // row lock at accept time (DEC-223). Wraps database.ErrPlanLimit. + if err := s.db.CheckMemberLimit(ctx, tenantID); err != nil { + return fmt.Errorf("humanauth: %w", err) + } tenant, err := s.db.GetTenant(ctx, tenantID) if err != nil { return fmt.Errorf("humanauth: get tenant: %w", err)