diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index d74510e..130ba2d 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -47,9 +47,17 @@ runs: - 'source/end2end-tests/**' - 'source/Makefile' - '.github/**' + # A "service" deployable spans its domain lib + thin bin crate; the + # tenants binary additionally composes the subscriptions + billing libs + # (ADR-0010), so a change to any of them rebuilds tenants. tenants: - 'source/crates/tenants/**' + - 'source/crates/app-tenants/**' + - 'source/crates/subscriptions/**' + - 'source/crates/billing/**' ddns: - 'source/crates/ddns/**' + - 'source/crates/app-ddns/**' tunneller: - 'source/crates/tunneller/**' + - 'source/crates/app-tunneller/**' diff --git a/.github/workflows/build-service.yml b/.github/workflows/build-service.yml index 6a10800..fd36781 100644 --- a/.github/workflows/build-service.yml +++ b/.github/workflows/build-service.yml @@ -80,15 +80,56 @@ jobs: # bails ("Failed to find targets"). `--all` formats every workspace member. run: cargo fmt --all --check --manifest-path source/Cargo.toml + - name: Resolve cargo package set + # A "service" is now a domain lib + its thin bin (uniform lib/bin layout, + # ADR-0010); `tenants` additionally composes the `subscriptions` + `billing` + # libs into its binary. Lint/test the full set; build the bin package. + id: pkgs + env: + SERVICE: ${{ inputs.service }} + run: | + set -euo pipefail + case "$SERVICE" in + tenants) LINT="-p wardnet-tenants -p wardnet-subscriptions -p wardnet-billing -p wardnet-tenants-bin" ;; + ddns) LINT="-p wardnet-ddns -p wardnet-ddns-bin" ;; + tunneller) LINT="-p wardnet-tunneller -p wardnet-tunneller-bin" ;; + *) echo "::error::unknown service $SERVICE"; exit 1 ;; + esac + { + echo "lint=$LINT -p wardnet_common" + echo "bin=-p wardnet-${SERVICE}-bin" + } >> "$GITHUB_OUTPUT" + + - name: Verify aggregate boundaries (ADR-0010) + # The tenants / subscriptions / billing crates MUST stay mutually + # independent (each promotable to its own host) — they may depend only on + # `wardnet_common`. A sibling dependency would make the boundary a + # convention, not a compiler guarantee. Cheap guard so it cannot regress. + if: inputs.service == 'tenants' + working-directory: source + run: | + set -euo pipefail + fail=0 + for c in tenants subscriptions billing; do + if grep -REn 'wardnet[_-](tenants|subscriptions|billing)' "crates/$c/Cargo.toml" \ + | grep -v 'name = '; then + echo "::error::crate '$c' depends on a sibling aggregate crate"; fail=1 + fi + if grep -REn 'use +wardnet_(tenants|subscriptions|billing)' "crates/$c/src" 2>/dev/null; then + echo "::error::crate '$c' imports a sibling aggregate crate"; fail=1 + fi + done + [ "$fail" -eq 0 ] || exit 1 + - name: Clippy - # Lint this service crate + the shared common lib. Pipe clippy JSON - # through clippy-sarif so CI gets a SARIF report for Code Scanning - # while still printing human-readable output. `-D warnings` is - # enforced; any warning becomes a build failure. + # Lint this service's crates (domain lib + thin bin, plus the libs it + # composes) + the shared common lib. Pipe clippy JSON through clippy-sarif + # so CI gets a SARIF report for Code Scanning while still printing + # human-readable output. `-D warnings` is enforced. run: | set -o pipefail cargo clippy --manifest-path source/Cargo.toml \ - -p wardnet-${{ inputs.service }} -p wardnet_common \ + ${{ steps.pkgs.outputs.lint }} \ --all-targets --message-format=json -- -D warnings \ | clippy-sarif | tee rust-clippy-${{ inputs.service }}.sarif | sarif-fmt @@ -115,7 +156,7 @@ jobs: TUNNELLER_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432 run: > cargo test --manifest-path source/Cargo.toml - -p wardnet-${{ inputs.service }} -p wardnet_common + ${{ steps.pkgs.outputs.lint }} --all-targets -- --include-ignored build: @@ -149,7 +190,7 @@ jobs: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: > cargo build --manifest-path source/Cargo.toml - -p wardnet-${{ inputs.service }} + -p wardnet-${{ inputs.service }}-bin --release --target aarch64-unknown-linux-gnu - name: Strip binary diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..d1907fa --- /dev/null +++ b/codecov.yml @@ -0,0 +1,21 @@ +# Codecov configuration. +# +# The cloud services' binary entrypoints (`crates/app-*/src/main.rs`) are thin +# composition roots — process wiring (build repos → services → reactors → listeners) +# exercised by the mesh e2e harness, not by unit coverage — so they are excluded from +# coverage accounting. A 1% status threshold keeps sub-percent noise (e.g. a structural +# refactor that moves uncovered wiring) from failing the patch/project gate; the +# `All checks passed` aggregator remains the required branch-protection check. +coverage: + status: + project: + default: + threshold: 1% + patch: + default: + threshold: 1% + +ignore: + - "**/crates/app-tenants/src/main.rs" + - "**/crates/app-ddns/src/main.rs" + - "**/crates/app-tunneller/src/main.rs" diff --git a/source/AGENTS.md b/source/AGENTS.md index 1443954..b6e23a3 100644 --- a/source/AGENTS.md +++ b/source/AGENTS.md @@ -62,6 +62,26 @@ Conventions and invariants for agents working inside `source/`. > account creation now happens only at a credential-proving moment. See `docs/adr/0008`, > `docs/adr/0009`, and invariants #2/#18/#23/#25. +> **PR1 / My Account (2026-06-28):** the `tenants` crate's intermingled billing was split +> into **three mutually-independent aggregate crates** — **`subscriptions`** (the +> provider-agnostic **license**: status/entitlement/trial+grace + reaper), **`billing`** +> (the **payment** provider: `StripeGateway`, Checkout/Portal, the webhook, the +> `processed_stripe_events` ledger, and a new Billing-owned `billing_customers` table that +> the `stripe_*` columns moved into), and **`tenants`** (identity/networks/daemons + +> Identities). The three depend on `wardnet_common` and **never on each other** +> (compiler-enforced; a CI guard greps for regressions). All cross-crate interaction goes +> through **ports in `wardnet_common`**: the transport-free **`EventBus`/`EventStream`** +> (replacing the tokio-leaking `EventPublisher`; `DomainEvent` is now serde+versioned) and +> the synchronous **`SubscriptionReader`** (entitlement reads), **`SubscriptionCommands`** +> (the one-way Billing→Subscription edge), and **`BillingPort`** (checkout/portal/webhook) +> trait objects, injected by a new composition-root binary crate. The uniform **lib/bin** +> layout was applied to all services: each domain is a pure lib (`crates/`) and a thin +> bin crate (`crates/app-`, artifact name unchanged) composes it — for `tenants` the +> bin composes `tenants` + `subscriptions` + `billing`. The cross-aggregate **reconcile** +> moved to the composition root. **Strictly behavior-preserving**; the split is designed so +> a future out-of-process move is an adapter/config change, not a rewrite. See +> `docs/adr/0010` (supersedes `0006`, refines `0007`) and invariants #22/#23/#24. + > **Status:** every invariant below is **live on `main`**. The earlier `[#444]`/`[#445]` planning > tags are retired — the SNI/tunnel data plane (WS-D), PostgreSQL/Neon, the multi-node `TunnelRouter` > (`LocalRouter`, its sole impl, already does inter-node forwarding), and the inforge-injected env diff --git a/source/CONTEXT.md b/source/CONTEXT.md index 84a6416..b03c953 100644 --- a/source/CONTEXT.md +++ b/source/CONTEXT.md @@ -28,11 +28,21 @@ details. (See `docs/adr/` for the decisions behind these.) httpOnly cookie (30-day sliding), created at login. Distinct from the short-lived [JWT](#caller-type) it mints via the silent exchange — the session is what logout / password-reset / deregister destroys. See `docs/adr/0009`. -- **Subscription** — the billing aggregate that **grants** a tenant's - [entitlement](#entitlement). A tenant has a 1:N history with at most one **live** - (non-canceled) row — its *current* subscription. Status is `trialing → active → - past_due → canceled` (Stripe-driven once paid). The free [trial](#trial) is itself - a subscription row. Owned by `SubscriptionService`; no other service touches it. +- **Subscription** — the **license**: the provider-agnostic aggregate that **grants** a + tenant's [entitlement](#entitlement). A tenant has a 1:N history with at most one + **live** (non-canceled) row — its *current* subscription. Status is `trialing → active + → past_due → canceled`. The free [trial](#trial) is itself a subscription row. Knows + nothing about payment providers (that is [Billing](#billing)); owned by + `SubscriptionService` in the `subscriptions` crate. See `docs/adr/0010`. +- **Billing** — *how* a subscription is paid for: the payment provider (Stripe today), + hosted [Checkout](#checkout-session)/[Portal](#billing-portal), the webhook, the + idempotency ledger, and the provider-reference ids (the `billing_customers` table). + Swappable; owned by `BillingService` in the `billing` crate. Drives the license only + through the [SubscriptionCommands](#subscriptionreader--subscriptioncommands) port — + Subscription never calls Billing back. +- **PaymentProvider** — the port behind which a concrete provider sits (`StripeGateway` + today). Billing talks to the provider only through it, so the wire format never leaks + into the lifecycle logic. - **Plan** — a purchasable tier, defined as a Stripe Price whose metadata carries the `max_networks` / `max_daemons` it grants. Adding a plan is a Stripe change, no deploy. - **Network** — one wardnet network owned by a tenant. Holds a globally-unique @@ -73,10 +83,22 @@ details. (See `docs/adr/` for the decisions behind these.) ## Eventing & reconciliation -- **Domain event** — an in-process signal a service raises so another aggregate can - react, instead of one service reaching into another's repository (`TenantCreated`, - `TenantDeregistered`, `SubscriptionDeactivated`). Best-effort delivery (a broadcast - bus); the reconcile is the guarantee. See `docs/adr/0007`. +- **Domain event** — a signal a service raises so another aggregate can react, instead + of one service reaching into another's repository (`TenantCreated`, + `TenantDeregistered`, `SubscriptionDeactivated`). Best-effort delivery; the + [reconcile](#reconcile) is the guarantee. Serde-serializable with a versioned wire + format. See `docs/adr/0007`, `docs/adr/0010`. +- **EventBus / EventStream** — the transport-free **port** domain events flow through: + `publish(&event)` + `subscribe(group) -> EventStream` (no `tokio` type in any + signature). One in-process adapter today; a durable broker (using `group` for + competing consumers across replicas) drops in later with no reactor change. Supersedes + the in-process-only "broadcast bus" framing. +- **SubscriptionReader / SubscriptionCommands** — the synchronous query/command **ports** + over the [license](#subscription) aggregate (in `wardnet_common`). `SubscriptionReader` + = entitlement reads (`current` / grace-aware `is_active`); `SubscriptionCommands` = the + one-way [Billing](#billing) → Subscription write edge (`convert_trial_to_paid` / + `update_paid` / `mark_past_due` / `cancel`). In-proc adapter = a direct call; a + mesh-mTLS HTTP adapter later. The crates depend on these ports, never on each other. - **Reactor** — a long-running loop subscribed to the event bus that turns a domain event into a call on the **owning** service's method (e.g. `TenantCreated` → `SubscriptionService::create_trial`; `SubscriptionDeactivated` → diff --git a/source/Cargo.lock b/source/Cargo.lock index 198bc62..6e646dc 100644 --- a/source/Cargo.lock +++ b/source/Cargo.lock @@ -2336,9 +2336,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", @@ -3888,6 +3888,27 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wardnet-billing" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "hex", + "hmac 0.13.0", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.11.0", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "wardnet_common", + "wiremock", +] + [[package]] name = "wardnet-ddns" version = "0.1.0" @@ -3922,6 +3943,17 @@ dependencies = [ "wiremock", ] +[[package]] +name = "wardnet-ddns-bin" +version = "0.1.0" +dependencies = [ + "anyhow", + "tokio", + "tracing", + "wardnet-ddns", + "wardnet_common", +] + [[package]] name = "wardnet-e2e-mesh" version = "0.1.0" @@ -3935,6 +3967,22 @@ dependencies = [ "wardnet_common", ] +[[package]] +name = "wardnet-subscriptions" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "wardnet_common", +] + [[package]] name = "wardnet-tenants" version = "0.1.0" @@ -3974,6 +4022,35 @@ dependencies = [ "wiremock", ] +[[package]] +name = "wardnet-tenants-bin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "ed25519-dalek", + "hex", + "http-body-util", + "opentelemetry", + "rcgen", + "reqwest 0.12.28", + "serde_json", + "sha2 0.11.0", + "sqlx", + "tokio", + "tokio-rustls", + "tower", + "tracing", + "uuid", + "wardnet-billing", + "wardnet-subscriptions", + "wardnet-tenants", + "wardnet_common", +] + [[package]] name = "wardnet-tunneller" version = "0.1.0" @@ -4012,6 +4089,18 @@ dependencies = [ "wardnet_common", ] +[[package]] +name = "wardnet-tunneller-bin" +version = "0.1.0" +dependencies = [ + "anyhow", + "opentelemetry", + "tokio", + "tracing", + "wardnet-tunneller", + "wardnet_common", +] + [[package]] name = "wardnet_common" version = "0.1.0" diff --git a/source/Cargo.toml b/source/Cargo.toml index 8b5a9f2..64e7359 100644 --- a/source/Cargo.toml +++ b/source/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/common", "crates/tenants", "crates/ddns", "crates/tunneller", "xtask", "end2end-tests/mesh"] +members = ["crates/common", "crates/subscriptions", "crates/billing", "crates/tenants", "crates/app-tenants", "crates/ddns", "crates/app-ddns", "crates/tunneller", "crates/app-tunneller", "xtask", "end2end-tests/mesh"] [workspace.package] edition = "2024" @@ -16,6 +16,11 @@ missing_panics_doc = "allow" [workspace.dependencies] # Internal wardnet_common = { path = "crates/common" } +wardnet-tenants = { path = "crates/tenants" } +wardnet-subscriptions = { path = "crates/subscriptions" } +wardnet-billing = { path = "crates/billing" } +wardnet-ddns = { path = "crates/ddns" } +wardnet-tunneller = { path = "crates/tunneller" } # Web stack axum = { version = "0.8", features = ["macros", "ws"] } diff --git a/source/crates/app-ddns/Cargo.toml b/source/crates/app-ddns/Cargo.toml new file mode 100644 index 0000000..9be6b58 --- /dev/null +++ b/source/crates/app-ddns/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "wardnet-ddns-bin" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Wardnet DDNS deployable — thin composition root over the wardnet_ddns library" + +# Binary artifact stays `wardnet-ddns` (unchanged deploy unit); only the package that +# owns it moved out of the library crate (uniform lib/bin layout — ADR-0010). +[[bin]] +name = "wardnet-ddns" +path = "src/main.rs" + +[dependencies] +wardnet_common = { workspace = true } +wardnet-ddns = { workspace = true } + +tokio = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } + +[lints] +workspace = true diff --git a/source/crates/ddns/src/main.rs b/source/crates/app-ddns/src/main.rs similarity index 100% rename from source/crates/ddns/src/main.rs rename to source/crates/app-ddns/src/main.rs diff --git a/source/crates/app-tenants/Cargo.toml b/source/crates/app-tenants/Cargo.toml new file mode 100644 index 0000000..e24e9a5 --- /dev/null +++ b/source/crates/app-tenants/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "wardnet-tenants-bin" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Wardnet Tenants deployable — composition root wiring tenants + subscriptions + billing into one binary" + +# The composition root is a thin lib (`wardnet_tenants_app`) — the merged `db` migrator +# plus the cross-aggregate `test_support` fixtures (the `Harness` that wires all three +# aggregates) — and a thin bin that composes it. `test_support` is a non-cfg-test +# module so the integration tests in `tests/` can use it, mirroring the old tenants +# crate's doc-hidden `test_helpers`; its dependencies are therefore regular ones. +[lib] +name = "wardnet_tenants_app" +path = "src/lib.rs" + +# The binary artifact stays `wardnet-tenants` (unchanged deploy unit); only the +# Cargo package that owns it moved out of the tenants *library* crate so the three +# aggregate crates can stay mutually independent (ADR-0010). +[[bin]] +name = "wardnet-tenants" +path = "src/main.rs" + +[dependencies] +wardnet_common = { workspace = true } +wardnet-tenants = { workspace = true } +wardnet-subscriptions = { workspace = true } +wardnet-billing = { workspace = true } + +axum = { workspace = true } +tokio = { workspace = true } +sqlx = { workspace = true } +tracing = { workspace = true } +opentelemetry = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +# The shared integration-test fixture (tests/common/mod.rs) needs these; they are +# test-only, so they stay out of the production library + binary dependency graph. +async-trait = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +ed25519-dalek = { workspace = true } +serde_json = { workspace = true } +tower = { workspace = true } +http-body-util = { workspace = true } +reqwest = { workspace = true } +rcgen = { workspace = true } +tokio-rustls = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +uuid = { workspace = true } + +[lints] +workspace = true diff --git a/source/crates/app-tenants/src/db.rs b/source/crates/app-tenants/src/db.rs new file mode 100644 index 0000000..fe09366 --- /dev/null +++ b/source/crates/app-tenants/src/db.rs @@ -0,0 +1,38 @@ +//! Composition-root database init: one shared Postgres, one linear migration history. +//! +//! Each aggregate crate owns its own `migrations/` dir and exposes a `MIGRATOR` +//! (`sqlx::migrate!`, compile-time-relative). Here we merge them into a single +//! ordered history and run it against the default `_sqlx_migrations` table — so the DB +//! has one coherent schema history while each crate keeps file ownership of its +//! migrations (ADR-0010). We deliberately avoid `Migrator::dangerous_set_table_name` +//! (a documented production data-loss footgun): the merge keeps the standard tracking +//! table. + +use sqlx::migrate::{Migration, Migrator}; + +use wardnet_common::db::DbPools; + +/// Initialise the global pool and run the merged pending migrations. +/// +/// Ordering is by migration **version** (timestamp), independent of crate dependency +/// order: billing's `billing_customers` (+ back-fill) is timestamped *before* +/// subscriptions' `drop_stripe_cols`, so the back-fill reads the `stripe_*` columns +/// before they are dropped. +/// +/// # Errors +/// Returns an error if the pool cannot be established or a migration fails. +pub async fn init(global_database_url: &str) -> anyhow::Result { + let pool = wardnet_common::db::connect(global_database_url).await?; + + let mut all: Vec = wardnet_tenants::db::MIGRATOR + .iter() + .chain(wardnet_subscriptions::MIGRATOR.iter()) + .chain(wardnet_billing::MIGRATOR.iter()) + .cloned() + .collect(); + all.sort_by_key(|m| m.version); + + Migrator::with_migrations(all).run(&pool).await?; + tracing::info!("global database initialised (merged tenants + subscriptions + billing)"); + Ok(DbPools::single(pool)) +} diff --git a/source/crates/app-tenants/src/lib.rs b/source/crates/app-tenants/src/lib.rs new file mode 100644 index 0000000..e00bdf9 --- /dev/null +++ b/source/crates/app-tenants/src/lib.rs @@ -0,0 +1,37 @@ +//! Composition-root library for the **wardnet-tenants** deployable. +//! +//! This is the *only* crate that depends on all three aggregate crates (`tenants` + +//! `subscriptions` + `billing`), so the cross-aggregate glue lives here: the merged +//! [`db`] migrator and the [`reconcile`] safety net. The thin `main.rs` bin composes +//! this lib into a process. The full-wiring integration-test `Harness` lives in +//! `tests/common/mod.rs` (a shared test fixture, per the `tests/` convention) — it is +//! deliberately *not* part of the production library surface. + +pub mod db; + +use wardnet_subscriptions::SubscriptionService; +use wardnet_tenants::service::TenantsService; + +/// Reconcile desired state across the tenant + license aggregates — the safety net for +/// any dropped domain event. This spans two aggregates, so it lives at the composition +/// root (not inside either service — ADR-0010). For every live tenant: open a missing +/// trial (only when the tenant has *no* subscription history, so a reaped trial is +/// never resurrected); and if the tenant still has no current subscription, deprovision +/// its networks. Idempotent. +/// +/// # Errors +/// Propagates a repository / aggregate failure from either side. +pub async fn reconcile( + service: &TenantsService, + subscriptions: &SubscriptionService, +) -> anyhow::Result<()> { + for tenant_id in service.list_live_tenant_ids().await? { + if subscriptions.current(&tenant_id).await?.is_some() { + continue; + } + if !subscriptions.create_trial(&tenant_id).await? { + service.deprovision_networks_for(&tenant_id).await?; + } + } + Ok(()) +} diff --git a/source/crates/tenants/src/main.rs b/source/crates/app-tenants/src/main.rs similarity index 82% rename from source/crates/tenants/src/main.rs rename to source/crates/app-tenants/src/main.rs index 098dade..30a41c9 100644 --- a/source/crates/tenants/src/main.rs +++ b/source/crates/app-tenants/src/main.rs @@ -1,34 +1,48 @@ -use std::sync::Arc; +//! Composition root for the **wardnet-tenants** deployable. +//! +//! Wires the three independent aggregate crates — `tenants` (identity/networks/ +//! daemons/enrollment + web auth), `subscriptions` (the license), `billing` (the +//! payment provider) — into one process (a modular monolith). This is the **only** +//! crate that depends on all three; it instantiates each concrete service and injects +//! the others as `dyn` **port** trait objects, so the aggregates never name each +//! other (ADR-0010). The in-process event bus + direct port calls are the adapters +//! today; a broker + mesh-HTTP adapters drop in here later with no domain-code change. use std::collections::HashMap; +use std::sync::Arc; use wardnet_common::config as common_config; -use wardnet_common::event::{BroadcastEventBus, EventPublisher}; +use wardnet_common::event::{EventBus, InProcessEventBus}; +use wardnet_common::ports::{BillingPort, SubscriptionCommands, SubscriptionReader}; use wardnet_common::{mtls, serve, token}; +use wardnet_billing::{BillingRepository, BillingService, PgBillingRepository, StripeClient}; +use wardnet_subscriptions::{ + PgSubscriptionRepository, SubscriptionRepository, SubscriptionService, TrialPolicy, + reactor as subscription_reactor, +}; use wardnet_tenants::{ api, config::Config, - db, email::{EmailSender, NoopEmailSender, ResendEmailSender}, identities::{ IdentitiesService, provider::{ExternalIdentityProvider, GitHubProvider, OidcProvider}, reactor as identities_reactor, }, - mesh, + mesh, reactor, repository::{ DaemonRepository, EnrollmentRepository, NetworkRepository, PgDaemonRepository, - PgEnrollmentRepository, PgNetworkRepository, PgSessionRepository, PgSubscriptionRepository, - PgTenantIdentityRepository, PgTenantRepository, SessionRepository, SubscriptionRepository, + PgEnrollmentRepository, PgNetworkRepository, PgSessionRepository, + PgTenantIdentityRepository, PgTenantRepository, SessionRepository, TenantIdentityRepository, TenantRepository, }, service::TenantsService, state::AppState, - stripe::StripeClient, - subscription::{SubscriptionService, TrialPolicy, reactor}, }; +use wardnet_tenants_app::db; + /// Broadcast channel depth for domain events. Generous so a momentarily-busy reactor /// never lags (a dropped event is still recovered by the reconcile loop). const EVENT_BUS_CAPACITY: usize = 1024; @@ -60,6 +74,7 @@ async fn main() -> anyhow::Result<()> { let networks_repo = Arc::new(PgNetworkRepository::new_pools(pools.clone())); let daemons_repo = Arc::new(PgDaemonRepository::new_pools(pools.clone())); let subscriptions_repo = Arc::new(PgSubscriptionRepository::new_pools(pools.clone())); + let billing_repo = Arc::new(PgBillingRepository::new_pools(pools.clone())); // Identities aggregate repos (WS-F). let identity_repo = Arc::new(PgTenantIdentityRepository::new_pools(pools.clone())); let session_repo = Arc::new(PgSessionRepository::new_pools(pools.clone())); @@ -73,36 +88,42 @@ async fn main() -> anyhow::Result<()> { drop(signing_key_pem); // The auth layer verifies identity JWTs offline with the matching public key, - // scoped to this service's own audience (ADR-0008): a token whose `aud` omits - // `tenants` is rejected. + // scoped to this service's own audience (ADR-0008). let verifier = token::Verifier::from_pem( common_config::load_jwt_verify_key_pem()?.as_bytes(), "tenants", )?; - // Domain-event bus: services publish, reactors react (one-way, never a direct - // cross-aggregate write call). - let events: Arc = Arc::new(BroadcastEventBus::new(EVENT_BUS_CAPACITY)); + // Domain-event bus (in-process adapter): services publish, reactors react. + let events: Arc = Arc::new(InProcessEventBus::new(EVENT_BUS_CAPACITY)); - // Stripe gateway (hand-rolled reqwest client; the signature secret is the webhook - // credential). Secrets arrive in the env via inforge, like the DSN. - let stripe = Arc::new(StripeClient::new( - &config.stripe_secret_key, - &config.stripe_webhook_secret, - &config.account_base_url, - )); - - // Build the subscription aggregate first (Tenants reads it via a service method). + // The license aggregate. Shared as both its read port (entitlement) and its + // command port (Billing → Subscription, account cancel). let subscriptions = Arc::new(SubscriptionService::new( subscriptions_repo as Arc, Arc::clone(&events), - stripe, TrialPolicy { trial_days: config.trial_days, trial_grace_days: config.trial_grace_days, payment_grace_days: config.payment_grace_days, }, )); + let subscription_reader: Arc = subscriptions.clone(); + let subscription_commands: Arc = subscriptions.clone(); + + // The payment aggregate. Drives the license aggregate only through the ports above. + let stripe = Arc::new(StripeClient::new( + &config.stripe_secret_key, + &config.stripe_webhook_secret, + &config.account_base_url, + )); + let billing: Arc = Arc::new(BillingService::new( + stripe, + billing_repo as Arc, + Arc::clone(&subscription_reader), + Arc::clone(&subscription_commands), + )); + // Transactional email: Resend when configured, else the dev no-op (logs the code). let email: Arc = if let Some(key) = &config.resend_api_key { Arc::new(ResendEmailSender::new(key, &config.email_from)?) @@ -116,7 +137,7 @@ async fn main() -> anyhow::Result<()> { networks_repo as Arc, daemons_repo as Arc, enrollment_repo as Arc, - Arc::clone(&subscriptions), + Arc::clone(&subscription_reader), Arc::clone(&events), email, Arc::clone(&signer), @@ -136,25 +157,27 @@ async fn main() -> anyhow::Result<()> { config.user_jwt_ttl_secs, )); - // Reactors: turn published events into the owning service's method calls. - tokio::spawn(reactor::run_subscription_reactor( + // Reactors: turn published events into the owning service's method calls. Each + // takes an `EventStream` from the bus (no tokio transport type leaks). + tokio::spawn(subscription_reactor::run_subscription_reactor( Arc::clone(&subscriptions), - events.subscribe(), + events.subscribe("subscription").await?, )); tokio::spawn(reactor::run_network_reactor( Arc::clone(&service), - events.subscribe(), + events.subscribe("network").await?, )); - // Identities reactor: TenantDeregistered → purge sessions + login methods. tokio::spawn(identities_reactor::run_identities_reactor( Arc::clone(&identities), - events.subscribe(), + events.subscribe("identities").await?, )); let state = AppState::new( config.clone(), Arc::clone(&service), - Arc::clone(&subscriptions), + Arc::clone(&subscription_reader), + Arc::clone(&subscription_commands), + Arc::clone(&billing), Arc::clone(&identities), verifier, ); @@ -279,10 +302,6 @@ async fn build_identity_providers( async fn sweep_loop(service: Arc, interval: std::time::Duration) { use opentelemetry::{KeyValue, global}; - // Bounded-cardinality domain metrics (no per-tenant labels — plan §5a): - // `deleted` counts reclaimed tenants; `runs` counts passes labelled - // `result=ok|error` so operators can alert on the sweep *failure* rate, not - // just successes. let meter = global::meter(wardnet_common::telemetry::SCOPE); let deleted = meter .u64_counter("tenants.tombstone_sweep.deleted") @@ -322,7 +341,7 @@ async fn sub_reaper_loop( if let Err(e) = subscriptions.expire_overdue().await { tracing::error!(error = %e, "subscription reaper failed"); } - if let Err(e) = service.reconcile().await { + if let Err(e) = wardnet_tenants_app::reconcile(&service, &subscriptions).await { tracing::error!(error = %e, "subscription reconcile failed"); } } diff --git a/source/crates/tenants/tests/api.rs b/source/crates/app-tenants/tests/api.rs similarity index 68% rename from source/crates/tenants/tests/api.rs rename to source/crates/app-tenants/tests/api.rs index fd5614b..40c784b 100644 --- a/source/crates/tenants/tests/api.rs +++ b/source/crates/app-tenants/tests/api.rs @@ -13,12 +13,13 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tower::ServiceExt; +use wardnet_billing::gateway::{StripeEvent, StripeEventKind, SubscriptionData}; +use wardnet_common::contract::SubscriptionStatus; use wardnet_common::token::{ClaimsSpec, PrincipalType, canonical_request_payload}; use wardnet_tenants::api; -use wardnet_tenants::repository::subscription::SubscriptionStatus; use wardnet_tenants::repository::tenant::Tenant; -use wardnet_tenants::stripe::{StripeEvent, StripeEventKind, SubscriptionData}; -use wardnet_tenants::test_helpers::{build_harness, build_state, daemon_keypair, test_signer}; +mod common; +use common::{build_harness, build_state, daemon_keypair, test_signer}; const SEED: u8 = 5; @@ -442,6 +443,167 @@ async fn stripe_webhook_converts_trial_to_paid() { let current = h.store.current_subscription("tw").unwrap(); assert_eq!(current.status, SubscriptionStatus::Active); assert_eq!(current.entitlement.max_networks, 5); + // The Billing-side provider ref is recorded, so subsequent webhooks for this + // subscription resolve the tenant via the mapping (not just checkout metadata). + assert_eq!( + h.store.billing_tenant_for_subscription("sub_w").as_deref(), + Some("tw") + ); + assert_eq!(h.store.billing_customer_id("tw").as_deref(), Some("cus_w")); + // Trial→paid *replaced* the live row (cancel trial + insert paid), never mutated it + // in place: two rows total, exactly one non-canceled (the `uq_subscriptions_live` + // invariant). + assert_eq!(h.store.subscription_count("tw"), 2); +} + +/// POST a (mock-verified) Stripe webhook through the real router; returns the status. +async fn post_webhook(state: &wardnet_tenants::state::AppState) -> StatusCode { + api::router(state.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/billing/stripe/webhook") + .header("Stripe-Signature", "t=1,v1=deadbeef") + .body(Body::from(b"{}".to_vec())) + .unwrap(), + ) + .await + .unwrap() + .status() +} + +#[tokio::test] +async fn stripe_webhook_without_price_metadata_declines_and_records_nothing() { + // A misconfigured plan (price has no max_networks/max_daemons metadata) → the + // webhook declines to grant (safe-closed) AND records no provider ref, so a later + // delete for the same subscription cannot resolve — and cancel — the tenant's trial. + let h = build_harness(SEED); + h.store.seed_tenant(Tenant { + id: "tm".to_string(), + email: "m@b.com".to_string(), + created_at: chrono::Utc::now(), + deregistered_at: None, + }); + h.subscriptions.create_trial("tm").await.unwrap(); + + h.stripe.set_event(StripeEvent { + id: "evt_nometa".to_string(), + kind: StripeEventKind::SubscriptionUpsert(SubscriptionData { + tenant_id: Some("tm".to_string()), + stripe_subscription_id: "sub_m".to_string(), + stripe_customer_id: "cus_m".to_string(), + price_id: Some("price_broken".to_string()), + entitlement: None, // <-- no price metadata + status: SubscriptionStatus::Active, + current_period_end: Some(chrono::Utc::now()), + }), + }); + assert_eq!(post_webhook(&h.state).await, StatusCode::OK); + + // Declined: still on the trial, and NOTHING recorded on the Billing side. + assert_eq!( + h.store.current_subscription("tm").unwrap().status, + SubscriptionStatus::Trialing + ); + assert_eq!(h.store.billing_tenant_for_subscription("sub_m"), None); + + // Now a `customer.subscription.deleted` for that never-granted subscription must be + // a no-op — it must NOT cancel the tenant's live trial. + h.stripe.set_event(StripeEvent { + id: "evt_del".to_string(), + kind: StripeEventKind::SubscriptionDeleted { + stripe_subscription_id: "sub_m".to_string(), + }, + }); + assert_eq!(post_webhook(&h.state).await, StatusCode::OK); + assert_eq!( + h.store.current_subscription("tm").unwrap().status, + SubscriptionStatus::Trialing + ); +} + +#[tokio::test] +async fn stripe_webhook_deleted_cancels_via_recorded_mapping() { + // Convert (records the mapping), then a delete resolves the tenant via + // tenant_for_subscription and cancels the paid license. + let h = build_harness(SEED); + h.store.seed_tenant(Tenant { + id: "td".to_string(), + email: "d@b.com".to_string(), + created_at: chrono::Utc::now(), + deregistered_at: None, + }); + h.subscriptions.create_trial("td").await.unwrap(); + + h.stripe.set_event(StripeEvent { + id: "evt_c".to_string(), + kind: StripeEventKind::SubscriptionUpsert(SubscriptionData { + tenant_id: Some("td".to_string()), + stripe_subscription_id: "sub_d".to_string(), + stripe_customer_id: "cus_d".to_string(), + price_id: Some("price_pro".to_string()), + entitlement: Some(wardnet_common::contract::Entitlement { + max_networks: 3, + max_daemons: 9, + }), + status: SubscriptionStatus::Active, + current_period_end: Some(chrono::Utc::now()), + }), + }); + assert_eq!(post_webhook(&h.state).await, StatusCode::OK); + assert_eq!( + h.store.current_subscription("td").unwrap().status, + SubscriptionStatus::Active + ); + + // The delete carries no checkout metadata — resolution is purely via the mapping. + h.stripe.set_event(StripeEvent { + id: "evt_d".to_string(), + kind: StripeEventKind::SubscriptionDeleted { + stripe_subscription_id: "sub_d".to_string(), + }, + }); + assert_eq!(post_webhook(&h.state).await, StatusCode::OK); + assert!(h.store.current_subscription("td").is_none()); +} + +#[tokio::test] +async fn stripe_webhook_is_idempotent_on_redelivery() { + // The same event id delivered twice applies once (the ledger dedupes the second). + let h = build_harness(SEED); + h.store.seed_tenant(Tenant { + id: "ti".to_string(), + email: "i@b.com".to_string(), + created_at: chrono::Utc::now(), + deregistered_at: None, + }); + h.subscriptions.create_trial("ti").await.unwrap(); + + h.stripe.set_event(StripeEvent { + id: "evt_dup".to_string(), + kind: StripeEventKind::SubscriptionUpsert(SubscriptionData { + tenant_id: Some("ti".to_string()), + stripe_subscription_id: "sub_i".to_string(), + stripe_customer_id: "cus_i".to_string(), + price_id: Some("price_pro".to_string()), + entitlement: Some(wardnet_common::contract::Entitlement { + max_networks: 2, + max_daemons: 4, + }), + status: SubscriptionStatus::Active, + current_period_end: Some(chrono::Utc::now()), + }), + }); + assert_eq!(post_webhook(&h.state).await, StatusCode::OK); + assert_eq!(post_webhook(&h.state).await, StatusCode::OK); // redelivery, same id + + // Applied exactly once: the conversion produced one trial + one paid row, not two + // paid rows from a double-apply. + assert_eq!(h.store.subscription_count("ti"), 2); + assert_eq!( + h.store.current_subscription("ti").unwrap().status, + SubscriptionStatus::Active + ); } #[tokio::test] diff --git a/source/crates/tenants/tests/auth.rs b/source/crates/app-tenants/tests/auth.rs similarity index 99% rename from source/crates/tenants/tests/auth.rs rename to source/crates/app-tenants/tests/auth.rs index 07bb772..f8217d5 100644 --- a/source/crates/tenants/tests/auth.rs +++ b/source/crates/app-tenants/tests/auth.rs @@ -17,7 +17,8 @@ use tower::ServiceExt; use wardnet_tenants::api; use wardnet_tenants::identities::provider::{ExternalIdentityProvider, VerifiedIdentity}; use wardnet_tenants::repository::tenant::Tenant; -use wardnet_tenants::test_helpers::{Harness, MockIdentityProvider, build_harness_with_providers}; +mod common; +use common::{Harness, MockIdentityProvider, build_harness_with_providers}; const SEED: u8 = 5; diff --git a/source/crates/tenants/src/test_helpers.rs b/source/crates/app-tenants/tests/common/mod.rs similarity index 78% rename from source/crates/tenants/src/test_helpers.rs rename to source/crates/app-tenants/tests/common/mod.rs index 5a5fed5..2abb785 100644 --- a/source/crates/tenants/src/test_helpers.rs +++ b/source/crates/app-tenants/tests/common/mod.rs @@ -1,7 +1,17 @@ -//! Shared test fixtures: a deterministic JWT keypair, in-memory mock repositories -//! over a single shared store (so cross-aggregate invariants hold), and an -//! [`AppState`] builder. Doc-hidden; used by both unit tests and the integration -//! tests in `tests/`. +//! Shared integration-test fixtures (mock store + Harness wiring the three +//! aggregates). Per-binary, each test uses a subset, so silence dead-code here. + +#![allow(dead_code)] + +//! Shared test fixtures for the composition crate: a deterministic JWT keypair, an +//! in-memory mock store that backs **every** aggregate's repositories (so +//! cross-aggregate invariants hold), and a fully-wired [`Harness`] (the three +//! aggregate services + their `common` ports + the [`AppState`]). +//! +//! This module is **not** `#[cfg(test)]`: the integration tests in `tests/` are +//! separate crates and can only see `pub` items (mirroring the old tenants crate's +//! doc-hidden `test_helpers`). It is the only place that names all three aggregate +//! crates' concrete types — exactly the job of the composition root. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; @@ -10,32 +20,35 @@ use async_trait::async_trait; use base64::Engine as _; use chrono::{DateTime, Duration, Utc}; use ed25519_dalek::SigningKey; -use tokio::sync::broadcast; -use wardnet_common::event::{BroadcastEventBus, DomainEvent, EventPublisher}; +use wardnet_common::contract::{Entitlement, SubscriptionStatus}; +use wardnet_common::event::{DomainEvent, EventBus, EventStream, InProcessEventBus}; +use wardnet_common::ports::{BillingPort, SubscriptionCommands, SubscriptionReader}; use wardnet_common::token::{Signer, Verifier}; -use crate::config::Config; -use crate::email::EmailSender; -use crate::identities::IdentitiesService; -use crate::identities::provider::ExternalIdentityProvider; -use crate::repository::daemon::{Daemon, DaemonRepository}; -use crate::repository::enrollment::{EnrollOutcome, EnrollmentRepository}; -use crate::repository::identity::{ +use wardnet_billing::BillingService; +use wardnet_billing::gateway::{CheckoutSession, StripeEvent, StripeGateway}; +use wardnet_billing::repository::BillingRepository; +use wardnet_subscriptions::{ + Subscription, SubscriptionRepository, SubscriptionService, TrialPolicy, +}; + +use wardnet_tenants::config::Config; +use wardnet_tenants::email::EmailSender; +use wardnet_tenants::identities::IdentitiesService; +use wardnet_tenants::identities::provider::ExternalIdentityProvider; +use wardnet_tenants::repository::daemon::{Daemon, DaemonRepository}; +use wardnet_tenants::repository::enrollment::{EnrollOutcome, EnrollmentRepository}; +use wardnet_tenants::repository::identity::{ InsertIdentityOutcome, TenantIdentity, TenantIdentityRepository, }; -use crate::repository::network::{ +use wardnet_tenants::repository::network::{ Network, NetworkRepository, ProvisioningState, RegisterNetworkOutcome, }; -use crate::repository::session::{Session, SessionRepository}; -use crate::repository::subscription::{ - Entitlement, Subscription, SubscriptionRepository, SubscriptionStatus, -}; -use crate::repository::tenant::{CreateTenantOutcome, Tenant, TenantRepository}; -use crate::service::TenantsService; -use crate::state::AppState; -use crate::stripe::{CheckoutSession, StripeEvent, StripeGateway}; -use crate::subscription::{SubscriptionService, TrialPolicy}; +use wardnet_tenants::repository::session::{Session, SessionRepository}; +use wardnet_tenants::repository::tenant::{CreateTenantOutcome, Tenant, TenantRepository}; +use wardnet_tenants::service::TenantsService; +use wardnet_tenants::state::AppState; // ── Key material ──────────────────────────────────────────────────────────────── @@ -83,6 +96,15 @@ struct PendingRow { expires_at: DateTime, } +/// A Billing-owned provider-reference row (the `stripe_*` ids that moved out of the +/// subscription into Billing's `billing_customers` table), keyed by tenant id. +#[derive(Clone, Default)] +struct BillingCustomer { + customer: Option, + subscription: Option, + price: Option, +} + #[derive(Default)] struct Data { tenants: HashMap, @@ -92,7 +114,10 @@ struct Data { codes: HashMap, pending: HashMap, code_log: Vec<(String, DateTime)>, - processed_events: HashSet, + /// Billing provider-reference rows (one per tenant). + billing_customers: HashMap, + /// Billing webhook idempotency ledger. + processed_stripe_events: HashSet, /// Login methods keyed on `(provider, subject)` (mirrors the PK). identities: HashMap<(String, String), TenantIdentity>, /// Sessions keyed on `token_hash`. @@ -100,7 +125,8 @@ struct Data { } /// A shared mock backing store. All mock repositories built from one -/// [`MockStore`] read/write the same data, so saga invariants hold across them. +/// [`MockStore`] read/write the same data, so saga invariants hold across them — +/// including across the now-separate subscription + billing aggregates. #[derive(Clone)] pub struct MockStore(Arc>); @@ -152,6 +178,31 @@ impl MockStore { .count() } + /// The tenant a recorded provider subscription id maps to (the `billing_customers` + /// webhook→tenant lookup), if any. `None` means no provider ref was recorded for + /// that subscription — e.g. a declined (no-metadata) subscription must stay `None`. + #[must_use] + pub fn billing_tenant_for_subscription(&self, subscription_id: &str) -> Option { + self.0 + .lock() + .unwrap() + .billing_customers + .iter() + .find(|(_, c)| c.subscription.as_deref() == Some(subscription_id)) + .map(|(tenant_id, _)| tenant_id.clone()) + } + + /// The provider customer id recorded for a tenant, if any. + #[must_use] + pub fn billing_customer_id(&self, tenant_id: &str) -> Option { + self.0 + .lock() + .unwrap() + .billing_customers + .get(tenant_id) + .and_then(|c| c.customer.clone()) + } + /// Number of networks currently stored. #[must_use] pub fn network_count(&self) -> usize { @@ -736,47 +787,6 @@ impl SubscriptionRepository for MockStore { .cloned()) } - async fn find_by_stripe_subscription_id( - &self, - stripe_subscription_id: &str, - ) -> anyhow::Result> { - Ok(self - .0 - .lock() - .unwrap() - .subscriptions - .values() - .find(|s| s.stripe_subscription_id.as_deref() == Some(stripe_subscription_id)) - .cloned()) - } - - async fn latest_customer_id(&self, tenant_id: &str) -> anyhow::Result> { - let d = self.0.lock().unwrap(); - Ok(d.subscriptions - .values() - .filter(|s| s.tenant_id == tenant_id && s.stripe_customer_id.is_some()) - .max_by_key(|s| s.created_at) - .and_then(|s| s.stripe_customer_id.clone())) - } - - async fn stamp_customer_id( - &self, - tenant_id: &str, - stripe_customer_id: &str, - ) -> anyhow::Result { - let mut d = self.0.lock().unwrap(); - if let Some(s) = d - .subscriptions - .values_mut() - .find(|s| s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled) - { - s.stripe_customer_id = Some(stripe_customer_id.to_string()); - Ok(true) - } else { - Ok(false) - } - } - async fn convert_trial_to_paid( &self, tenant_id: &str, @@ -792,9 +802,9 @@ impl SubscriptionRepository for MockStore { Ok(()) } - async fn update_from_stripe( + async fn update_current( &self, - stripe_subscription_id: &str, + tenant_id: &str, status: SubscriptionStatus, entitlement: Entitlement, current_period_end: Option>, @@ -803,7 +813,7 @@ impl SubscriptionRepository for MockStore { if let Some(s) = d .subscriptions .values_mut() - .find(|s| s.stripe_subscription_id.as_deref() == Some(stripe_subscription_id)) + .find(|s| s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled) { s.status = status; s.entitlement = entitlement; @@ -814,6 +824,20 @@ impl SubscriptionRepository for MockStore { } } + async fn mark_past_due_current(&self, tenant_id: &str) -> anyhow::Result { + let mut d = self.0.lock().unwrap(); + if let Some(s) = d + .subscriptions + .values_mut() + .find(|s| s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled) + { + s.status = SubscriptionStatus::PastDue; + Ok(true) + } else { + Ok(false) + } + } + async fn cancel_current(&self, tenant_id: &str) -> anyhow::Result { let mut d = self.0.lock().unwrap(); if let Some(s) = d @@ -851,38 +875,101 @@ impl SubscriptionRepository for MockStore { .map(|s| s.tenant_id.clone()) .collect()) } +} + +#[async_trait] +impl BillingRepository for MockStore { + async fn upsert_customer( + &self, + tenant_id: &str, + stripe_customer_id: &str, + ) -> anyhow::Result<()> { + let mut d = self.0.lock().unwrap(); + d.billing_customers + .entry(tenant_id.to_string()) + .or_default() + .customer = Some(stripe_customer_id.to_string()); + Ok(()) + } + + async fn upsert_subscription( + &self, + tenant_id: &str, + stripe_customer_id: &str, + stripe_subscription_id: &str, + price_id: Option<&str>, + ) -> anyhow::Result<()> { + let mut d = self.0.lock().unwrap(); + let row = d + .billing_customers + .entry(tenant_id.to_string()) + .or_default(); + row.customer = Some(stripe_customer_id.to_string()); + row.subscription = Some(stripe_subscription_id.to_string()); + row.price = price_id.map(str::to_string); + Ok(()) + } + + async fn customer_id(&self, tenant_id: &str) -> anyhow::Result> { + Ok(self + .0 + .lock() + .unwrap() + .billing_customers + .get(tenant_id) + .and_then(|r| r.customer.clone())) + } + + async fn tenant_for_subscription( + &self, + stripe_subscription_id: &str, + ) -> anyhow::Result> { + Ok(self + .0 + .lock() + .unwrap() + .billing_customers + .iter() + .find(|(_, r)| r.subscription.as_deref() == Some(stripe_subscription_id)) + .map(|(tenant_id, _)| tenant_id.clone())) + } async fn is_event_processed(&self, event_id: &str) -> anyhow::Result { - Ok(self.0.lock().unwrap().processed_events.contains(event_id)) + Ok(self + .0 + .lock() + .unwrap() + .processed_stripe_events + .contains(event_id)) } async fn record_event(&self, event_id: &str, _now: DateTime) -> anyhow::Result<()> { self.0 .lock() .unwrap() - .processed_events + .processed_stripe_events .insert(event_id.to_string()); Ok(()) } } -// ── Recording event publisher ─────────────────────────────────────────────────── +// ── Recording event bus ───────────────────────────────────────────────────────── -/// An [`EventPublisher`] that records every published event (for `published()` -/// assertions) and queues them for the deterministic [`Harness::pump`] — while also -/// forwarding to a real broadcast bus so a test can drive the async reactors if it +/// An [`EventBus`] that records every published event (for `published()` assertions) +/// and queues them for the deterministic [`Harness::pump`] — while also forwarding to +/// a real [`InProcessEventBus`] so a test can drive the spawned async reactors if it /// wants to. -pub struct RecordingEventPublisher { - bus: BroadcastEventBus, +pub struct RecordingEventBus { + inner: InProcessEventBus, log: Mutex>, pending: Mutex>, } -impl RecordingEventPublisher { +impl RecordingEventBus { #[must_use] pub fn new() -> Self { Self { - bus: BroadcastEventBus::new(256), + inner: InProcessEventBus::new(256), log: Mutex::new(Vec::new()), pending: Mutex::new(VecDeque::new()), } @@ -900,33 +987,34 @@ impl RecordingEventPublisher { } } -impl Default for RecordingEventPublisher { +impl Default for RecordingEventBus { fn default() -> Self { Self::new() } } -impl EventPublisher for RecordingEventPublisher { - fn publish(&self, event: DomainEvent) { +#[async_trait] +impl EventBus for RecordingEventBus { + async fn publish(&self, event: &DomainEvent) -> anyhow::Result<()> { self.log.lock().unwrap().push(event.clone()); self.pending.lock().unwrap().push_back(event.clone()); - self.bus.publish(event); + self.inner.publish(event).await } - fn subscribe(&self) -> broadcast::Receiver { - self.bus.subscribe() + async fn subscribe(&self, group: &str) -> anyhow::Result> { + self.inner.subscribe(group).await } } // ── Mock Stripe gateway ───────────────────────────────────────────────────────── -/// A recording [`StripeGateway`] fake: checkout/portal return canned URLs and record -/// their calls; `construct_event` returns a pre-set [`StripeEvent`] (set by the -/// webhook-endpoint test). No real Stripe — the signature crypto is exercised -/// directly in `stripe::tests`, not re-tested here. /// A recorded `create_checkout_session` call: `(customer_id, email, price_id, tenant_id)`. pub type CheckoutCall = (Option, String, String, String); +/// A recording [`StripeGateway`] fake: checkout/portal return canned URLs and record +/// their calls; `construct_event` returns a pre-set [`StripeEvent`] (set by the +/// webhook-endpoint test). No real Stripe — the signature crypto is exercised directly +/// in `wardnet_billing::gateway::tests`, not re-tested here. pub struct MockStripeGateway { checkout_url: String, portal_url: String, @@ -997,8 +1085,8 @@ impl StripeGateway for MockStripeGateway { // ── Recording email sender ────────────────────────────────────────────────────── /// A recording [`EmailSender`] fake: records every `(to, code)` and reports -/// `delivers() == false` (so the API still echoes the code, keeping mock-backed -/// HTTP tests exercisable). Use [`sent`](Self::sent) to assert an email was sent. +/// `delivers() == false` (so the API still echoes the code, keeping mock-backed HTTP +/// tests exercisable). Use [`sent`](Self::sent) to assert an email was sent. pub struct RecordingEmailSender { sent: Mutex>, } @@ -1086,7 +1174,7 @@ pub fn test_signer(seed: u8) -> Signer { pub struct Harness { pub state: AppState, pub store: MockStore, - pub events: Arc, + pub events: Arc, pub stripe: Arc, pub email: Arc, pub subscriptions: Arc, @@ -1097,18 +1185,27 @@ pub struct Harness { impl Harness { /// Apply every queued domain event through the reactor handlers, to a fixpoint. pub async fn pump(&self) { - pump_events(&self.events, &self.subscriptions, &self.tenants).await; + pump_events( + &self.events, + &self.subscriptions, + &self.tenants, + &self.identities, + ) + .await; } } -/// Apply every queued domain event through the reactor handlers, to a fixpoint (a -/// cancel publishes a further `SubscriptionDeactivated`, etc.). The synchronous -/// stand-in for the spawned reactors, so tests stay deterministic. Shared by the -/// mock-backed [`Harness`] and the Postgres integration harness. +/// Apply every queued domain event through the split reactors, to a fixpoint (a cancel +/// publishes a further `SubscriptionDeactivated`, etc.). The synchronous stand-in for +/// the spawned reactors, so tests stay deterministic. Spans all three aggregates' +/// reactors — subscription (open trial / cancel), network (deprovision cascade), and +/// identities (purge on deregister) — shared by the mock-backed [`Harness`] and the +/// Postgres integration harness. pub async fn pump_events( - events: &RecordingEventPublisher, + events: &RecordingEventBus, subscriptions: &SubscriptionService, tenants: &TenantsService, + identities: &IdentitiesService, ) { loop { let batch = events.take_pending(); @@ -1116,8 +1213,9 @@ pub async fn pump_events( break; } for event in &batch { - crate::subscription::reactor::apply_to_subscription(subscriptions, event).await; - crate::subscription::reactor::apply_to_network(tenants, event).await; + wardnet_subscriptions::reactor::apply_to_subscription(subscriptions, event).await; + wardnet_tenants::reactor::apply_to_network(tenants, event).await; + wardnet_tenants::identities::reactor::apply_to_identities(identities, event).await; } } } @@ -1139,36 +1237,47 @@ pub fn build_harness_with_providers( providers: HashMap>, ) -> Harness { let store = MockStore::new(); - let events: Arc = Arc::new(RecordingEventPublisher::new()); + let events: Arc = Arc::new(RecordingEventBus::new()); let stripe: Arc = Arc::new(MockStripeGateway::new()); let email: Arc = Arc::new(RecordingEmailSender::new()); let signer = Arc::new(test_signer(seed)); let verifier = Verifier::from_pem(jwt_keypair_pem(seed).1.as_bytes(), "tenants").unwrap(); + // The license aggregate, shared as both its read + command port. let subscriptions = Arc::new(SubscriptionService::new( - Arc::new(store.clone()), - events.clone(), - stripe.clone(), + Arc::new(store.clone()) as Arc, + Arc::clone(&events) as Arc, TrialPolicy { trial_days: 60, trial_grace_days: 15, payment_grace_days: 15, }, )); + let subscription_reader: Arc = subscriptions.clone(); + let subscription_commands: Arc = subscriptions.clone(); + + // The payment aggregate, driving the license aggregate only through the ports. + let billing: Arc = Arc::new(BillingService::new( + Arc::clone(&stripe) as Arc, + Arc::new(store.clone()) as Arc, + Arc::clone(&subscription_reader), + Arc::clone(&subscription_commands), + )); + let tenants = Arc::new(TenantsService::new( - Arc::new(store.clone()), - Arc::new(store.clone()), - Arc::new(store.clone()), - Arc::new(store.clone()), - subscriptions.clone(), - events.clone(), - email.clone(), + Arc::new(store.clone()) as Arc, + Arc::new(store.clone()) as Arc, + Arc::new(store.clone()) as Arc, + Arc::new(store.clone()) as Arc, + Arc::clone(&subscription_reader), + Arc::clone(&events) as Arc, + Arc::clone(&email) as Arc, Arc::clone(&signer), ["use1".to_string(), "eu1".to_string()], )); let identities = Arc::new(IdentitiesService::new( - Arc::new(store.clone()), - Arc::new(store.clone()), + Arc::new(store.clone()) as Arc, + Arc::new(store.clone()) as Arc, tenants.clone(), providers, signer, @@ -1177,7 +1286,9 @@ pub fn build_harness_with_providers( let state = AppState::new( test_config(), tenants.clone(), - subscriptions.clone(), + Arc::clone(&subscription_reader), + Arc::clone(&subscription_commands), + Arc::clone(&billing), identities.clone(), verifier, ); @@ -1193,24 +1304,24 @@ pub fn build_harness_with_providers( } } -/// A mock [`ExternalIdentityProvider`] returning a preset [`VerifiedIdentity`] from +/// A mock [`ExternalIdentityProvider`] returning a preset `VerifiedIdentity` from /// `exchange` (and a fixed authorize URL) — drives the OIDC-callback tests without a /// real provider. pub struct MockIdentityProvider { - identity: crate::identities::provider::VerifiedIdentity, + identity: wardnet_tenants::identities::provider::VerifiedIdentity, } impl MockIdentityProvider { #[must_use] - pub fn new(identity: crate::identities::provider::VerifiedIdentity) -> Self { + pub fn new(identity: wardnet_tenants::identities::provider::VerifiedIdentity) -> Self { Self { identity } } } #[async_trait] impl ExternalIdentityProvider for MockIdentityProvider { - fn authorize_url(&self) -> crate::identities::provider::AuthorizeRequest { - crate::identities::provider::AuthorizeRequest { + fn authorize_url(&self) -> wardnet_tenants::identities::provider::AuthorizeRequest { + wardnet_tenants::identities::provider::AuthorizeRequest { url: "https://provider.test/authorize".to_string(), csrf_state: "test-state".to_string(), verifier: String::new(), @@ -1221,7 +1332,7 @@ impl ExternalIdentityProvider for MockIdentityProvider { &self, _code: &str, _verifier: &str, - ) -> anyhow::Result { + ) -> anyhow::Result { Ok(self.identity.clone()) } } diff --git a/source/crates/app-tenants/tests/identities.rs b/source/crates/app-tenants/tests/identities.rs new file mode 100644 index 0000000..b193a34 --- /dev/null +++ b/source/crates/app-tenants/tests/identities.rs @@ -0,0 +1,315 @@ +//! Integration tests for the Identities aggregate: the two-gate verified-email +//! resolver and the password / session flows over the fully-wired [`Harness`]. The +//! argon2 primitive round-trip (which needs the private `hash_password`/ +//! `verify_password`) stays a unit test in the `tenants` crate. + +use chrono::Utc; + +use wardnet_common::token::Verifier; + +use wardnet_tenants::error::IdentitiesError; +use wardnet_tenants::identities::provider::VerifiedIdentity; +use wardnet_tenants::repository::tenant::Tenant; +mod common; +use common::{Harness, build_harness, jwt_keypair_pem}; + +const SEED: u8 = 5; + +/// A verifier over the harness's keypair, scoped to `tenants` (the USER JWT audience). +fn verifier() -> Verifier { + Verifier::from_pem(jwt_keypair_pem(SEED).1.as_bytes(), "tenants").unwrap() +} + +fn verified(provider: &str, subject: &str, email: &str, email_verified: bool) -> VerifiedIdentity { + VerifiedIdentity { + provider: provider.to_string(), + subject: subject.to_string(), + email: email.to_string(), + email_verified, + } +} + +// ── resolve_identity: the two gates ──────────────────────────────────────────────── + +#[tokio::test] +async fn resolve_verified_no_match_creates_tenant() { + let h = build_harness(SEED); + let (tenant_id, existed) = h + .identities + .resolve_identity(&verified("google", "g-1", "New@Example.com", true), None) + .await + .unwrap(); + assert!(!existed); + // Web-first signup created the tenant (normalized email) + published TenantCreated. + let tenant = h.store.find_tenant(&tenant_id).unwrap(); + assert_eq!(tenant.email, "new@example.com"); +} + +#[tokio::test] +async fn resolve_verified_match_auto_links_existing_tenant() { + let h = build_harness(SEED); + // A daemon-born tenant already exists for this email. + h.store.seed_tenant(Tenant { + id: "tenant-daemon-born".to_string(), + email: "owner@example.com".to_string(), + created_at: Utc::now(), + deregistered_at: None, + }); + let (tenant_id, existed) = h + .identities + .resolve_identity(&verified("google", "g-9", "owner@example.com", true), None) + .await + .unwrap(); + assert!(!existed); + assert_eq!(tenant_id, "tenant-daemon-born"); +} + +#[tokio::test] +async fn resolve_returning_identity_is_existing() { + let h = build_harness(SEED); + let v = verified("google", "g-7", "repeat@example.com", true); + let (first, existed1) = h.identities.resolve_identity(&v, None).await.unwrap(); + assert!(!existed1); + let (second, existed2) = h.identities.resolve_identity(&v, None).await.unwrap(); + assert!(existed2); + assert_eq!(first, second); +} + +#[tokio::test] +async fn resolve_unverified_email_is_rejected() { + let h = build_harness(SEED); + let err = h + .identities + .resolve_identity(&verified("google", "g-2", "spoof@example.com", false), None) + .await + .unwrap_err(); + assert!(matches!(err, IdentitiesError::Unauthorized(_))); + // No tenant was created behind the rejected gate. + assert!( + h.tenants + .find_tenant_by_email("spoof@example.com") + .await + .unwrap() + .is_none() + ); +} + +// ── Password flows ───────────────────────────────────────────────────────────────── + +/// Issue a real signup code through the tenant aggregate (the gate-1 primitive). +async fn signup_code(h: &Harness, email: &str) -> String { + h.tenants + .issue_signup_code(email, "203.0.113.7") + .await + .unwrap() +} + +#[tokio::test] +async fn password_signup_then_login() { + let h = build_harness(SEED); + let code = signup_code(&h, "alice@example.com").await; + let session = h + .identities + .password_signup("alice@example.com", &code, "hunter2hunter2") + .await + .unwrap(); + assert!(!session.is_empty()); + + // The session exchanges to a USER JWT the verifier accepts (aud = [tenants]). + let jwt = h.identities.exchange_session(&session).await.unwrap(); + let claims = verifier().verify(&jwt).unwrap(); + assert_eq!(claims.aud, vec!["tenants".to_string()]); + assert_eq!(claims.tid, claims.sub); // User == Tenant 1:1 + + // And the password logs in. + let login = h + .identities + .password_login("alice@example.com", "hunter2hunter2", "203.0.113.1") + .await + .unwrap(); + assert!(!login.is_empty()); +} + +#[tokio::test] +async fn password_signup_rejects_bad_code() { + let h = build_harness(SEED); + let err = h + .identities + .password_signup("bob@example.com", "deadbeef", "longenough1") + .await + .unwrap_err(); + assert!(matches!(err, IdentitiesError::BadCode(_))); +} + +#[tokio::test] +async fn password_signup_rejects_weak_password() { + let h = build_harness(SEED); + let code = signup_code(&h, "weak@example.com").await; + let err = h + .identities + .password_signup("weak@example.com", &code, "short") + .await + .unwrap_err(); + assert!(matches!(err, IdentitiesError::BadRequest(_))); +} + +#[tokio::test] +async fn second_password_signup_for_same_email_conflicts() { + let h = build_harness(SEED); + let code1 = signup_code(&h, "dup@example.com").await; + h.identities + .password_signup("dup@example.com", &code1, "longenough1") + .await + .unwrap(); + let code2 = signup_code(&h, "dup@example.com").await; + let err = h + .identities + .password_signup("dup@example.com", &code2, "longenough2") + .await + .unwrap_err(); + assert!(matches!(err, IdentitiesError::Conflict(_))); +} + +#[tokio::test] +async fn login_rejects_unknown_and_wrong_password() { + let h = build_harness(SEED); + let code = signup_code(&h, "carol@example.com").await; + h.identities + .password_signup("carol@example.com", &code, "rightpassword") + .await + .unwrap(); + + assert!(matches!( + h.identities + .password_login("carol@example.com", "wrongpassword", "203.0.113.2") + .await + .unwrap_err(), + IdentitiesError::Unauthorized(_) + )); + assert!(matches!( + h.identities + .password_login("nobody@example.com", "whatever12", "203.0.113.3") + .await + .unwrap_err(), + IdentitiesError::Unauthorized(_) + )); +} + +// ── Session lifecycle ────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn logout_invalidates_the_exchange() { + let h = build_harness(SEED); + let code = signup_code(&h, "dave@example.com").await; + let session = h + .identities + .password_signup("dave@example.com", &code, "longenough1") + .await + .unwrap(); + + assert!(h.identities.exchange_session(&session).await.is_ok()); + h.identities.logout(&session).await.unwrap(); + assert!(matches!( + h.identities.exchange_session(&session).await.unwrap_err(), + IdentitiesError::Unauthorized(_) + )); +} + +#[tokio::test] +async fn purge_for_deletes_sessions_and_identities() { + let h = build_harness(SEED); + let code = signup_code(&h, "erin@example.com").await; + let session = h + .identities + .password_signup("erin@example.com", &code, "longenough1") + .await + .unwrap(); + let tenant = h + .tenants + .find_tenant_by_email("erin@example.com") + .await + .unwrap() + .unwrap(); + + h.identities.purge_for(&tenant.id).await.unwrap(); + // Session gone (exchange fails) and the password identity gone (login fails). + assert!(h.identities.exchange_session(&session).await.is_err()); + assert!( + h.identities + .password_login("erin@example.com", "longenough1", "203.0.113.4") + .await + .is_err() + ); +} + +#[tokio::test] +async fn deregistered_tenant_cannot_exchange_or_log_in() { + // Even if the identities reactor has NOT yet purged the session/identity (best-effort + // bus), a tombstoned tenant must not mint a USER JWT or open a new session. + let h = build_harness(SEED); + let code = signup_code(&h, "frank@example.com").await; + let session = h + .identities + .password_signup("frank@example.com", &code, "longenough1") + .await + .unwrap(); + let tenant = h + .tenants + .find_tenant_by_email("frank@example.com") + .await + .unwrap() + .unwrap(); + + // Tombstone the tenant WITHOUT running the identities reactor (rows still present). + assert!(h.tenants.deregister_tenant(&tenant.id).await.unwrap()); + + // The silent exchange refuses to mint for a tombstoned tenant (session-query guard). + assert!(matches!( + h.identities.exchange_session(&session).await.unwrap_err(), + IdentitiesError::Unauthorized(_) + )); + // And a fresh login is refused at session creation (create_session liveness check). + assert!(matches!( + h.identities + .password_login("frank@example.com", "longenough1", "203.0.113.9") + .await + .unwrap_err(), + IdentitiesError::Unauthorized(_) + )); +} + +#[tokio::test] +async fn password_login_is_rate_limited_per_ip() { + let h = build_harness(SEED); + let code = signup_code(&h, "grace@example.com").await; + h.identities + .password_signup("grace@example.com", &code, "longenough1") + .await + .unwrap(); + + // 10 wrong attempts from one IP are each Unauthorized; the 11th is RateLimited. + let ip = "198.51.100.7"; + for _ in 0..10 { + assert!(matches!( + h.identities + .password_login("grace@example.com", "wrongpassword", ip) + .await + .unwrap_err(), + IdentitiesError::Unauthorized(_) + )); + } + assert!(matches!( + h.identities + .password_login("grace@example.com", "longenough1", ip) + .await + .unwrap_err(), + IdentitiesError::RateLimited(_) + )); + // A different IP is unaffected (correct credentials still succeed). + assert!( + h.identities + .password_login("grace@example.com", "longenough1", "198.51.100.8") + .await + .is_ok() + ); +} diff --git a/source/crates/tenants/tests/networks_mesh.rs b/source/crates/app-tenants/tests/networks_mesh.rs similarity index 97% rename from source/crates/tenants/tests/networks_mesh.rs rename to source/crates/app-tenants/tests/networks_mesh.rs index 2417ff7..ca61ddb 100644 --- a/source/crates/tenants/tests/networks_mesh.rs +++ b/source/crates/app-tenants/tests/networks_mesh.rs @@ -18,13 +18,15 @@ use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; use wardnet_common::auth::ServiceIdentity; +use wardnet_common::contract::{Entitlement, SubscriptionStatus}; use wardnet_common::mtls::ExpectedPeer; use wardnet_common::{mtls, serve}; +use wardnet_subscriptions::Subscription; use wardnet_tenants::api::reconcile; -use wardnet_tenants::repository::subscription::{Entitlement, Subscription, SubscriptionStatus}; use wardnet_tenants::repository::tenant::Tenant; use wardnet_tenants::state::AppState; -use wardnet_tenants::test_helpers::{build_state, daemon_keypair}; +mod common; +use common::{build_state, daemon_keypair}; const SEED: u8 = 5; const REGION: &str = "use1"; @@ -101,9 +103,6 @@ async fn state_with_network() -> (AppState, String) { max_networks: 5, max_daemons: 5, }, - stripe_customer_id: None, - stripe_subscription_id: None, - price_id: None, trial_expires_at: None, current_period_end: None, created_at: now, diff --git a/source/crates/tenants/tests/pg.rs b/source/crates/app-tenants/tests/pg.rs similarity index 77% rename from source/crates/tenants/tests/pg.rs rename to source/crates/app-tenants/tests/pg.rs index f4ba4ce..f7884a0 100644 --- a/source/crates/tenants/tests/pg.rs +++ b/source/crates/app-tenants/tests/pg.rs @@ -1,28 +1,39 @@ //! Postgres-gated tests for the real SQL repositories (the enroll + register-network -//! transactions and the reconcile cursor the in-memory mocks can't validate). +//! transactions and the reconcile cursor the in-memory mocks can't validate), wired +//! through the composition root's **merged** migrator (tenants + subscriptions + +//! billing) — the only place the live schema exists. //! //! `#[ignore]`d by default — they need a `PostgreSQL` server. Run with: //! `TENANTS_TEST_DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432 \ -//! cargo test -p wardnet-tenants -- --ignored` +//! cargo test -p wardnet-tenants-bin -- --ignored` //! The URL is a bare server URL **without** a `/database` suffix; each test creates -//! a fresh UUID-named database and runs the init migration into it. +//! a fresh UUID-named database and runs the merged migration into it. +use std::collections::HashMap; use std::sync::Arc; use sqlx::PgPool; use uuid::Uuid; -use wardnet_tenants::db::{self, DbPools}; +use wardnet_common::db::DbPools; +use wardnet_common::event::EventBus; +use wardnet_common::ports::SubscriptionReader; + +use wardnet_subscriptions::{ + PgSubscriptionRepository, SubscriptionRepository, SubscriptionService, TrialPolicy, +}; +use wardnet_tenants::email::EmailSender; +use wardnet_tenants::identities::IdentitiesService; use wardnet_tenants::repository::{ DaemonRepository, EnrollmentRepository, NetworkRepository, PgDaemonRepository, - PgEnrollmentRepository, PgNetworkRepository, PgSubscriptionRepository, PgTenantRepository, - ProvisioningState, SubscriptionRepository, TenantRepository, + PgEnrollmentRepository, PgNetworkRepository, PgSessionRepository, PgTenantIdentityRepository, + PgTenantRepository, ProvisioningState, SessionRepository, TenantIdentityRepository, + TenantRepository, }; use wardnet_tenants::service::TenantsService; -use wardnet_tenants::subscription::{SubscriptionService, TrialPolicy}; -use wardnet_tenants::test_helpers::{ - MockStripeGateway, RecordingEventPublisher, daemon_keypair, pump_events, test_signer, -}; +use wardnet_tenants_app::db; +mod common; +use common::{RecordingEventBus, daemon_keypair, pump_events, test_signer}; const SEED: u8 = 5; const REGION: &str = "use1"; @@ -52,13 +63,14 @@ async fn test_pool() -> DbPools { .expect("init test database") } -/// A Postgres-backed harness mirroring the mock one: the tenant + subscription -/// services over real repos, plus the recording publisher so flows can be pumped +/// A Postgres-backed harness mirroring the mock one: the tenant + subscription + +/// identities services over real repos, plus the recording bus so flows can be pumped /// deterministically (the spawned reactors are not running in tests). struct PgHarness { - events: Arc, + events: Arc, subscriptions: Arc, tenants: Arc, + identities: Arc, } impl PgHarness { @@ -67,40 +79,57 @@ impl PgHarness { } async fn pump(&self) { - pump_events(&self.events, &self.subscriptions, &self.tenants).await; + pump_events( + &self.events, + &self.subscriptions, + &self.tenants, + &self.identities, + ) + .await; } } fn harness(pools: DbPools) -> PgHarness { // Built concretely so the harness keeps the recording handle; the `Arc` coerces - // to `Arc` at each service call site. - let events: Arc = Arc::new(RecordingEventPublisher::new()); + // to `Arc` at each service call site. + let events: Arc = Arc::new(RecordingEventBus::new()); let subscriptions = Arc::new(SubscriptionService::new( Arc::new(PgSubscriptionRepository::new_pools(pools.clone())) as Arc, - events.clone(), - Arc::new(MockStripeGateway::new()), + Arc::clone(&events) as Arc, TrialPolicy { trial_days: 60, trial_grace_days: 15, payment_grace_days: 15, }, )); + let subscription_reader: Arc = subscriptions.clone(); + let signer = Arc::new(test_signer(SEED)); let tenants = Arc::new(TenantsService::new( Arc::new(PgTenantRepository::new_pools(pools.clone())) as Arc, Arc::new(PgNetworkRepository::new_pools(pools.clone())) as Arc, Arc::new(PgDaemonRepository::new_pools(pools.clone())) as Arc, - Arc::new(PgEnrollmentRepository::new_pools(pools)) as Arc, - subscriptions.clone(), - events.clone(), - Arc::new(wardnet_tenants::email::NoopEmailSender), - Arc::new(test_signer(SEED)), + Arc::new(PgEnrollmentRepository::new_pools(pools.clone())) as Arc, + Arc::clone(&subscription_reader), + Arc::clone(&events) as Arc, + Arc::new(wardnet_tenants::email::NoopEmailSender) as Arc, + Arc::clone(&signer), ["use1".to_string(), "eu1".to_string()], )); + let identities = Arc::new(IdentitiesService::new( + Arc::new(PgTenantIdentityRepository::new_pools(pools.clone())) + as Arc, + Arc::new(PgSessionRepository::new_pools(pools)) as Arc, + Arc::clone(&tenants), + HashMap::new(), + signer, + 300, + )); PgHarness { events, subscriptions, tenants, + identities, } } diff --git a/source/crates/tenants/tests/resource_reads.rs b/source/crates/app-tenants/tests/resource_reads.rs similarity index 95% rename from source/crates/tenants/tests/resource_reads.rs rename to source/crates/app-tenants/tests/resource_reads.rs index ba1e1ee..d974bb3 100644 --- a/source/crates/tenants/tests/resource_reads.rs +++ b/source/crates/app-tenants/tests/resource_reads.rs @@ -9,11 +9,13 @@ use http_body_util::BodyExt as _; use tower::ServiceExt as _; use wardnet_common::auth::ServiceIdentity; +use wardnet_common::contract::{Entitlement, SubscriptionStatus}; +use wardnet_subscriptions::Subscription; use wardnet_tenants::api::{network, reconcile, tenant}; -use wardnet_tenants::repository::subscription::{Entitlement, Subscription, SubscriptionStatus}; use wardnet_tenants::repository::tenant::Tenant; use wardnet_tenants::state::AppState; -use wardnet_tenants::test_helpers::{build_state, daemon_keypair}; +mod common; +use common::{build_state, daemon_keypair}; const SEED: u8 = 5; const REGION: &str = "use1"; @@ -37,9 +39,6 @@ async fn seeded() -> (AppState, String) { max_networks: 5, max_daemons: 5, }, - stripe_customer_id: None, - stripe_subscription_id: None, - price_id: None, trial_expires_at: None, current_period_end: None, created_at: now, diff --git a/source/crates/tenants/src/service/tests.rs b/source/crates/app-tenants/tests/service.rs similarity index 93% rename from source/crates/tenants/src/service/tests.rs rename to source/crates/app-tenants/tests/service.rs index b0364f9..af9c15a 100644 --- a/source/crates/tenants/src/service/tests.rs +++ b/source/crates/app-tenants/tests/service.rs @@ -1,15 +1,19 @@ -//! Unit tests for [`TenantsService`] over the shared mock store + recording event -//! publisher. Flows that depend on the event-driven trial run [`Harness::pump`] to -//! apply the reactors deterministically. +//! Service-level tests for [`TenantsService`] over the fully-wired [`Harness`] (the +//! tenant + subscription + billing aggregates over one shared mock store + recording +//! event bus). Flows that depend on the event-driven trial run [`Harness::pump`] to +//! apply the split reactors deterministically. Lives in the composition crate because +//! it needs all three aggregates wired together. use chrono::Utc; +use wardnet_common::contract::{Entitlement, SubscriptionStatus}; use wardnet_common::token::{PrincipalType, Verifier}; -use crate::error::TenantsError; -use crate::repository::subscription::{Entitlement, Subscription, SubscriptionStatus}; -use crate::repository::{ProvisioningState, Tenant}; -use crate::test_helpers::{Harness, build_harness, build_state, daemon_keypair, jwt_keypair_pem}; +use wardnet_subscriptions::Subscription; +use wardnet_tenants::error::TenantsError; +use wardnet_tenants::repository::{ProvisioningState, Tenant}; +mod common; +use common::{Harness, build_harness, build_state, daemon_keypair, jwt_keypair_pem}; const SEED: u8 = 5; const REGION: &str = "use1"; @@ -35,9 +39,6 @@ fn seed_tenant_with_entitlement(h: &Harness, id: &str, max_networks: u32, max_da max_networks, max_daemons, }, - stripe_customer_id: None, - stripe_subscription_id: None, - price_id: None, trial_expires_at: None, current_period_end: None, created_at: now, @@ -349,7 +350,11 @@ async fn mint_jwt_denied_after_subscription_canceled() { // Active (trialing) tenant mints fine. assert!(h.state.tenants().mint_jwt(&cnf).await.is_ok()); // After cancel, the daemon's key can no longer mint a token (revocation at refresh). - h.state.subscriptions().cancel(&tenant_id).await.unwrap(); + h.state + .subscription_commands() + .cancel(&tenant_id) + .await + .unwrap(); assert!(matches!( h.state.tenants().mint_jwt(&cnf).await, Err(TenantsError::Forbidden(_)) @@ -406,7 +411,11 @@ async fn reconcile_provisioner_then_reaper_lifecycle() { // Cancel deactivates the subscription; the network reactor cascades the network // to deprovisioning. - h.state.subscriptions().cancel(&tenant_id).await.unwrap(); + h.state + .subscription_commands() + .cancel(&tenant_id) + .await + .unwrap(); h.pump().await; assert_eq!( h.store.network_state(&slug), @@ -583,12 +592,20 @@ async fn reconcile_backfills_a_missing_trial() { deregistered_at: None, }); assert!(h.store.current_subscription("drift").is_none()); - h.state.tenants().reconcile().await.unwrap(); + wardnet_tenants_app::reconcile(h.tenants.as_ref(), h.subscriptions.as_ref()) + .await + .unwrap(); assert!(h.store.current_subscription("drift").is_some()); // Reap it, then reconcile again — no fresh trial (history exists). - h.state.subscriptions().cancel("drift").await.unwrap(); - h.state.tenants().reconcile().await.unwrap(); + h.state + .subscription_commands() + .cancel("drift") + .await + .unwrap(); + wardnet_tenants_app::reconcile(h.tenants.as_ref(), h.subscriptions.as_ref()) + .await + .unwrap(); assert!(h.store.current_subscription("drift").is_none()); } diff --git a/source/crates/app-tunneller/Cargo.toml b/source/crates/app-tunneller/Cargo.toml new file mode 100644 index 0000000..5f24c2f --- /dev/null +++ b/source/crates/app-tunneller/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "wardnet-tunneller-bin" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Wardnet Tunneller deployable — thin composition root over the wardnet_tunneller library" + +# Binary artifact stays `wardnet-tunneller` (unchanged deploy unit); only the package +# that owns it moved out of the library crate (uniform lib/bin layout — ADR-0010). +[[bin]] +name = "wardnet-tunneller" +path = "src/main.rs" + +[dependencies] +wardnet_common = { workspace = true } +wardnet-tunneller = { workspace = true } + +tokio = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } +opentelemetry = { workspace = true } + +[lints] +workspace = true diff --git a/source/crates/tunneller/src/main.rs b/source/crates/app-tunneller/src/main.rs similarity index 100% rename from source/crates/tunneller/src/main.rs rename to source/crates/app-tunneller/src/main.rs diff --git a/source/crates/billing/Cargo.toml b/source/crates/billing/Cargo.toml new file mode 100644 index 0000000..5d64b81 --- /dev/null +++ b/source/crates/billing/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "wardnet-billing" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Wardnet Billing — the payment aggregate (Stripe gateway, webhooks, provider-reference ledger)" + +[lib] +name = "wardnet_billing" +path = "src/lib.rs" + +[dependencies] +wardnet_common = { workspace = true } + +sqlx = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +reqwest = { workspace = true } + +hmac = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +serde_json = { workspace = true } +wiremock = { workspace = true } + +[lints] +workspace = true diff --git a/source/crates/billing/migrations/20260620000000_billing_customers.sql b/source/crates/billing/migrations/20260620000000_billing_customers.sql new file mode 100644 index 0000000..0564eee --- /dev/null +++ b/source/crates/billing/migrations/20260620000000_billing_customers.sql @@ -0,0 +1,38 @@ +-- Billing-owned provider-reference table (ADR-0010): the payment-provider ids that +-- used to live on the `subscriptions` row move here, keyed by (tenant_id, provider). +-- One live row per (tenant, provider) holding the tenant's current refs. +-- +-- Ordering: this migration carries an EARLIER timestamp than the subscriptions +-- `drop_stripe_cols` migration, so the back-fill below copies the columns BEFORE +-- they are dropped from `subscriptions`. The merged migrator runs them in timestamp +-- order against the single shared DB. +CREATE TABLE IF NOT EXISTS billing_customers ( + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + provider VARCHAR(32) NOT NULL DEFAULT 'stripe', + stripe_customer_id VARCHAR(255), + stripe_subscription_id VARCHAR(255), + price_id VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (tenant_id, provider) +); + +-- Webhook → tenant lookup (and a guard for one subscription id per provider). +CREATE INDEX IF NOT EXISTS idx_billing_customers_subscription + ON billing_customers (stripe_subscription_id); + +-- Back-fill from existing subscription rows that carry provider refs. Per tenant, +-- prefer the **live** (non-canceled) subscription's refs — that is the row whose +-- stripe_subscription_id is still active — falling back to the most recent +-- stripe-bearing row only when no live row carries refs (so the tenant's stable +-- stripe_customer_id is still carried forward for re-subscribe). +INSERT INTO billing_customers + (tenant_id, provider, stripe_customer_id, stripe_subscription_id, price_id, created_at, updated_at) +SELECT DISTINCT ON (s.tenant_id) + s.tenant_id, 'stripe', s.stripe_customer_id, s.stripe_subscription_id, s.price_id, + s.created_at, now() +FROM subscriptions s +WHERE s.stripe_customer_id IS NOT NULL + OR s.stripe_subscription_id IS NOT NULL +ORDER BY s.tenant_id, (s.status = 'canceled'), s.created_at DESC +ON CONFLICT (tenant_id, provider) DO NOTHING; diff --git a/source/crates/tenants/src/stripe.rs b/source/crates/billing/src/gateway.rs similarity index 97% rename from source/crates/tenants/src/stripe.rs rename to source/crates/billing/src/gateway.rs index 7874997..80e9b74 100644 --- a/source/crates/tenants/src/stripe.rs +++ b/source/crates/billing/src/gateway.rs @@ -1,12 +1,12 @@ -//! Stripe integration behind a [`StripeGateway`] trait. +//! Stripe integration behind a [`StripeGateway`] trait (the `PaymentProvider` port). //! -//! The trait normalizes the bits of Stripe we use into our own types so the -//! [`SubscriptionService`](crate::subscription::SubscriptionService) (and its tests) -//! never touch Stripe's wire format directly. [`StripeClient`] is the production impl -//! — a hand-rolled `reqwest` client against the Stripe REST API (the same pattern we -//! use for GitHub `OAuth2`); tests use a recording fake. Webhook signatures are -//! verified in-process ([`verify_signature`]) — the signature *is* the credential, so -//! the ingress endpoint is unauthenticated. +//! The trait normalizes the bits of Stripe we use into our own types so +//! [`BillingService`](crate::service::BillingService) (and its tests) never touch +//! Stripe's wire format directly. [`StripeClient`] is the production impl — a +//! hand-rolled `reqwest` client against the Stripe REST API (the same pattern we use +//! for GitHub `OAuth2`); tests use a recording fake. Webhook signatures are verified +//! in-process ([`verify_signature`]) — the signature *is* the credential, so the +//! ingress endpoint is unauthenticated. use std::collections::HashMap; @@ -16,7 +16,7 @@ use hmac::{Hmac, Mac, digest::KeyInit}; use serde::Deserialize; use sha2::Sha256; -use crate::repository::subscription::{Entitlement, SubscriptionStatus}; +use wardnet_common::contract::{Entitlement, SubscriptionStatus}; /// Stripe's webhook signatures are HMAC-SHA256. type HmacSha256 = Hmac; diff --git a/source/crates/tenants/src/stripe/tests.rs b/source/crates/billing/src/gateway/tests.rs similarity index 100% rename from source/crates/tenants/src/stripe/tests.rs rename to source/crates/billing/src/gateway/tests.rs diff --git a/source/crates/billing/src/lib.rs b/source/crates/billing/src/lib.rs new file mode 100644 index 0000000..e34b6bd --- /dev/null +++ b/source/crates/billing/src/lib.rs @@ -0,0 +1,34 @@ +//! Wardnet **Billing** — the payment aggregate: *how* a subscription is paid for. +//! +//! Owns the payment provider (Stripe today, behind the [`StripeGateway`] port), +//! hosted Checkout/Portal, the webhook + signature verification, the +//! `processed_stripe_events` idempotency ledger, and the `billing_customers` +//! provider-reference table. It is **swappable** and provider-specific. +//! +//! It depends only on `wardnet_common` and **never** on `subscriptions`/`tenants` +//! (decision #5 / ADR-0010): it implements [`BillingPort`] and drives the license +//! aggregate solely through the [`SubscriptionReader`] / [`SubscriptionCommands`] +//! ports. Subscription never calls Billing back. +//! +//! [`StripeGateway`]: crate::gateway::StripeGateway +//! [`BillingPort`]: wardnet_common::ports::BillingPort +//! [`SubscriptionReader`]: wardnet_common::ports::SubscriptionReader +//! [`SubscriptionCommands`]: wardnet_common::ports::SubscriptionCommands + +pub mod gateway; +pub mod repository; +pub mod service; + +pub use gateway::{StripeClient, StripeGateway}; +pub use repository::{BillingRepository, PgBillingRepository}; +pub use service::BillingService; + +/// This crate's migration set (the `billing_customers` table + back-fill). +/// `sqlx::migrate!` resolves the directory at compile time relative to this crate. +/// +/// **Not independently runnable.** The migration FKs `tenants(id)` and back-fills from +/// `subscriptions` (both owned by the `tenants` migrator), so running this `Migrator` +/// alone against a fresh DB fails. The schema authority is the **composed** migrator in +/// the binary's `db::init` (tenants + subscriptions + billing, ordered by version); any +/// live-pool repo test must migrate through that composed set, not this fragment. +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); diff --git a/source/crates/billing/src/repository.rs b/source/crates/billing/src/repository.rs new file mode 100644 index 0000000..54e9157 --- /dev/null +++ b/source/crates/billing/src/repository.rs @@ -0,0 +1,173 @@ +//! Billing data access: the **`billing_customers`** provider-reference table (the +//! tenant's payment-provider ids — Stripe customer/subscription/price) and the +//! **`processed_stripe_events`** webhook idempotency ledger. Both are owned by the +//! Billing aggregate; no other aggregate touches them (ADR-0010). + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use wardnet_common::db::DbPools; + +/// The payment provider behind a tenant's billing account. One value today; the +/// column keeps the table provider-neutral for later. +pub const PROVIDER_STRIPE: &str = "stripe"; + +/// Data access for the Billing-owned tables. +#[async_trait] +pub trait BillingRepository: Send + Sync { + /// Stamp the tenant's provider **customer** id (at checkout start), inserting the + /// `billing_customers` row if absent. Idempotent. + async fn upsert_customer( + &self, + tenant_id: &str, + stripe_customer_id: &str, + ) -> anyhow::Result<()>; + + /// Record the tenant's current provider **subscription** (customer + subscription + /// + price ids), inserting or updating the `billing_customers` row. Idempotent. + async fn upsert_subscription( + &self, + tenant_id: &str, + stripe_customer_id: &str, + stripe_subscription_id: &str, + price_id: Option<&str>, + ) -> anyhow::Result<()>; + + /// The tenant's recorded provider customer id, if any (so a re-subscribe reuses + /// the same Stripe Customer, and the portal can be opened). + async fn customer_id(&self, tenant_id: &str) -> anyhow::Result>; + + /// The tenant that owns `stripe_subscription_id`, if any (webhook → tenant lookup). + async fn tenant_for_subscription( + &self, + stripe_subscription_id: &str, + ) -> anyhow::Result>; + + /// Whether a Stripe event id has already been processed (the fast-path dedupe + /// read). Checked **before** applying; the id is recorded only **after** a + /// successful apply, so a failed apply leaves it un-recorded and Stripe's retry + /// re-applies it. + async fn is_event_processed(&self, event_id: &str) -> anyhow::Result; + + /// Record a processed Stripe event id (after a successful apply). Idempotent. + async fn record_event(&self, event_id: &str, now: DateTime) -> anyhow::Result<()>; +} + +/// `PostgreSQL`-backed [`BillingRepository`]. +pub struct PgBillingRepository { + pools: DbPools, +} + +impl PgBillingRepository { + #[must_use] + pub fn new(pool: sqlx::PgPool) -> Self { + Self { + pools: DbPools::single(pool), + } + } + + #[must_use] + pub fn new_pools(pools: DbPools) -> Self { + Self { pools } + } +} + +#[async_trait] +impl BillingRepository for PgBillingRepository { + async fn upsert_customer( + &self, + tenant_id: &str, + stripe_customer_id: &str, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO billing_customers \ + (tenant_id, provider, stripe_customer_id, created_at, updated_at) \ + VALUES ($1, $2, $3, now(), now()) \ + ON CONFLICT (tenant_id, provider) \ + DO UPDATE SET stripe_customer_id = EXCLUDED.stripe_customer_id, updated_at = now()", + ) + .bind(tenant_id) + .bind(PROVIDER_STRIPE) + .bind(stripe_customer_id) + .execute(&self.pools.write) + .await?; + Ok(()) + } + + async fn upsert_subscription( + &self, + tenant_id: &str, + stripe_customer_id: &str, + stripe_subscription_id: &str, + price_id: Option<&str>, + ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO billing_customers \ + (tenant_id, provider, stripe_customer_id, stripe_subscription_id, price_id, \ + created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, now(), now()) \ + ON CONFLICT (tenant_id, provider) DO UPDATE SET \ + stripe_customer_id = EXCLUDED.stripe_customer_id, \ + stripe_subscription_id = EXCLUDED.stripe_subscription_id, \ + price_id = EXCLUDED.price_id, \ + updated_at = now()", + ) + .bind(tenant_id) + .bind(PROVIDER_STRIPE) + .bind(stripe_customer_id) + .bind(stripe_subscription_id) + .bind(price_id) + .execute(&self.pools.write) + .await?; + Ok(()) + } + + async fn customer_id(&self, tenant_id: &str) -> anyhow::Result> { + let id: Option = sqlx::query_scalar( + "SELECT stripe_customer_id FROM billing_customers \ + WHERE tenant_id = $1 AND provider = $2 AND stripe_customer_id IS NOT NULL", + ) + .bind(tenant_id) + .bind(PROVIDER_STRIPE) + .fetch_optional(&self.pools.read) + .await?; + Ok(id) + } + + async fn tenant_for_subscription( + &self, + stripe_subscription_id: &str, + ) -> anyhow::Result> { + let tenant_id: Option = sqlx::query_scalar( + "SELECT tenant_id FROM billing_customers \ + WHERE stripe_subscription_id = $1 AND provider = $2", + ) + .bind(stripe_subscription_id) + .bind(PROVIDER_STRIPE) + .fetch_optional(&self.pools.read) + .await?; + Ok(tenant_id) + } + + async fn is_event_processed(&self, event_id: &str) -> anyhow::Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM processed_stripe_events WHERE event_id = $1)", + ) + .bind(event_id) + .fetch_one(&self.pools.read) + .await?; + Ok(exists) + } + + async fn record_event(&self, event_id: &str, now: DateTime) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO processed_stripe_events (event_id, processed_at) VALUES ($1, $2) \ + ON CONFLICT (event_id) DO NOTHING", + ) + .bind(event_id) + .bind(now) + .execute(&self.pools.write) + .await?; + Ok(()) + } +} diff --git a/source/crates/billing/src/service.rs b/source/crates/billing/src/service.rs new file mode 100644 index 0000000..4ee60f6 --- /dev/null +++ b/source/crates/billing/src/service.rs @@ -0,0 +1,246 @@ +//! `BillingService` — owns *how a subscription is paid for*: hosted Checkout/Portal, +//! the Stripe webhook, the provider-reference table, and the idempotency ledger. +//! +//! It drives the **license** aggregate exclusively through the +//! [`SubscriptionReader`] / [`SubscriptionCommands`] ports — it never names a +//! `subscriptions` (or `tenants`) type, so the boundary is compiler-enforced +//! (ADR-0010). Subscription never calls Billing back. + +use std::sync::Arc; + +use chrono::Utc; + +use wardnet_common::contract::{Entitlement, SubscriptionStatus}; +use wardnet_common::ports::{BillingError, BillingPort, SubscriptionCommands, SubscriptionReader}; + +use crate::gateway::{StripeEvent, StripeEventKind, StripeGateway, SubscriptionData}; +use crate::repository::BillingRepository; + +/// The payment business-rule layer. +pub struct BillingService { + stripe: Arc, + billing: Arc, + /// Read the license aggregate (e.g. preserve entitlement on a provider update + /// that carries no price metadata). + subscription_reader: Arc, + /// The one-way Billing → Subscription write edge. + subscription_commands: Arc, +} + +impl BillingService { + #[must_use] + pub fn new( + stripe: Arc, + billing: Arc, + subscription_reader: Arc, + subscription_commands: Arc, + ) -> Self { + Self { + stripe, + billing, + subscription_reader, + subscription_commands, + } + } + + /// Apply a verified Stripe webhook event, idempotently (a redelivery whose id is + /// already recorded is a no-op). The id is recorded only **after** a successful + /// apply, so a failed apply stays un-recorded and Stripe's retry re-applies it. + async fn apply_event(&self, event: StripeEvent) -> Result<(), BillingError> { + if self.billing.is_event_processed(&event.id).await? { + tracing::debug!(event_id = %event.id, "stripe event already processed; skipping"); + return Ok(()); + } + self.apply_event_kind(event.kind).await?; + self.billing.record_event(&event.id, Utc::now()).await?; + Ok(()) + } + + /// Apply the event's effect. Each branch is idempotent, so an at-least-once + /// redelivery (or a retry after a recorded-but-failed apply) is safe. + async fn apply_event_kind(&self, kind: StripeEventKind) -> Result<(), BillingError> { + match kind { + StripeEventKind::SubscriptionUpsert(data) => self.apply_upsert(data).await?, + StripeEventKind::SubscriptionDeleted { + stripe_subscription_id, + } => { + if let Some(tenant_id) = self + .billing + .tenant_for_subscription(&stripe_subscription_id) + .await? + { + self.subscription_commands.cancel(&tenant_id).await?; + } + } + StripeEventKind::PaymentFailed { + stripe_subscription_id, + } => { + if let Some(tenant_id) = self + .billing + .tenant_for_subscription(&stripe_subscription_id) + .await? + { + self.subscription_commands.mark_past_due(&tenant_id).await?; + } + } + StripeEventKind::Ignored => {} + } + Ok(()) + } + + /// `customer.subscription.created`/`.updated`: record the provider refs and drive + /// the license aggregate to the reported state. + /// + /// The convert-vs-update decision is made on the **license state** (read via the + /// port), not on whether we have already recorded the provider ref. That keeps two + /// at-least-once properties the old single-aggregate code had implicitly: a retry + /// after a partial write still *converts* (rather than mutating the still-trial row + /// in place), and a renewed subscription whose license the reaper already canceled + /// re-entitles by recreating the paid row. + async fn apply_upsert(&self, data: SubscriptionData) -> Result<(), BillingError> { + // Resolve the tenant: the recorded provider-ref mapping first (an `.updated` + // payload may omit checkout metadata), else the checkout metadata (a never-seen + // subscription). `known` also tells us whether this subscription is one we have + // actually recorded — only those are cancellable. + let known = self + .billing + .tenant_for_subscription(&data.stripe_subscription_id) + .await?; + let Some(tenant_id) = known.clone().or_else(|| data.tenant_id.clone()) else { + // No mapping and no metadata: a never-seen subscription we can't attribute. + // Canceled is simply nothing-to-do; anything else is declined (safe-closed). + if data.status != SubscriptionStatus::Canceled { + tracing::error!( + stripe_subscription_id = %data.stripe_subscription_id, + "stripe subscription has no tenant_id metadata; ignoring" + ); + } + return Ok(()); + }; + + // A reported Canceled routes through the single cancel path (publishes the + // deactivation). Only cancel a subscription we have recorded — a never-seen + // subscription reporting canceled has nothing to cancel (the tenant may still be + // on its trial), matching the old new-vs-existing split. + if data.status == SubscriptionStatus::Canceled { + if let Some(tenant_id) = known { + self.subscription_commands.cancel(&tenant_id).await?; + } + return Ok(()); + } + + // Read the current license once: its status picks convert-vs-update, and its + // entitlement is the fallback when Stripe omits price metadata on an update. + let current = self.subscription_reader.current(&tenant_id).await?; + + // Already a paid license → patch it in place, preserving the current + // entitlement when Stripe omits price metadata. Otherwise (no live license — e.g. + // the reaper canceled it — or still on the trial) (re)create the paid license, + // carrying Stripe's reported status. + // + // The provider ref is recorded only **after** we commit to granting (and before + // the license command, so a retry still maps the subscription to its tenant). A + // subscription we decline to grant (no price metadata) must NOT be recorded — + // otherwise a later `.deleted`/`.payment_failed` would resolve this tenant and + // wrongly cancel/past-due its still-live trial. + if matches!( + current.as_ref().map(|s| s.status), + Some(SubscriptionStatus::Active | SubscriptionStatus::PastDue) + ) { + let entitlement = data + .entitlement + .or_else(|| current.map(|s| s.entitlement)) + .unwrap_or(Entitlement::DEFAULT); + self.record_ref(&tenant_id, &data).await?; + self.subscription_commands + .update_paid( + &tenant_id, + data.status, + entitlement, + data.current_period_end, + ) + .await?; + } else { + // The plan's entitlement must come from price metadata; without it we + // decline to grant (safe-closed) and record nothing. + let Some(entitlement) = data.entitlement else { + tracing::error!( + stripe_subscription_id = %data.stripe_subscription_id, + "stripe price has no max_networks/max_daemons metadata; not granting" + ); + return Ok(()); + }; + self.record_ref(&tenant_id, &data).await?; + self.subscription_commands + .convert_trial_to_paid( + &tenant_id, + data.status, + entitlement, + data.current_period_end, + ) + .await?; + tracing::info!(tenant_id, "converted to paid subscription"); + } + Ok(()) + } + + /// Record the provider refs (idempotent) so future webhooks for this subscription + /// resolve back to its tenant. Called only once we have committed to grant/update — + /// never on a declined subscription (see [`apply_upsert`](Self::apply_upsert)). + async fn record_ref( + &self, + tenant_id: &str, + data: &SubscriptionData, + ) -> Result<(), BillingError> { + self.billing + .upsert_subscription( + tenant_id, + &data.stripe_customer_id, + &data.stripe_subscription_id, + data.price_id.as_deref(), + ) + .await?; + Ok(()) + } +} + +#[async_trait::async_trait] +impl BillingPort for BillingService { + async fn start_checkout( + &self, + tenant_id: &str, + email: &str, + price_id: &str, + ) -> Result { + let customer_id = self.billing.customer_id(tenant_id).await?; + let session = self + .stripe + .create_checkout_session(customer_id.as_deref(), email, price_id, tenant_id) + .await + .map_err(BillingError::Internal)?; + // Best-effort: stamp the customer id now if Stripe surfaced one (the + // authoritative value still arrives via the webhook). + if let Some(cid) = session.customer_id { + self.billing.upsert_customer(tenant_id, &cid).await?; + } + Ok(session.url) + } + + async fn billing_portal(&self, tenant_id: &str) -> Result { + let customer_id = self.billing.customer_id(tenant_id).await?.ok_or_else(|| { + BillingError::InvalidRequest("tenant has no billing account yet".to_string()) + })?; + self.stripe + .create_billing_portal_session(&customer_id) + .await + .map_err(BillingError::Internal) + } + + async fn handle_webhook(&self, payload: &[u8], signature: &str) -> Result<(), BillingError> { + let event = self + .stripe + .construct_event(payload, signature) + .map_err(|e| BillingError::InvalidRequest(format!("invalid Stripe webhook: {e}")))?; + self.apply_event(event).await + } +} diff --git a/source/crates/tenants/tests/stripe.rs b/source/crates/billing/tests/stripe.rs similarity index 97% rename from source/crates/tenants/tests/stripe.rs rename to source/crates/billing/tests/stripe.rs index 59d2f96..b5b0a65 100644 --- a/source/crates/tenants/tests/stripe.rs +++ b/source/crates/billing/tests/stripe.rs @@ -1,9 +1,9 @@ //! `StripeClient` (the reqwest gateway) against a wiremock Stripe — validates the //! request shape (path, Bearer auth, form body) for checkout / billing-portal sessions //! and that a Stripe API error surfaces only its `type`/`code`, never the raw response -//! body (invariant #9). Mirrors `tests/email.rs`; no real Stripe API is touched. +//! body (invariant #9). No real Stripe API is touched. -use wardnet_tenants::stripe::{StripeClient, StripeGateway}; +use wardnet_billing::gateway::{StripeClient, StripeGateway}; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; diff --git a/source/crates/common/src/contract.rs b/source/crates/common/src/contract.rs index f25afff..57e5052 100644 --- a/source/crates/common/src/contract.rs +++ b/source/crates/common/src/contract.rs @@ -147,23 +147,19 @@ pub struct NetworkView { pub updated_at: DateTime, } -/// The full **Subscription** resource — the billing aggregate that grants a -/// tenant's [`Entitlement`]. Producer: Tenants (account plane + embedded in -/// [`TenantView`]). A tenant's *current* subscription is its single non-`Canceled` -/// row; `Canceled` rows are history and are never embedded as current. +/// The full **Subscription** resource — the **license** aggregate that grants a +/// tenant's [`Entitlement`]. Provider-agnostic: payment-provider reference ids +/// (Stripe customer/subscription/price) live in the **Billing** aggregate and are +/// surfaced by Billing's own read endpoints, not here. Producer: Tenants (account +/// plane + embedded in [`TenantView`]). A tenant's *current* subscription is its +/// single non-`Canceled` row; `Canceled` rows are history and are never embedded as +/// current. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SubscriptionView { pub id: String, pub status: SubscriptionStatus, /// The limits this subscription's plan grants. pub entitlement: Entitlement, - /// Stripe Customer handle (the tenant's stable billing identity); `None` until - /// the tenant first reaches checkout. - pub stripe_customer_id: Option, - /// Stripe Subscription handle; `None` while still on the card-less trial. - pub stripe_subscription_id: Option, - /// The purchased Stripe Price id; `None` on the trial. - pub price_id: Option, /// When the free trial lapses (a `Trialing` subscription only). pub trial_expires_at: Option>, /// End of the current paid period (a paid subscription only). diff --git a/source/crates/common/src/event.rs b/source/crates/common/src/event.rs index a78b583..618262d 100644 --- a/source/crates/common/src/event.rs +++ b/source/crates/common/src/event.rs @@ -1,28 +1,46 @@ -//! In-process domain-event bus for cross-aggregate decoupling. +//! Cross-aggregate domain-event bus — a **port**, with an in-process adapter. //! //! Services **raise domain events; others react.** A service never reaches into //! another aggregate's repository to drive a side-effect — instead it publishes a -//! [`DomainEvent`] and a long-running *reactor* (subscribed to the bus) calls the -//! owning service's method. Reads stay direct (a service method call); only -//! write-side side-effects flow as events. +//! [`DomainEvent`] through the [`EventBus`] port and a long-running *reactor* +//! (subscribed to the bus) calls the owning service's method. Reads stay direct +//! (a synchronous query/command port — see [`crate::ports`]); only write-side +//! side-effects flow as events. //! -//! The transport is a best-effort [`tokio::sync::broadcast`] channel: a slow or -//! absent subscriber may miss an event, so **reactors must be idempotent** and a -//! periodic reconcile (owned by the repo-owning service) closes any gap. Events are -//! the fast path; reconciliation is the guarantee. This mirrors the daemon's -//! `wardnet_common::event` design. +//! The port deliberately leaks **no transport type**: `subscribe` hands back a +//! `Box`, not a `tokio::broadcast::Receiver`. The only adapter +//! shipped today is the in-process [`InProcessEventBus`] (a `tokio::broadcast` +//! channel); a durable broker (AMQP/RabbitMQ) adapter is a later drop-in that uses +//! the `group` argument for competing consumers across replicas and a real broker +//! ack in [`Delivery::ack`]. Because of that future, [`DomainEvent`] is +//! serde-serializable with a **stable, versioned wire format** ([`EVENT_WIRE_VERSION`]). +//! +//! Across **both** transports the same invariant holds: events are a best-effort +//! **fast path**; the periodic reconcile (owned by the repo-owning service) is the +//! **correctness guarantee**. A slow/absent subscriber may miss an event, so every +//! reactor must be **idempotent** and tolerate **at-least-once** delivery. The +//! broker buys durability/decoupling, never correctness. +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; -/// A cross-aggregate domain event. Cloneable so it can fan out over the broadcast -/// channel to every subscriber. -#[derive(Debug, Clone, PartialEq, Eq)] +/// A cross-aggregate domain event. +/// +/// Serde-serializable with a stable, `snake_case`, internally-tagged shape so the +/// future broker adapter is a drop-in (see [`WireEnvelope`]). The wire format is +/// **additive-only**: new variants and new optional fields are backward-compatible; +/// never rename or repurpose an existing variant/field without bumping +/// [`EVENT_WIRE_VERSION`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum DomainEvent { /// A tenant account was created. The subscription aggregate reacts by creating /// the tenant's free **trial** subscription. TenantCreated { tenant_id: String }, /// A tenant account was deregistered (tombstoned). The subscription aggregate - /// reacts by **cancelling** the tenant's current subscription. + /// reacts by **cancelling** the tenant's current subscription; the identities + /// aggregate reacts by purging the tenant's login methods + sessions. TenantDeregistered { tenant_id: String }, /// A tenant's subscription became inactive (cancelled, or lapsed past its /// grace). The tenants aggregate reacts by **deprovisioning** the tenant's @@ -30,29 +48,101 @@ pub enum DomainEvent { SubscriptionDeactivated { tenant_id: String }, } -/// Abstraction over domain-event publishing and subscribing. +/// Wire-format version for the serialized [`DomainEvent`] envelope. Bump only on a +/// **breaking** change to the shape (a rename/removal); additive changes do not. +pub const EVENT_WIRE_VERSION: u32 = 1; + +/// The versioned envelope a broker adapter puts on the wire: `{ "v": 1, "type": +/// "tenant_created", "tenant_id": "…" }`. The in-process adapter does not serialize +/// (it passes the [`DomainEvent`] by value), so this exists for the future broker +/// adapter and for round-trip tests pinning the format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireEnvelope { + /// The wire-format version ([`EVENT_WIRE_VERSION`]). + pub v: u32, + /// The event payload. + #[serde(flatten)] + pub event: DomainEvent, +} + +impl WireEnvelope { + /// Wrap an event in the current versioned envelope. + #[must_use] + pub fn new(event: DomainEvent) -> Self { + Self { + v: EVENT_WIRE_VERSION, + event, + } + } +} + +/// One delivered event plus its acknowledgement handle. /// -/// A trait so services can take an `Arc` and tests can assert -/// "event X was raised" with a recording fake instead of a live channel. -pub trait EventPublisher: Send + Sync { - /// Publish a domain event to all current subscribers. - fn publish(&self, event: DomainEvent); - - /// Create a new subscriber that receives events published from now on. - fn subscribe(&self) -> broadcast::Receiver; +/// In-process delivery is **auto-acked** ([`Delivery::ack`] is a no-op): the +/// reconcile loop is the safety net, so there is nothing to redeliver. A broker +/// adapter carries a real ack handle here so a reactor acks only after its effect +/// lands (at-least-once). +pub struct Delivery { + event: DomainEvent, +} + +impl Delivery { + /// Borrow the delivered event. + #[must_use] + pub fn event(&self) -> &DomainEvent { + &self.event + } + + /// Acknowledge the delivery. No-op for the in-process adapter; a broker adapter + /// confirms the message here so it is not redelivered (hence `async` + `self` by + /// value — part of the port contract, even though the in-proc body is empty). + #[allow(clippy::unused_async)] + pub async fn ack(self) {} +} + +/// Domain-event **publishing** port. Transport-free: no `tokio` type appears in any +/// signature. Services take an `Arc`. +#[async_trait] +pub trait EventBus: Send + Sync { + /// Publish a domain event. Best-effort: success does not guarantee any + /// subscriber observed it (the reconcile loop is the guarantee). + /// + /// # Errors + /// Returns an error only if the underlying transport fails to accept the event + /// (the in-process adapter never errors). + async fn publish(&self, event: &DomainEvent) -> anyhow::Result<()>; + + /// Open a subscription stream. `group` names a competing-consumer group: a no-op + /// for the in-process adapter (every subscriber sees every event), the + /// shared-queue key for a future broker adapter (one delivery per group). + /// + /// # Errors + /// Returns an error if the transport cannot establish the subscription (the + /// in-process adapter never errors). + async fn subscribe(&self, group: &str) -> anyhow::Result>; +} + +/// A live subscription stream. Transport-free; the concrete receiver is hidden +/// inside the adapter's implementation. +#[async_trait] +pub trait EventStream: Send { + /// The next delivery, or `None` once the bus is closed (no more events will + /// ever arrive). + async fn next(&mut self) -> Option; } -/// Default [`EventPublisher`] backed by [`tokio::sync::broadcast`]. +/// In-process [`EventBus`] adapter backed by [`tokio::sync::broadcast`]. /// -/// Clone-friendly — wraps a `broadcast::Sender`, which is `Clone`. A send with no +/// Clone-friendly — wraps a `broadcast::Sender`, which is `Clone`. A publish with no /// live subscribers is a no-op (the reconcile loop is the safety net), so publishing -/// never blocks and never fails the caller. +/// never blocks and never fails the caller. `group` is ignored: every subscriber +/// sees every event (parity with the prior in-process behaviour). #[derive(Debug, Clone)] -pub struct BroadcastEventBus { +pub struct InProcessEventBus { sender: broadcast::Sender, } -impl BroadcastEventBus { +impl InProcessEventBus { /// Create a bus whose channel buffers up to `capacity` undelivered events per /// subscriber before the slowest subscriber starts lagging. #[must_use] @@ -62,15 +152,40 @@ impl BroadcastEventBus { } } -impl EventPublisher for BroadcastEventBus { - fn publish(&self, event: DomainEvent) { +#[async_trait] +impl EventBus for InProcessEventBus { + async fn publish(&self, event: &DomainEvent) -> anyhow::Result<()> { // An error here only means there are no subscribers — harmless; the periodic // reconcile re-derives the desired state regardless. - let _ = self.sender.send(event); + let _ = self.sender.send(event.clone()); + Ok(()) + } + + async fn subscribe(&self, _group: &str) -> anyhow::Result> { + Ok(Box::new(BroadcastStream { + rx: self.sender.subscribe(), + })) } +} + +/// In-process [`EventStream`] over a `broadcast::Receiver`. A `Lagged` gap is +/// swallowed (logged) and the stream continues — the reconcile loop closes the gap. +struct BroadcastStream { + rx: broadcast::Receiver, +} - fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() +#[async_trait] +impl EventStream for BroadcastStream { + async fn next(&mut self) -> Option { + loop { + match self.rx.recv().await { + Ok(event) => return Some(Delivery { event }), + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!(skipped, "event stream lagged; reconcile is the safety net"); + } + Err(broadcast::error::RecvError::Closed) => return None, + } + } } } diff --git a/source/crates/common/src/event/tests.rs b/source/crates/common/src/event/tests.rs index b4942f4..0da0a1e 100644 --- a/source/crates/common/src/event/tests.rs +++ b/source/crates/common/src/event/tests.rs @@ -1,47 +1,76 @@ -//! Unit tests for the broadcast event bus. +//! Unit tests for the in-process event bus + the versioned wire format. -use super::{BroadcastEventBus, DomainEvent, EventPublisher}; +use super::{DomainEvent, EVENT_WIRE_VERSION, EventBus, InProcessEventBus, WireEnvelope}; #[tokio::test] async fn subscriber_receives_published_events() { - let bus = BroadcastEventBus::new(16); - let mut rx = bus.subscribe(); + let bus = InProcessEventBus::new(16); + let mut stream = bus.subscribe("test").await.expect("subscribe"); - bus.publish(DomainEvent::TenantCreated { + bus.publish(&DomainEvent::TenantCreated { tenant_id: "t1".to_string(), - }); + }) + .await + .expect("publish"); - let got = rx.recv().await.expect("event delivered"); + let delivery = stream.next().await.expect("event delivered"); assert_eq!( - got, - DomainEvent::TenantCreated { + delivery.event(), + &DomainEvent::TenantCreated { tenant_id: "t1".to_string() } ); + delivery.ack().await; } #[tokio::test] async fn publish_with_no_subscribers_is_a_noop() { - let bus = BroadcastEventBus::new(16); + let bus = InProcessEventBus::new(16); // No panic / no error surfaced to the caller when nobody is listening. - bus.publish(DomainEvent::SubscriptionDeactivated { + bus.publish(&DomainEvent::SubscriptionDeactivated { tenant_id: "t1".to_string(), - }); + }) + .await + .expect("publish with no subscribers must not error"); } #[tokio::test] async fn each_subscriber_sees_every_event() { - let bus = BroadcastEventBus::new(16); - let mut a = bus.subscribe(); - let mut b = bus.subscribe(); + let bus = InProcessEventBus::new(16); + let mut a = bus.subscribe("a").await.expect("subscribe a"); + let mut b = bus.subscribe("b").await.expect("subscribe b"); - bus.publish(DomainEvent::TenantDeregistered { + bus.publish(&DomainEvent::TenantDeregistered { tenant_id: "t9".to_string(), - }); + }) + .await + .expect("publish"); let expected = DomainEvent::TenantDeregistered { tenant_id: "t9".to_string(), }; - assert_eq!(a.recv().await.unwrap(), expected); - assert_eq!(b.recv().await.unwrap(), expected); + assert_eq!(a.next().await.unwrap().event(), &expected); + assert_eq!(b.next().await.unwrap().event(), &expected); +} + +#[test] +fn wire_format_is_stable_and_versioned() { + // Pin the on-the-wire shape a future broker adapter must produce/consume: + // `{ "v": 1, "type": "tenant_created", "tenant_id": "t1" }`. + let env = WireEnvelope::new(DomainEvent::TenantCreated { + tenant_id: "t1".to_string(), + }); + let json = serde_json::to_value(&env).expect("serialize"); + assert_eq!(json["v"], EVENT_WIRE_VERSION); + assert_eq!(json["type"], "tenant_created"); + assert_eq!(json["tenant_id"], "t1"); + + let round: WireEnvelope = serde_json::from_value(json).expect("deserialize"); + assert_eq!(round.v, EVENT_WIRE_VERSION); + assert_eq!( + round.event, + DomainEvent::TenantCreated { + tenant_id: "t1".to_string() + } + ); } diff --git a/source/crates/common/src/lib.rs b/source/crates/common/src/lib.rs index 8d67e22..ec9630d 100644 --- a/source/crates/common/src/lib.rs +++ b/source/crates/common/src/lib.rs @@ -15,6 +15,7 @@ pub mod error; pub mod event; pub mod health; pub mod mtls; +pub mod ports; pub mod proxy_protocol; pub mod replay_cache; pub mod serve; diff --git a/source/crates/common/src/ports.rs b/source/crates/common/src/ports.rs new file mode 100644 index 0000000..0048b6c --- /dev/null +++ b/source/crates/common/src/ports.rs @@ -0,0 +1,159 @@ +//! Synchronous inter-aggregate **client ports** — the query/command seams between +//! the (currently in-process) aggregates that need an answer *now*, not eventually. +//! +//! These traits live in `common` on purpose: the aggregate crates (`tenants`, +//! `subscriptions`, `billing`) depend on `common` and **never on each other**, so a +//! consumer can only ever name an `Arc` here — the compiler forbids it from +//! reaching a sibling's concrete type. That boundary is exactly what becomes a +//! network call when an aggregate is promoted to its own host: today the composition +//! root injects an in-process adapter (a direct method call); later it injects a +//! mesh-mTLS HTTP adapter, with **zero** change to the consuming domain code. +//! +//! Two directions, mirroring the established Identities → Tenants edge: +//! - [`SubscriptionReader`] — entitlement **reads** (Tenants' `register_network` / +//! daemon JWT minting, the resource-read view, the Tunneller's `TenantView`). +//! - [`SubscriptionCommands`] — the one-way **Billing → Subscription** write edge +//! (the webhook drives license transitions only through this). Subscription never +//! calls Billing. +//! +//! Only `common` types cross the boundary (no concrete domain struct): reads return +//! the [`SubscriptionView`](crate::contract::SubscriptionView) DTO; commands take +//! primitives + [`Entitlement`](crate::contract::Entitlement). + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use crate::contract::{Entitlement, SubscriptionStatus, SubscriptionView}; +use crate::error::ApiError; + +/// Read port over the **license** (subscription) aggregate. In-process adapter = +/// a direct call into `SubscriptionService`. +#[async_trait] +pub trait SubscriptionReader: Send + Sync { + /// The tenant's current (single non-`Canceled`) subscription as a wire view, or + /// `None` if it has no live subscription. + /// + /// # Errors + /// Propagates a backing-store / transport failure. + async fn current(&self, tenant_id: &str) -> anyhow::Result>; + + /// Whether the tenant is **currently entitled** to service, applying the trial / + /// payment **grace** windows (the policy lives with the aggregate, so this is + /// computed provider-side, not re-derived by the caller). + /// + /// # Errors + /// Propagates a backing-store / transport failure. + async fn is_active(&self, tenant_id: &str) -> anyhow::Result; +} + +/// Command port over the **license** aggregate — the one-way Billing → Subscription +/// edge. Implemented by `SubscriptionService`; called by Billing's webhook path. The +/// `stripe_*` reference ids never cross here (they live in Billing's own +/// `billing_customers` table), so every transition is provider-agnostic. +#[async_trait] +pub trait SubscriptionCommands: Send + Sync { + /// Convert the tenant's trial to a paid subscription (cancel the live trial + + /// insert the paid row, atomically) at the provider-reported `status` (never + /// `Canceled` — cancellation routes through [`cancel`](Self::cancel)). Used both + /// for the first paid event and to recreate a paid license that lapsed. + /// + /// # Errors + /// Propagates a backing-store / transport failure. + async fn convert_trial_to_paid( + &self, + tenant_id: &str, + status: SubscriptionStatus, + entitlement: Entitlement, + current_period_end: Option>, + ) -> anyhow::Result<()>; + + /// Reconcile the tenant's live paid subscription to a provider update — a plan + /// change / period roll / `active`⇄`past_due` refresh. `status` is the provider's + /// reported lifecycle (never `Canceled` here — cancellation routes through + /// [`cancel`](Self::cancel)). Returns `false` if the tenant has no live + /// subscription. + /// + /// # Errors + /// Propagates a backing-store / transport failure. + async fn update_paid( + &self, + tenant_id: &str, + status: SubscriptionStatus, + entitlement: Entitlement, + current_period_end: Option>, + ) -> anyhow::Result; + + /// Flag the tenant's live subscription `past_due` (a payment failed), preserving + /// its entitlement + period. Returns `false` if the tenant has no live + /// subscription. + /// + /// # Errors + /// Propagates a backing-store / transport failure. + async fn mark_past_due(&self, tenant_id: &str) -> anyhow::Result; + + /// Cancel the tenant's current subscription (publishing + /// [`SubscriptionDeactivated`](crate::event::DomainEvent::SubscriptionDeactivated) + /// so the network reactor deprovisions its networks). Idempotent. + /// + /// # Errors + /// Propagates a backing-store / transport failure. + async fn cancel(&self, tenant_id: &str) -> anyhow::Result<()>; +} + +/// Error surfaced by the [`BillingPort`]. HTTP-agnostic but distinguishes a +/// client-fixable request (bad webhook signature, no billing account yet) from an +/// internal failure, so the HTTP shell maps it to the right status. +#[derive(Debug, thiserror::Error)] +pub enum BillingError { + /// The request itself is bad — an unverifiable webhook signature, or a portal + /// request for a tenant with no billing account. Maps to `400`. + #[error("{0}")] + InvalidRequest(String), + /// A provider/repository failure. Maps to `500`. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +impl From for ApiError { + fn from(e: BillingError) -> Self { + match e { + BillingError::InvalidRequest(m) => ApiError::BadRequest(m), + BillingError::Internal(e) => ApiError::Internal(e), + } + } +} + +/// Command port over the **payment** (billing) aggregate — the +/// composition/Tenants → Billing edge. Implemented by `BillingService`; consumed by +/// the HTTP handlers (hosted in the deployable's router today; served by Billing's +/// own bin after a future split, where the webhook is delivered to Billing directly +/// and checkout/portal arrive over mesh-mTLS). Billing never calls back. +#[async_trait] +pub trait BillingPort: Send + Sync { + /// Start a hosted Checkout for `price_id` (reusing the tenant's provider customer + /// when known) and return the redirect URL. + /// + /// # Errors + /// [`BillingError::Internal`] on a provider/repository failure. + async fn start_checkout( + &self, + tenant_id: &str, + email: &str, + price_id: &str, + ) -> Result; + + /// Create a hosted Billing Portal session for the tenant and return its URL. + /// + /// # Errors + /// [`BillingError::InvalidRequest`] if the tenant has no billing account yet; + /// [`BillingError::Internal`] on a provider failure. + async fn billing_portal(&self, tenant_id: &str) -> Result; + + /// Verify a raw provider webhook (the signature is the credential) and apply it + /// idempotently. + /// + /// # Errors + /// [`BillingError::InvalidRequest`] on an unverifiable/malformed payload; + /// [`BillingError::Internal`] on a repository failure. + async fn handle_webhook(&self, payload: &[u8], signature: &str) -> Result<(), BillingError>; +} diff --git a/source/crates/ddns/Cargo.toml b/source/crates/ddns/Cargo.toml index 46da409..7dae626 100644 --- a/source/crates/ddns/Cargo.toml +++ b/source/crates/ddns/Cargo.toml @@ -10,10 +10,6 @@ description = "Wardnet DDNS service — regional DNS reconciler (provisioner + r name = "wardnet_ddns" path = "src/lib.rs" -[[bin]] -name = "wardnet-ddns" -path = "src/main.rs" - [dependencies] wardnet_common = { workspace = true } diff --git a/source/crates/ddns/Dockerfile b/source/crates/ddns/Dockerfile index 0ff2083..1ff02bb 100644 --- a/source/crates/ddns/Dockerfile +++ b/source/crates/ddns/Dockerfile @@ -5,7 +5,7 @@ FROM rust:1-bookworm AS build WORKDIR /src COPY . . -RUN cargo build --release -p wardnet-ddns +RUN cargo build --release -p wardnet-ddns-bin FROM debian:bookworm-slim AS runtime RUN apt-get update \ diff --git a/source/crates/subscriptions/Cargo.toml b/source/crates/subscriptions/Cargo.toml new file mode 100644 index 0000000..9a21de2 --- /dev/null +++ b/source/crates/subscriptions/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "wardnet-subscriptions" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Wardnet Subscriptions — the provider-agnostic license aggregate (entitlement + trial/grace lifecycle)" + +[lib] +name = "wardnet_subscriptions" +path = "src/lib.rs" + +[dependencies] +wardnet_common = { workspace = true } + +sqlx = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } + +[lints] +workspace = true diff --git a/source/crates/subscriptions/migrations/20260620000100_drop_subscription_stripe_cols.sql b/source/crates/subscriptions/migrations/20260620000100_drop_subscription_stripe_cols.sql new file mode 100644 index 0000000..8db494c --- /dev/null +++ b/source/crates/subscriptions/migrations/20260620000100_drop_subscription_stripe_cols.sql @@ -0,0 +1,13 @@ +-- The subscription is the provider-agnostic LICENSE (ADR-0010). Stripe reference +-- ids move OUT of `subscriptions` into the Billing-owned `billing_customers` table. +-- +-- Forward-only + ordering: the billing `billing_customers` migration carries an +-- EARLIER timestamp, so its back-fill (INSERT … SELECT from subscriptions) has +-- already copied these columns before this migration drops them. `IF EXISTS` keeps +-- it safe on a fresh DB where init created the columns and on re-runs. +ALTER TABLE subscriptions + DROP COLUMN IF EXISTS stripe_customer_id, + DROP COLUMN IF EXISTS stripe_subscription_id, + DROP COLUMN IF EXISTS price_id; + +DROP INDEX IF EXISTS idx_subscriptions_stripe; diff --git a/source/crates/subscriptions/src/error.rs b/source/crates/subscriptions/src/error.rs new file mode 100644 index 0000000..a587640 --- /dev/null +++ b/source/crates/subscriptions/src/error.rs @@ -0,0 +1,21 @@ +//! Service-layer domain error for [`SubscriptionService`](crate::service::SubscriptionService). +//! +//! HTTP-agnostic. Consumers reach this aggregate through the +//! [`wardnet_common::ports`] traits (which return `anyhow::Result`), so the +//! `?`-conversion to `anyhow::Error` (via `thiserror`'s `std::error::Error` impl) is +//! all the cross-crate surface needed — there is deliberately no `ApiError` mapping +//! here (that belongs to whichever crate serves HTTP). + +/// Things that can go wrong applying a subscription rule. +#[derive(Debug, thiserror::Error)] +pub enum SubscriptionError { + /// A referenced tenant / subscription does not exist. + #[error("{0}")] + NotFound(String), + /// Malformed input (e.g. an unknown plan). + #[error("{0}")] + BadRequest(String), + /// A repository failure. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} diff --git a/source/crates/subscriptions/src/lib.rs b/source/crates/subscriptions/src/lib.rs new file mode 100644 index 0000000..d4e4c30 --- /dev/null +++ b/source/crates/subscriptions/src/lib.rs @@ -0,0 +1,36 @@ +//! Wardnet **Subscriptions** — the provider-agnostic **license** aggregate. +//! +//! Owns *what entitlement a tenant currently holds* and its lifecycle +//! (`trialing → active → past_due → canceled`, with trial/payment **grace** +//! windows and the reaper that cancels overdue rows). It is the **source of truth +//! for entitlement** and knows **nothing** about payment providers — no Stripe +//! types appear anywhere in this crate. *How* a subscription is paid for lives in +//! the separate `billing` aggregate. +//! +//! This crate depends only on `wardnet_common` and **never** on `tenants`/`billing` +//! (decision #5 / ADR-0010): it *implements* the [`SubscriptionReader`] (entitlement +//! reads) and [`SubscriptionCommands`] (the one-way Billing → Subscription write +//! edge) ports from [`wardnet_common::ports`]; consumers hold those as `dyn` trait +//! objects, so the boundary is compiler-enforced. +//! +//! [`SubscriptionReader`]: wardnet_common::ports::SubscriptionReader +//! [`SubscriptionCommands`]: wardnet_common::ports::SubscriptionCommands + +pub mod error; +pub mod reactor; +pub mod repository; +pub mod service; + +pub use error::SubscriptionError; +pub use repository::{PgSubscriptionRepository, Subscription, SubscriptionRepository}; +pub use service::{SubscriptionService, TrialPolicy}; + +/// This crate's migration set (the license-only schema deltas). `sqlx::migrate!` +/// resolves the directory at compile time relative to this crate. +/// +/// **Not independently runnable.** These migrations `ALTER` the `subscriptions` table +/// (created by the `tenants` migrator), so running this `Migrator` alone against a +/// fresh DB fails. The schema authority is the **composed** migrator in the binary's +/// `db::init` (tenants + subscriptions + billing, ordered by version); any live-pool +/// repo test must migrate through that composed set, not this fragment. +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); diff --git a/source/crates/subscriptions/src/reactor.rs b/source/crates/subscriptions/src/reactor.rs new file mode 100644 index 0000000..3092546 --- /dev/null +++ b/source/crates/subscriptions/src/reactor.rs @@ -0,0 +1,42 @@ +//! The **subscription** reactor — the long-running loop that turns published +//! [`DomainEvent`]s into [`SubscriptionService`] calls. It holds an +//! `Arc` (never a repository), and every reaction is +//! **idempotent** so a redelivery is harmless and the periodic reconcile can +//! re-drive a dropped event. Spawn it from the binary +//! (`tokio::spawn(run_subscription_reactor(svc, bus.subscribe(group).await?))`). + +use std::sync::Arc; + +use wardnet_common::event::{DomainEvent, EventStream}; + +use crate::service::SubscriptionService; + +/// Apply a single event to the subscription aggregate: `TenantCreated` → open the +/// trial, `TenantDeregistered` → cancel. Other events are ignored. Factored out so +/// both the live loop and the deterministic test pump share one body. +pub async fn apply_to_subscription(service: &SubscriptionService, event: &DomainEvent) { + match event { + DomainEvent::TenantCreated { tenant_id } => { + if let Err(e) = service.create_trial(tenant_id).await { + tracing::error!(error = %e, tenant_id, "subscription reactor: create_trial failed"); + } + } + DomainEvent::TenantDeregistered { tenant_id } => { + if let Err(e) = service.cancel(tenant_id).await { + tracing::error!(error = %e, tenant_id, "subscription reactor: cancel failed"); + } + } + DomainEvent::SubscriptionDeactivated { .. } => {} + } +} + +/// React to tenant-lifecycle events by driving the subscription aggregate. +pub async fn run_subscription_reactor( + service: Arc, + mut events: Box, +) { + while let Some(delivery) = events.next().await { + apply_to_subscription(&service, delivery.event()).await; + delivery.ack().await; + } +} diff --git a/source/crates/tenants/src/repository/subscription.rs b/source/crates/subscriptions/src/repository.rs similarity index 60% rename from source/crates/tenants/src/repository/subscription.rs rename to source/crates/subscriptions/src/repository.rs index 116cb7a..56d5837 100644 --- a/source/crates/tenants/src/repository/subscription.rs +++ b/source/crates/subscriptions/src/repository.rs @@ -1,8 +1,12 @@ -//! The **subscription** — the billing aggregate that *grants* a tenant's -//! [`Entitlement`]. 1:N history with at most one live (non-`Canceled`) row per -//! tenant (the `uq_subscriptions_live` partial unique index); the free trial is -//! itself a subscription row. Owned by `SubscriptionService`; **no other service -//! touches this table**. +//! The **subscription** row — the provider-agnostic license that *grants* a +//! tenant's [`Entitlement`]. 1:N history with at most one live (non-`Canceled`) row +//! per tenant (the `uq_subscriptions_live` partial unique index); the free trial is +//! itself a subscription row. Owned by [`SubscriptionService`](crate::service); +//! **no other aggregate touches this table** — Billing reaches it only through the +//! [`SubscriptionCommands`](wardnet_common::ports::SubscriptionCommands) port. +//! +//! Payment-provider reference ids (Stripe customer/subscription/price) are **not** +//! here — they live in Billing's `billing_customers` table (ADR-0010). use async_trait::async_trait; use chrono::{DateTime, Utc}; @@ -10,35 +14,44 @@ use sqlx::types::Json; // `Entitlement` + `SubscriptionStatus` are part of the shared API contract; they // double as the DB-domain types here (their helpers travel with them). +use wardnet_common::contract::SubscriptionView; pub use wardnet_common::contract::{Entitlement, SubscriptionStatus}; +use wardnet_common::db::DbPools; -use crate::db::DbPools; - -/// A subscription row. +/// A subscription row (license-only). #[derive(Debug, Clone)] pub struct Subscription { pub id: String, pub tenant_id: String, pub status: SubscriptionStatus, pub entitlement: Entitlement, - pub stripe_customer_id: Option, - pub stripe_subscription_id: Option, - pub price_id: Option, pub trial_expires_at: Option>, pub current_period_end: Option>, pub created_at: DateTime, pub updated_at: DateTime, } +// Domain → contract conversion (orphan rule OK: `Subscription` is local here). +impl From for SubscriptionView { + fn from(s: Subscription) -> Self { + Self { + id: s.id, + status: s.status, + entitlement: s.entitlement, + trial_expires_at: s.trial_expires_at, + current_period_end: s.current_period_end, + created_at: s.created_at, + updated_at: s.updated_at, + } + } +} + #[derive(sqlx::FromRow)] struct SubscriptionRow { id: String, tenant_id: String, status: String, entitlement: Json, - stripe_customer_id: Option, - stripe_subscription_id: Option, - price_id: Option, trial_expires_at: Option>, current_period_end: Option>, created_at: DateTime, @@ -52,9 +65,6 @@ impl From for Subscription { tenant_id: r.tenant_id, status: SubscriptionStatus::from_db(&r.status), entitlement: r.entitlement.0, - stripe_customer_id: r.stripe_customer_id, - stripe_subscription_id: r.stripe_subscription_id, - price_id: r.price_id, trial_expires_at: r.trial_expires_at, current_period_end: r.current_period_end, created_at: r.created_at, @@ -63,10 +73,14 @@ impl From for Subscription { } } -const SUBSCRIPTION_COLS: &str = "id, tenant_id, status, entitlement, stripe_customer_id, \ - stripe_subscription_id, price_id, trial_expires_at, current_period_end, created_at, updated_at"; +const SUBSCRIPTION_COLS: &str = "id, tenant_id, status, entitlement, \ + trial_expires_at, current_period_end, created_at, updated_at"; + +const INSERT_SUBSCRIPTION: &str = "INSERT INTO subscriptions \ + (id, tenant_id, status, entitlement, trial_expires_at, current_period_end, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"; -/// Data access for the `subscriptions` table + the Stripe-webhook idempotency ledger. +/// Data access for the `subscriptions` table (license columns only). #[async_trait] pub trait SubscriptionRepository: Send + Sync { /// Insert `sub` as the tenant's trial **iff the tenant has no subscription rows @@ -78,24 +92,6 @@ pub trait SubscriptionRepository: Send + Sync { /// The tenant's current (single non-`Canceled`) subscription, if any. async fn find_current(&self, tenant_id: &str) -> anyhow::Result>; - /// The subscription carrying `stripe_subscription_id`, if any (webhook lookup). - async fn find_by_stripe_subscription_id( - &self, - stripe_subscription_id: &str, - ) -> anyhow::Result>; - - /// The most recent `stripe_customer_id` recorded for the tenant (across the whole - /// history), so a re-subscribe reuses the same Stripe Customer. - async fn latest_customer_id(&self, tenant_id: &str) -> anyhow::Result>; - - /// Stamp `stripe_customer_id` onto the tenant's live row (at checkout start). - /// Returns `false` if the tenant has no live subscription. - async fn stamp_customer_id( - &self, - tenant_id: &str, - stripe_customer_id: &str, - ) -> anyhow::Result; - /// Convert: in one transaction, cancel the tenant's live row (the trial) and /// insert `paid` as the new live row. The cancel-before-insert order satisfies /// `uq_subscriptions_live`. @@ -105,16 +101,20 @@ pub trait SubscriptionRepository: Send + Sync { paid: &Subscription, ) -> anyhow::Result<()>; - /// Patch the row carrying `stripe_subscription_id` from a Stripe update. Returns - /// `false` if no such row. - async fn update_from_stripe( + /// Patch the tenant's live row to a provider update (status + entitlement + + /// period). Returns `false` if the tenant has no live row. + async fn update_current( &self, - stripe_subscription_id: &str, + tenant_id: &str, status: SubscriptionStatus, entitlement: Entitlement, current_period_end: Option>, ) -> anyhow::Result; + /// Flag the tenant's live row `past_due`, preserving entitlement + period. + /// Returns `false` if the tenant has no live row. + async fn mark_past_due_current(&self, tenant_id: &str) -> anyhow::Result; + /// Cancel the tenant's current subscription. Returns `true` if one was canceled. async fn cancel_current(&self, tenant_id: &str) -> anyhow::Result; @@ -126,15 +126,6 @@ pub trait SubscriptionRepository: Send + Sync { trial_cutoff: DateTime, payment_cutoff: DateTime, ) -> anyhow::Result>; - - /// Whether a Stripe event id has already been processed (the fast-path dedupe - /// read). Checked **before** applying; the id is recorded only **after** a - /// successful apply, so a failed apply leaves it un-recorded and Stripe's retry - /// re-applies it. - async fn is_event_processed(&self, event_id: &str) -> anyhow::Result; - - /// Record a processed Stripe event id (after a successful apply). Idempotent. - async fn record_event(&self, event_id: &str, now: DateTime) -> anyhow::Result<()>; } /// `PostgreSQL`-backed [`SubscriptionRepository`]. @@ -156,11 +147,6 @@ impl PgSubscriptionRepository { } } -const INSERT_SUBSCRIPTION: &str = "INSERT INTO subscriptions \ - (id, tenant_id, status, entitlement, stripe_customer_id, stripe_subscription_id, \ - price_id, trial_expires_at, current_period_end, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"; - #[async_trait] impl SubscriptionRepository for PgSubscriptionRepository { async fn create_trial(&self, sub: &Subscription) -> anyhow::Result { @@ -169,9 +155,9 @@ impl SubscriptionRepository for PgSubscriptionRepository { // trial). The `uq_subscriptions_live` index is a second guard against a race. let affected = sqlx::query( "INSERT INTO subscriptions \ - (id, tenant_id, status, entitlement, stripe_customer_id, stripe_subscription_id, \ - price_id, trial_expires_at, current_period_end, created_at, updated_at) \ - SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 \ + (id, tenant_id, status, entitlement, trial_expires_at, current_period_end, \ + created_at, updated_at) \ + SELECT $1, $2, $3, $4, $5, $6, $7, $8 \ WHERE NOT EXISTS (SELECT 1 FROM subscriptions WHERE tenant_id = $2) \ ON CONFLICT (tenant_id) WHERE status <> 'canceled' DO NOTHING", ) @@ -179,9 +165,6 @@ impl SubscriptionRepository for PgSubscriptionRepository { .bind(&sub.tenant_id) .bind(sub.status.as_str()) .bind(Json(sub.entitlement)) - .bind(&sub.stripe_customer_id) - .bind(&sub.stripe_subscription_id) - .bind(&sub.price_id) .bind(sub.trial_expires_at) .bind(sub.current_period_end) .bind(sub.created_at) @@ -203,48 +186,6 @@ impl SubscriptionRepository for PgSubscriptionRepository { Ok(row.map(Into::into)) } - async fn find_by_stripe_subscription_id( - &self, - stripe_subscription_id: &str, - ) -> anyhow::Result> { - let row = sqlx::query_as::<_, SubscriptionRow>(sqlx::AssertSqlSafe(format!( - "SELECT {SUBSCRIPTION_COLS} FROM subscriptions WHERE stripe_subscription_id = $1" - ))) - .bind(stripe_subscription_id) - .fetch_optional(&self.pools.read) - .await?; - Ok(row.map(Into::into)) - } - - async fn latest_customer_id(&self, tenant_id: &str) -> anyhow::Result> { - let id: Option = sqlx::query_scalar( - "SELECT stripe_customer_id FROM subscriptions \ - WHERE tenant_id = $1 AND stripe_customer_id IS NOT NULL \ - ORDER BY created_at DESC LIMIT 1", - ) - .bind(tenant_id) - .fetch_optional(&self.pools.read) - .await?; - Ok(id) - } - - async fn stamp_customer_id( - &self, - tenant_id: &str, - stripe_customer_id: &str, - ) -> anyhow::Result { - let affected = sqlx::query( - "UPDATE subscriptions SET stripe_customer_id = $2, updated_at = now() \ - WHERE tenant_id = $1 AND status <> 'canceled'", - ) - .bind(tenant_id) - .bind(stripe_customer_id) - .execute(&self.pools.write) - .await? - .rows_affected(); - Ok(affected > 0) - } - async fn convert_trial_to_paid( &self, tenant_id: &str, @@ -265,9 +206,6 @@ impl SubscriptionRepository for PgSubscriptionRepository { .bind(&paid.tenant_id) .bind(paid.status.as_str()) .bind(Json(paid.entitlement)) - .bind(&paid.stripe_customer_id) - .bind(&paid.stripe_subscription_id) - .bind(&paid.price_id) .bind(paid.trial_expires_at) .bind(paid.current_period_end) .bind(paid.created_at) @@ -278,9 +216,9 @@ impl SubscriptionRepository for PgSubscriptionRepository { Ok(()) } - async fn update_from_stripe( + async fn update_current( &self, - stripe_subscription_id: &str, + tenant_id: &str, status: SubscriptionStatus, entitlement: Entitlement, current_period_end: Option>, @@ -288,9 +226,9 @@ impl SubscriptionRepository for PgSubscriptionRepository { let affected = sqlx::query( "UPDATE subscriptions \ SET status = $2, entitlement = $3, current_period_end = $4, updated_at = now() \ - WHERE stripe_subscription_id = $1", + WHERE tenant_id = $1 AND status <> 'canceled'", ) - .bind(stripe_subscription_id) + .bind(tenant_id) .bind(status.as_str()) .bind(Json(entitlement)) .bind(current_period_end) @@ -300,6 +238,18 @@ impl SubscriptionRepository for PgSubscriptionRepository { Ok(affected > 0) } + async fn mark_past_due_current(&self, tenant_id: &str) -> anyhow::Result { + let affected = sqlx::query( + "UPDATE subscriptions SET status = 'past_due', updated_at = now() \ + WHERE tenant_id = $1 AND status <> 'canceled'", + ) + .bind(tenant_id) + .execute(&self.pools.write) + .await? + .rows_affected(); + Ok(affected > 0) + } + async fn cancel_current(&self, tenant_id: &str) -> anyhow::Result { let affected = sqlx::query( "UPDATE subscriptions SET status = 'canceled', updated_at = now() \ @@ -328,26 +278,4 @@ impl SubscriptionRepository for PgSubscriptionRepository { .await?; Ok(ids) } - - async fn is_event_processed(&self, event_id: &str) -> anyhow::Result { - let exists: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM processed_stripe_events WHERE event_id = $1)", - ) - .bind(event_id) - .fetch_one(&self.pools.read) - .await?; - Ok(exists) - } - - async fn record_event(&self, event_id: &str, now: DateTime) -> anyhow::Result<()> { - sqlx::query( - "INSERT INTO processed_stripe_events (event_id, processed_at) VALUES ($1, $2) \ - ON CONFLICT (event_id) DO NOTHING", - ) - .bind(event_id) - .bind(now) - .execute(&self.pools.write) - .await?; - Ok(()) - } } diff --git a/source/crates/subscriptions/src/service.rs b/source/crates/subscriptions/src/service.rs new file mode 100644 index 0000000..b2108b1 --- /dev/null +++ b/source/crates/subscriptions/src/service.rs @@ -0,0 +1,236 @@ +//! `SubscriptionService` — owns the **license** rules over the subscription +//! aggregate: opening the free trial, resolving the current subscription, cancelling +//! (with the network-deprovision cascade signalled by an event), expiring overdue +//! trials / past-due subscriptions, and the grace-aware entitlement check. +//! +//! It depends only on its own [`SubscriptionRepository`] and the +//! [`EventBus`] — **never** another aggregate's repository, and **never** a payment +//! provider. Cross-aggregate side-effects flow out as [`DomainEvent`]s; the inbound +//! Billing → Subscription edge arrives through the +//! [`SubscriptionCommands`] port impl below. + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use uuid::Uuid; + +use wardnet_common::contract::{Entitlement, SubscriptionStatus, SubscriptionView}; +use wardnet_common::event::{DomainEvent, EventBus}; +use wardnet_common::ports::{SubscriptionCommands, SubscriptionReader}; + +use crate::error::SubscriptionError; +use crate::repository::{Subscription, SubscriptionRepository}; + +/// Trial / grace policy, sourced from the service config. +#[derive(Debug, Clone, Copy)] +pub struct TrialPolicy { + /// Free-trial length (days) applied at trial creation. + pub trial_days: i64, + /// Extra days a lapsed trial keeps service before the reaper cancels it. + pub trial_grace_days: i64, + /// Extra days a `past_due` subscription keeps service before the reaper cancels it. + pub payment_grace_days: i64, +} + +/// The license business-rule layer. +pub struct SubscriptionService { + subscriptions: Arc, + events: Arc, + policy: TrialPolicy, +} + +impl SubscriptionService { + #[must_use] + pub fn new( + subscriptions: Arc, + events: Arc, + policy: TrialPolicy, + ) -> Self { + Self { + subscriptions, + events, + policy, + } + } + + /// Open the tenant's free **trial** subscription. Idempotent and safe to call for + /// any tenant: the insert only lands when the tenant has *no* subscription history + /// (so a replayed `TenantCreated` is a no-op, and a tenant whose trial already + /// lapsed is never given a fresh one). Returns whether a trial was created. + /// + /// # Errors + /// [`SubscriptionError::Internal`] on a repository failure. + pub async fn create_trial(&self, tenant_id: &str) -> Result { + let now = Utc::now(); + let sub = Subscription { + id: Uuid::new_v4().to_string(), + tenant_id: tenant_id.to_string(), + status: SubscriptionStatus::Trialing, + entitlement: Entitlement::DEFAULT, + trial_expires_at: Some(now + Duration::days(self.policy.trial_days)), + current_period_end: None, + created_at: now, + updated_at: now, + }; + let created = self.subscriptions.create_trial(&sub).await?; + if created { + tracing::info!(tenant_id, "opened trial subscription"); + } + Ok(created) + } + + /// The tenant's current (single non-`Canceled`) subscription, if any. + /// + /// # Errors + /// [`SubscriptionError::Internal`] on a repository failure. + pub async fn current( + &self, + tenant_id: &str, + ) -> Result, SubscriptionError> { + Ok(self.subscriptions.find_current(tenant_id).await?) + } + + /// Cancel the tenant's current subscription and, if one was actually cancelled, + /// publish [`DomainEvent::SubscriptionDeactivated`] so the network reactor + /// deprovisions the tenant's networks. Idempotent — the single cancel path. + /// + /// # Errors + /// [`SubscriptionError::Internal`] on a repository failure. + pub async fn cancel(&self, tenant_id: &str) -> Result<(), SubscriptionError> { + if self.subscriptions.cancel_current(tenant_id).await? { + tracing::info!(tenant_id, "subscription cancelled"); + // Best-effort publish: the cancel is already committed, so a transport + // failure must not fail the call — the network reactor's deprovision is + // re-driven by the periodic reconcile (ADR-0007/0010). + let event = DomainEvent::SubscriptionDeactivated { + tenant_id: tenant_id.to_string(), + }; + if let Err(e) = self.events.publish(&event).await { + tracing::error!(error = %e, tenant_id, "failed to publish SubscriptionDeactivated; reconcile is the safety net"); + } + } + Ok(()) + } + + /// Cancel every overdue subscription — a `trialing` row past + /// `trial_expires_at + trial_grace`, or a `past_due` row past + /// `current_period_end + payment_grace`. Each cancel cascades via its event. + /// Driven by the periodic reaper loop. Returns the number cancelled. + /// + /// # Errors + /// [`SubscriptionError::Internal`] on a repository failure. + pub async fn expire_overdue(&self) -> Result { + let now = Utc::now(); + let trial_cutoff = now - Duration::days(self.policy.trial_grace_days); + let payment_cutoff = now - Duration::days(self.policy.payment_grace_days); + let tenant_ids = self + .subscriptions + .list_overdue(trial_cutoff, payment_cutoff) + .await?; + let n = tenant_ids.len() as u64; + for tenant_id in tenant_ids { + self.cancel(&tenant_id).await?; + } + if n > 0 { + tracing::info!(count = n, "expired overdue subscriptions"); + } + Ok(n) + } + + /// Whether `sub` currently entitles its tenant to service, accounting for the + /// trial / payment grace windows. The grace-aware check `mint_jwt` and + /// `register_network` use via the [`SubscriptionReader`] port. + #[must_use] + pub fn is_active(&self, sub: &Subscription, now: DateTime) -> bool { + match sub.status { + SubscriptionStatus::Active => true, + SubscriptionStatus::Trialing => sub + .trial_expires_at + .is_some_and(|t| now < t + Duration::days(self.policy.trial_grace_days)), + SubscriptionStatus::PastDue => sub + .current_period_end + .is_some_and(|c| now < c + Duration::days(self.policy.payment_grace_days)), + SubscriptionStatus::Canceled => false, + } + } +} + +// ── Inter-aggregate ports (in-process adapter = direct calls) ──────────────────── + +#[async_trait] +impl SubscriptionReader for SubscriptionService { + async fn current(&self, tenant_id: &str) -> anyhow::Result> { + Ok(self + .subscriptions + .find_current(tenant_id) + .await? + .map(Into::into)) + } + + async fn is_active(&self, tenant_id: &str) -> anyhow::Result { + let now = Utc::now(); + Ok(self + .subscriptions + .find_current(tenant_id) + .await? + .is_some_and(|sub| SubscriptionService::is_active(self, &sub, now))) + } +} + +#[async_trait] +impl SubscriptionCommands for SubscriptionService { + async fn convert_trial_to_paid( + &self, + tenant_id: &str, + status: SubscriptionStatus, + entitlement: Entitlement, + current_period_end: Option>, + ) -> anyhow::Result<()> { + let now = Utc::now(); + let paid = Subscription { + id: Uuid::new_v4().to_string(), + tenant_id: tenant_id.to_string(), + // Carry the provider-reported status (Active / PastDue), not a hardcoded + // Active — a past-due initial subscription must not be granted full service. + status, + entitlement, + trial_expires_at: None, + current_period_end, + created_at: now, + updated_at: now, + }; + self.subscriptions + .convert_trial_to_paid(tenant_id, &paid) + .await?; + tracing::info!(tenant_id, "converted to paid subscription"); + Ok(()) + } + + async fn update_paid( + &self, + tenant_id: &str, + status: SubscriptionStatus, + entitlement: Entitlement, + current_period_end: Option>, + ) -> anyhow::Result { + Ok(self + .subscriptions + .update_current(tenant_id, status, entitlement, current_period_end) + .await?) + } + + async fn mark_past_due(&self, tenant_id: &str) -> anyhow::Result { + Ok(self.subscriptions.mark_past_due_current(tenant_id).await?) + } + + async fn cancel(&self, tenant_id: &str) -> anyhow::Result<()> { + // The inherent `cancel` (publishes SubscriptionDeactivated) is the single + // cancel path; the port just exposes it across the crate boundary. + SubscriptionService::cancel(self, tenant_id).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/source/crates/subscriptions/src/service/tests.rs b/source/crates/subscriptions/src/service/tests.rs new file mode 100644 index 0000000..58811df --- /dev/null +++ b/source/crates/subscriptions/src/service/tests.rs @@ -0,0 +1,347 @@ +//! Unit tests for [`SubscriptionService`] over a small in-memory mock repository + +//! a recording [`EventBus`]. License logic only (trial creation, grace, the reaper, +//! cancel); the Stripe-driven lifecycle is exercised in the `billing` aggregate and +//! the composition-root webhook integration tests, not here. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; + +use wardnet_common::contract::{Entitlement, SubscriptionStatus}; +use wardnet_common::event::{DomainEvent, EventBus, EventStream}; + +use crate::repository::{Subscription, SubscriptionRepository}; +use crate::service::{SubscriptionService, TrialPolicy}; + +const POLICY: TrialPolicy = TrialPolicy { + trial_days: 60, + trial_grace_days: 15, + payment_grace_days: 15, +}; + +// ── In-memory mock repository ───────────────────────────────────────────────────── + +/// A minimal in-memory [`SubscriptionRepository`] mirroring the SQL guards the live +/// loops depend on (one live row per tenant; the trial insert is skipped when any +/// history exists). +#[derive(Default)] +struct MockRepo(Mutex>); + +impl MockRepo { + fn new() -> Self { + Self(Mutex::new(Vec::new())) + } + + /// Seed a subscription row directly. + fn seed(&self, sub: Subscription) { + self.0.lock().unwrap().push(sub); + } + + /// Number of rows stored for a tenant (history included). + fn subscription_count(&self, tenant_id: &str) -> usize { + self.0 + .lock() + .unwrap() + .iter() + .filter(|s| s.tenant_id == tenant_id) + .count() + } +} + +#[async_trait] +impl SubscriptionRepository for MockRepo { + async fn create_trial(&self, sub: &Subscription) -> anyhow::Result { + let mut rows = self.0.lock().unwrap(); + if rows.iter().any(|s| s.tenant_id == sub.tenant_id) { + return Ok(false); + } + rows.push(sub.clone()); + Ok(true) + } + + async fn find_current(&self, tenant_id: &str) -> anyhow::Result> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .find(|s| s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled) + .cloned()) + } + + async fn convert_trial_to_paid( + &self, + tenant_id: &str, + paid: &Subscription, + ) -> anyhow::Result<()> { + let mut rows = self.0.lock().unwrap(); + for s in rows.iter_mut() { + if s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled { + s.status = SubscriptionStatus::Canceled; + } + } + rows.push(paid.clone()); + Ok(()) + } + + async fn update_current( + &self, + tenant_id: &str, + status: SubscriptionStatus, + entitlement: Entitlement, + current_period_end: Option>, + ) -> anyhow::Result { + let mut rows = self.0.lock().unwrap(); + if let Some(s) = rows + .iter_mut() + .find(|s| s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled) + { + s.status = status; + s.entitlement = entitlement; + s.current_period_end = current_period_end; + Ok(true) + } else { + Ok(false) + } + } + + async fn mark_past_due_current(&self, tenant_id: &str) -> anyhow::Result { + let mut rows = self.0.lock().unwrap(); + if let Some(s) = rows + .iter_mut() + .find(|s| s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled) + { + s.status = SubscriptionStatus::PastDue; + Ok(true) + } else { + Ok(false) + } + } + + async fn cancel_current(&self, tenant_id: &str) -> anyhow::Result { + let mut rows = self.0.lock().unwrap(); + if let Some(s) = rows + .iter_mut() + .find(|s| s.tenant_id == tenant_id && s.status != SubscriptionStatus::Canceled) + { + s.status = SubscriptionStatus::Canceled; + Ok(true) + } else { + Ok(false) + } + } + + async fn list_overdue( + &self, + trial_cutoff: DateTime, + payment_cutoff: DateTime, + ) -> anyhow::Result> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .filter(|s| match s.status { + SubscriptionStatus::Trialing => { + s.trial_expires_at.is_some_and(|t| t < trial_cutoff) + } + SubscriptionStatus::PastDue => { + s.current_period_end.is_some_and(|c| c < payment_cutoff) + } + _ => false, + }) + .map(|s| s.tenant_id.clone()) + .collect()) + } +} + +// ── Recording event bus ─────────────────────────────────────────────────────────── + +/// An [`EventBus`] that records every published event for assertions. `subscribe` is +/// never exercised by these synchronous tests. +#[derive(Default)] +struct RecordingBus(Mutex>); + +impl RecordingBus { + fn new() -> Self { + Self(Mutex::new(Vec::new())) + } + + fn published(&self) -> Vec { + self.0.lock().unwrap().clone() + } +} + +#[async_trait] +impl EventBus for RecordingBus { + async fn publish(&self, event: &DomainEvent) -> anyhow::Result<()> { + self.0.lock().unwrap().push(event.clone()); + Ok(()) + } + + async fn subscribe(&self, _group: &str) -> anyhow::Result> { + unreachable!("these tests never subscribe") + } +} + +// ── Fixtures ────────────────────────────────────────────────────────────────────── + +fn service() -> (Arc, Arc, Arc) { + let repo = Arc::new(MockRepo::new()); + let events = Arc::new(RecordingBus::new()); + let svc = Arc::new(SubscriptionService::new( + Arc::clone(&repo) as Arc, + Arc::clone(&events) as Arc, + POLICY, + )); + (svc, repo, events) +} + +/// A subscription row with the given status + default timestamps. +fn sub(tenant_id: &str, status: SubscriptionStatus) -> Subscription { + let now = Utc::now(); + Subscription { + id: format!("sub-{tenant_id}"), + tenant_id: tenant_id.to_string(), + status, + entitlement: Entitlement::DEFAULT, + trial_expires_at: None, + current_period_end: None, + created_at: now, + updated_at: now, + } +} + +// ── Trial creation ───────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn create_trial_opens_one_trial_and_is_idempotent() { + let (svc, store, _events) = service(); + assert!(svc.create_trial("t1").await.unwrap()); + assert_eq!(store.subscription_count("t1"), 1); + let current = svc.current("t1").await.unwrap().unwrap(); + assert_eq!(current.status, SubscriptionStatus::Trialing); + assert!(current.trial_expires_at.is_some()); + + // A second call (replayed TenantCreated) does not open a second trial. + assert!(!svc.create_trial("t1").await.unwrap()); + assert_eq!(store.subscription_count("t1"), 1); +} + +#[tokio::test] +async fn create_trial_does_not_resurrect_a_reaped_trial() { + let (svc, store, _events) = service(); + svc.create_trial("t1").await.unwrap(); + svc.cancel("t1").await.unwrap(); + // The tenant now has a canceled row but no live one. + assert!(svc.current("t1").await.unwrap().is_none()); + // create_trial must not open a fresh trial (history exists). + assert!(!svc.create_trial("t1").await.unwrap()); + assert!(svc.current("t1").await.unwrap().is_none()); + assert_eq!(store.subscription_count("t1"), 1); +} + +// ── Cancel + reaper ──────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn cancel_publishes_deactivation_and_is_idempotent() { + let (svc, _store, events) = service(); + svc.create_trial("t1").await.unwrap(); + + svc.cancel("t1").await.unwrap(); + assert!(svc.current("t1").await.unwrap().is_none()); + assert!( + events + .published() + .contains(&DomainEvent::SubscriptionDeactivated { + tenant_id: "t1".to_string() + }) + ); + + // A second cancel (no live row) publishes nothing further. + let before = events.published().len(); + svc.cancel("t1").await.unwrap(); + assert_eq!(events.published().len(), before); +} + +#[tokio::test] +async fn expire_overdue_cancels_expired_trials_and_past_due() { + let (svc, store, events) = service(); + let now = Utc::now(); + + // A trial that lapsed past its 15-day grace. + let mut expired_trial = sub("t-trial", SubscriptionStatus::Trialing); + expired_trial.trial_expires_at = Some(now - Duration::days(20)); + store.seed(expired_trial); + + // A trial still within grace — must survive. + let mut fresh_trial = sub("t-fresh", SubscriptionStatus::Trialing); + fresh_trial.trial_expires_at = Some(now - Duration::days(2)); + store.seed(fresh_trial); + + // A past-due subscription past its payment grace. + let mut overdue_paid = sub("t-paid", SubscriptionStatus::PastDue); + overdue_paid.current_period_end = Some(now - Duration::days(20)); + store.seed(overdue_paid); + + let n = svc.expire_overdue().await.unwrap(); + assert_eq!(n, 2); + assert!(svc.current("t-trial").await.unwrap().is_none()); + assert!(svc.current("t-paid").await.unwrap().is_none()); + assert!(svc.current("t-fresh").await.unwrap().is_some()); + + // Each cancellation cascaded via an event. + let deactivations = events + .published() + .iter() + .filter(|e| matches!(e, DomainEvent::SubscriptionDeactivated { .. })) + .count(); + assert_eq!(deactivations, 2); +} + +// ── Grace-aware entitlement ───────────────────────────────────────────────────────── + +#[test] +fn is_active_respects_status_and_grace() { + let (svc, _store, _events) = service(); + let now = Utc::now(); + + // Active is always entitling. + assert!(svc.is_active(&sub("t", SubscriptionStatus::Active), now)); + + // Trialing: within trial_expires_at + grace true, past it false. + let mut trial = sub("t", SubscriptionStatus::Trialing); + trial.trial_expires_at = Some(now - Duration::days(10)); // 10d < 15d grace + assert!(svc.is_active(&trial, now)); + trial.trial_expires_at = Some(now - Duration::days(20)); // past grace + assert!(!svc.is_active(&trial, now)); + + // Past-due: within current_period_end + grace true, past it false. + let mut paid = sub("t", SubscriptionStatus::PastDue); + paid.current_period_end = Some(now - Duration::days(10)); + assert!(svc.is_active(&paid, now)); + paid.current_period_end = Some(now - Duration::days(20)); + assert!(!svc.is_active(&paid, now)); + + // Canceled is never entitling. + assert!(!svc.is_active(&sub("t", SubscriptionStatus::Canceled), now)); +} + +#[test] +fn is_active_grace_boundary_is_exclusive() { + // The grace check is `now < expiry + grace` (strict): at the exact cutoff the + // subscription is already inactive. Pins the operator so a `<` → `<=` regression + // (a free extra moment of service past grace) is caught. + let (svc, _store, _events) = service(); + let now = Utc::now(); + + let mut trial = sub("t", SubscriptionStatus::Trialing); + // One second inside the 15-day grace window → still active. + trial.trial_expires_at = + Some(now - Duration::days(POLICY.trial_grace_days) + Duration::seconds(1)); + assert!(svc.is_active(&trial, now)); + // Exactly at the cutoff (now == expiry + grace) → no longer active. + trial.trial_expires_at = Some(now - Duration::days(POLICY.trial_grace_days)); + assert!(!svc.is_active(&trial, now)); +} diff --git a/source/crates/tenants/Cargo.toml b/source/crates/tenants/Cargo.toml index 1883d5a..9599da3 100644 --- a/source/crates/tenants/Cargo.toml +++ b/source/crates/tenants/Cargo.toml @@ -10,10 +10,6 @@ description = "Wardnet Tenants service — global identity/naming authority + me name = "wardnet_tenants" path = "src/lib.rs" -[[bin]] -name = "wardnet-tenants" -path = "src/main.rs" - [dependencies] wardnet_common = { workspace = true } diff --git a/source/crates/tenants/Dockerfile b/source/crates/tenants/Dockerfile index c72faa5..d02062d 100644 --- a/source/crates/tenants/Dockerfile +++ b/source/crates/tenants/Dockerfile @@ -5,7 +5,7 @@ FROM rust:1-bookworm AS build WORKDIR /src COPY . . -RUN cargo build --release -p wardnet-tenants +RUN cargo build --release -p wardnet-tenants-bin FROM debian:bookworm-slim AS runtime RUN apt-get update \ diff --git a/source/crates/tenants/src/api/billing.rs b/source/crates/tenants/src/api/billing.rs index c8d8920..dc60a7d 100644 --- a/source/crates/tenants/src/api/billing.rs +++ b/source/crates/tenants/src/api/billing.rs @@ -44,9 +44,6 @@ async fn stripe_webhook( .get("Stripe-Signature") .and_then(|v| v.to_str().ok()) .ok_or_else(|| ApiError::BadRequest("missing Stripe-Signature header".to_string()))?; - state - .subscriptions() - .handle_webhook(&body, signature) - .await?; + state.billing().handle_webhook(&body, signature).await?; Ok(StatusCode::OK) } diff --git a/source/crates/tenants/src/api/tenant.rs b/source/crates/tenants/src/api/tenant.rs index 72f7765..904e006 100644 --- a/source/crates/tenants/src/api/tenant.rs +++ b/source/crates/tenants/src/api/tenant.rs @@ -32,10 +32,15 @@ async fn get_tenant( State(state): State, Path(id): Path, ) -> Result, ApiError> { - let (tenant, subscription) = state + let tenant = state .tenants() .find_tenant(&id) .await? .ok_or_else(|| ApiError::NotFound("no such tenant".to_string()))?; + let subscription = state + .subscriptions() + .current(&id) + .await + .map_err(ApiError::Internal)?; Ok(Json(crate::api::tenants::tenant_view(tenant, subscription))) } diff --git a/source/crates/tenants/src/api/tenants.rs b/source/crates/tenants/src/api/tenants.rs index 35f1041..6599568 100644 --- a/source/crates/tenants/src/api/tenants.rs +++ b/source/crates/tenants/src/api/tenants.rs @@ -18,7 +18,7 @@ use wardnet_common::contract::{ }; use crate::error::ApiError; -use crate::repository::{Daemon, Subscription, Tenant}; +use crate::repository::{Daemon, Tenant}; use crate::state::AppState; /// Register all account-plane routes. @@ -36,30 +36,17 @@ pub fn register(router: OpenApiRouter) -> OpenApiRouter { } // ── Domain → contract conversions (orphan rule OK: the domain type is local) ─── +// +// `From for SubscriptionView` now lives in the `subscriptions` crate +// (it owns `Subscription`); handlers receive the `SubscriptionView` straight from the +// `SubscriptionReader` port. -impl From for SubscriptionView { - fn from(s: Subscription) -> Self { - Self { - id: s.id, - status: s.status, - entitlement: s.entitlement, - stripe_customer_id: s.stripe_customer_id, - stripe_subscription_id: s.stripe_subscription_id, - price_id: s.price_id, - trial_expires_at: s.trial_expires_at, - current_period_end: s.current_period_end, - created_at: s.created_at, - updated_at: s.updated_at, - } - } -} - -/// Build the full [`TenantView`] from a tenant and its current subscription. -pub(crate) fn tenant_view(tenant: Tenant, subscription: Option) -> TenantView { +/// Build the full [`TenantView`] from a tenant and its current subscription view. +pub(crate) fn tenant_view(tenant: Tenant, subscription: Option) -> TenantView { TenantView { id: tenant.id, email: tenant.email, - subscription: subscription.map(Into::into), + subscription, created_at: tenant.created_at, } } @@ -104,15 +91,20 @@ async fn me( let Caller::User(user) = &caller else { return Err(ApiError::Forbidden("user credential required".to_string())); }; - let (tenant, subscription) = state + let tenant = state .tenants() .find_tenant(&user.tenant_id) .await? .ok_or_else(|| ApiError::NotFound("no such tenant".to_string()))?; + let subscription = state + .subscriptions() + .current(&user.tenant_id) + .await + .map_err(ApiError::Internal)?; Ok(Json(MeView { tenant_id: tenant.id, email: tenant.email, - subscription: subscription.map(Into::into), + subscription, })) } @@ -212,7 +204,11 @@ async fn update_tenant( } // Cancelling deactivates the subscription; the network-deprovision cascade follows // from the published `SubscriptionDeactivated` event (network reactor). - state.subscriptions().cancel(&id).await?; + state + .subscription_commands() + .cancel(&id) + .await + .map_err(ApiError::Internal)?; Ok(StatusCode::NO_CONTENT) } @@ -277,13 +273,15 @@ async fn create_checkout_session( Json(body): Json, ) -> Result, ApiError> { require_owner(&caller, &id)?; - let (tenant, _) = state + // Cross-aggregate orchestration: read the tenant email here, then drive Billing + // through its port (Billing never depends on the tenant aggregate). + let tenant = state .tenants() .find_tenant(&id) .await? .ok_or_else(|| ApiError::NotFound("no such tenant".to_string()))?; let url = state - .subscriptions() + .billing() .start_checkout(&id, &tenant.email, &body.price_id) .await?; Ok(Json(CheckoutSessionResponse { url })) @@ -305,6 +303,6 @@ async fn billing_portal( Path(id): Path, ) -> Result, ApiError> { require_owner(&caller, &id)?; - let url = state.subscriptions().billing_portal(&id).await?; + let url = state.billing().billing_portal(&id).await?; Ok(Json(BillingPortalResponse { url })) } diff --git a/source/crates/tenants/src/db.rs b/source/crates/tenants/src/db.rs index 3748079..c3a7f87 100644 --- a/source/crates/tenants/src/db.rs +++ b/source/crates/tenants/src/db.rs @@ -1,18 +1,13 @@ -//! Database initialisation for the Tenants bin. +//! Database plumbing for the Tenants aggregate. //! -//! The pool plumbing ([`DbPools`], `connect`) lives in [`wardnet_common::db`]. -//! Tenants owns the single global DB; migrations stay here because `sqlx::migrate!` -//! resolves its directory at compile time relative to this crate. +//! The pool plumbing ([`DbPools`], `connect`) lives in [`wardnet_common::db`]. This +//! crate owns the tenant/identity schema; its migration set is exposed as [`MIGRATOR`] +//! and composed with the other aggregates' migrators by the binary's `db::init` +//! (against the single shared DB — ADR-0010). `sqlx::migrate!` resolves the directory +//! at compile time relative to this crate. pub use wardnet_common::db::DbPools; -/// Initialise the global pool and run its pending migrations. -/// -/// # Errors -/// Returns an error if the pool cannot be established or a migration fails. -pub async fn init(global_database_url: &str) -> anyhow::Result { - let pool = wardnet_common::db::connect(global_database_url).await?; - sqlx::migrate!("./migrations").run(&pool).await?; - tracing::info!("global database initialised"); - Ok(DbPools::single(pool)) -} +/// The tenant/identity schema migration set (`init` + `identities_sessions`). Composed +/// with the `subscriptions` + `billing` migrators by the composition root. +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); diff --git a/source/crates/tenants/src/error.rs b/source/crates/tenants/src/error.rs index 0226489..4988b89 100644 --- a/source/crates/tenants/src/error.rs +++ b/source/crates/tenants/src/error.rs @@ -112,40 +112,3 @@ impl From for IdentitiesError { } } } - -/// Service-layer domain error for -/// [`SubscriptionService`](crate::subscription::SubscriptionService). -#[derive(Debug, thiserror::Error)] -pub enum SubscriptionError { - /// A referenced tenant / subscription does not exist. - #[error("{0}")] - NotFound(String), - /// Malformed input (e.g. an unknown plan / price). - #[error("{0}")] - BadRequest(String), - /// A provider/repository failure (DB, Stripe). - #[error(transparent)] - Internal(#[from] anyhow::Error), -} - -impl From for ApiError { - fn from(e: SubscriptionError) -> Self { - match e { - SubscriptionError::NotFound(m) => ApiError::NotFound(m), - SubscriptionError::BadRequest(m) => ApiError::BadRequest(m), - SubscriptionError::Internal(e) => ApiError::Internal(e), - } - } -} - -/// Bridge so `TenantsService` can surface a `SubscriptionService` read failure as its -/// own error when it reads the current subscription on a hot path. -impl From for TenantsError { - fn from(e: SubscriptionError) -> Self { - match e { - SubscriptionError::NotFound(m) => TenantsError::NotFound(m), - SubscriptionError::BadRequest(m) => TenantsError::BadRequest(m), - SubscriptionError::Internal(e) => TenantsError::Internal(e), - } - } -} diff --git a/source/crates/tenants/src/identities.rs b/source/crates/tenants/src/identities.rs index 9a6bd4c..ef0c2f3 100644 --- a/source/crates/tenants/src/identities.rs +++ b/source/crates/tenants/src/identities.rs @@ -417,7 +417,16 @@ impl IdentitiesService { /// already present (a returning user). For a new method it runs gate 1 /// (`email_verified`), then gate 2 (match→auto-link / no-match→web-first signup via /// the tenant aggregate's `register_tenant`), and inserts the identity row. - async fn resolve_identity( + /// + /// # Errors + /// [`IdentitiesError::Unauthorized`] when gate 1 fails (unverified email); + /// [`IdentitiesError::Internal`] on a repository / tenant-aggregate failure. + /// + /// `pub` so the composition crate's integration tests can pin the two-gate branches + /// directly; `#[doc(hidden)]` because it is an internal step of the login flows + /// (`signup_with_password` / OIDC completion), not part of the intended public API. + #[doc(hidden)] + pub async fn resolve_identity( &self, verified: &VerifiedIdentity, secret_hash: Option, diff --git a/source/crates/tenants/src/identities/reactor.rs b/source/crates/tenants/src/identities/reactor.rs index 4f4fe5b..05af142 100644 --- a/source/crates/tenants/src/identities/reactor.rs +++ b/source/crates/tenants/src/identities/reactor.rs @@ -11,10 +11,7 @@ use std::sync::Arc; -use tokio::sync::broadcast::Receiver; -use tokio::sync::broadcast::error::RecvError; - -use wardnet_common::event::DomainEvent; +use wardnet_common::event::{DomainEvent, EventStream}; use crate::identities::IdentitiesService; @@ -34,18 +31,10 @@ pub async fn apply_to_identities(service: &IdentitiesService, event: &DomainEven /// `IdentitiesService → TenantsService`; this reverse side-effect flows as an event. pub async fn run_identities_reactor( service: Arc, - mut events: Receiver, + mut events: Box, ) { - loop { - match events.recv().await { - Ok(event) => apply_to_identities(&service, &event).await, - Err(RecvError::Lagged(skipped)) => { - tracing::warn!( - skipped, - "identities reactor lagged; FK cascade is the safety net" - ); - } - Err(RecvError::Closed) => break, - } + while let Some(delivery) = events.next().await { + apply_to_identities(&service, delivery.event()).await; + delivery.ack().await; } } diff --git a/source/crates/tenants/src/identities/tests.rs b/source/crates/tenants/src/identities/tests.rs index d53d8d6..f0222f2 100644 --- a/source/crates/tenants/src/identities/tests.rs +++ b/source/crates/tenants/src/identities/tests.rs @@ -1,33 +1,10 @@ -//! Unit tests for the Identities aggregate: argon2 round-trip, the two-gate -//! verified-email resolver, and the password / session flows over the shared -//! [`MockStore`]. +//! Unit tests for Identities internals that need access to private items. The +//! aggregate's public flows (resolve/password/session) are exercised end-to-end in +//! the composition crate's integration tests (`app-tenants/tests/identities.rs`), +//! which can wire the full `Harness`; only the argon2 primitives — private to this +//! module — stay here. -use chrono::Utc; - -use wardnet_common::token::Verifier; - -use super::provider::VerifiedIdentity; use super::{hash_password, verify_password}; -use crate::repository::tenant::Tenant; -use crate::test_helpers::{build_harness, jwt_keypair_pem}; - -const SEED: u8 = 5; - -/// A verifier over the harness's keypair, scoped to `tenants` (the USER JWT audience). -fn verifier() -> Verifier { - Verifier::from_pem(jwt_keypair_pem(SEED).1.as_bytes(), "tenants").unwrap() -} - -fn verified(provider: &str, subject: &str, email: &str, email_verified: bool) -> VerifiedIdentity { - VerifiedIdentity { - provider: provider.to_string(), - subject: subject.to_string(), - email: email.to_string(), - email_verified, - } -} - -// ── argon2 ─────────────────────────────────────────────────────────────────────── #[test] fn password_hash_round_trips() { @@ -38,291 +15,3 @@ fn password_hash_round_trips() { assert!(hash.starts_with("$argon2id$")); assert!(!hash.contains("correct horse battery")); } - -// ── resolve_identity: the two gates ──────────────────────────────────────────────── - -#[tokio::test] -async fn resolve_verified_no_match_creates_tenant() { - let h = build_harness(SEED); - let (tenant_id, existed) = h - .identities - .resolve_identity(&verified("google", "g-1", "New@Example.com", true), None) - .await - .unwrap(); - assert!(!existed); - // Web-first signup created the tenant (normalized email) + published TenantCreated. - let tenant = h.store.find_tenant(&tenant_id).unwrap(); - assert_eq!(tenant.email, "new@example.com"); -} - -#[tokio::test] -async fn resolve_verified_match_auto_links_existing_tenant() { - let h = build_harness(SEED); - // A daemon-born tenant already exists for this email. - h.store.seed_tenant(Tenant { - id: "tenant-daemon-born".to_string(), - email: "owner@example.com".to_string(), - created_at: Utc::now(), - deregistered_at: None, - }); - let (tenant_id, existed) = h - .identities - .resolve_identity(&verified("google", "g-9", "owner@example.com", true), None) - .await - .unwrap(); - assert!(!existed); - assert_eq!(tenant_id, "tenant-daemon-born"); -} - -#[tokio::test] -async fn resolve_returning_identity_is_existing() { - let h = build_harness(SEED); - let v = verified("google", "g-7", "repeat@example.com", true); - let (first, existed1) = h.identities.resolve_identity(&v, None).await.unwrap(); - assert!(!existed1); - let (second, existed2) = h.identities.resolve_identity(&v, None).await.unwrap(); - assert!(existed2); - assert_eq!(first, second); -} - -#[tokio::test] -async fn resolve_unverified_email_is_rejected() { - let h = build_harness(SEED); - let err = h - .identities - .resolve_identity(&verified("google", "g-2", "spoof@example.com", false), None) - .await - .unwrap_err(); - assert!(matches!( - err, - crate::error::IdentitiesError::Unauthorized(_) - )); - // No tenant was created behind the rejected gate. - assert!( - h.tenants - .find_tenant_by_email("spoof@example.com") - .await - .unwrap() - .is_none() - ); -} - -// ── Password flows ───────────────────────────────────────────────────────────────── - -/// Issue a real signup code through the tenant aggregate (the gate-1 primitive). -async fn signup_code(h: &crate::test_helpers::Harness, email: &str) -> String { - h.tenants - .issue_signup_code(email, "203.0.113.7") - .await - .unwrap() -} - -#[tokio::test] -async fn password_signup_then_login() { - let h = build_harness(SEED); - let code = signup_code(&h, "alice@example.com").await; - let session = h - .identities - .password_signup("alice@example.com", &code, "hunter2hunter2") - .await - .unwrap(); - assert!(!session.is_empty()); - - // The session exchanges to a USER JWT the verifier accepts (aud = [tenants]). - let jwt = h.identities.exchange_session(&session).await.unwrap(); - let claims = verifier().verify(&jwt).unwrap(); - assert_eq!(claims.aud, vec!["tenants".to_string()]); - assert_eq!(claims.tid, claims.sub); // User == Tenant 1:1 - - // And the password logs in. - let login = h - .identities - .password_login("alice@example.com", "hunter2hunter2", "203.0.113.1") - .await - .unwrap(); - assert!(!login.is_empty()); -} - -#[tokio::test] -async fn password_signup_rejects_bad_code() { - let h = build_harness(SEED); - let err = h - .identities - .password_signup("bob@example.com", "deadbeef", "longenough1") - .await - .unwrap_err(); - assert!(matches!(err, crate::error::IdentitiesError::BadCode(_))); -} - -#[tokio::test] -async fn password_signup_rejects_weak_password() { - let h = build_harness(SEED); - let code = signup_code(&h, "weak@example.com").await; - let err = h - .identities - .password_signup("weak@example.com", &code, "short") - .await - .unwrap_err(); - assert!(matches!(err, crate::error::IdentitiesError::BadRequest(_))); -} - -#[tokio::test] -async fn second_password_signup_for_same_email_conflicts() { - let h = build_harness(SEED); - let code1 = signup_code(&h, "dup@example.com").await; - h.identities - .password_signup("dup@example.com", &code1, "longenough1") - .await - .unwrap(); - let code2 = signup_code(&h, "dup@example.com").await; - let err = h - .identities - .password_signup("dup@example.com", &code2, "longenough2") - .await - .unwrap_err(); - assert!(matches!(err, crate::error::IdentitiesError::Conflict(_))); -} - -#[tokio::test] -async fn login_rejects_unknown_and_wrong_password() { - let h = build_harness(SEED); - let code = signup_code(&h, "carol@example.com").await; - h.identities - .password_signup("carol@example.com", &code, "rightpassword") - .await - .unwrap(); - - assert!(matches!( - h.identities - .password_login("carol@example.com", "wrongpassword", "203.0.113.2") - .await - .unwrap_err(), - crate::error::IdentitiesError::Unauthorized(_) - )); - assert!(matches!( - h.identities - .password_login("nobody@example.com", "whatever12", "203.0.113.3") - .await - .unwrap_err(), - crate::error::IdentitiesError::Unauthorized(_) - )); -} - -// ── Session lifecycle ────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn logout_invalidates_the_exchange() { - let h = build_harness(SEED); - let code = signup_code(&h, "dave@example.com").await; - let session = h - .identities - .password_signup("dave@example.com", &code, "longenough1") - .await - .unwrap(); - - assert!(h.identities.exchange_session(&session).await.is_ok()); - h.identities.logout(&session).await.unwrap(); - assert!(matches!( - h.identities.exchange_session(&session).await.unwrap_err(), - crate::error::IdentitiesError::Unauthorized(_) - )); -} - -#[tokio::test] -async fn purge_for_deletes_sessions_and_identities() { - let h = build_harness(SEED); - let code = signup_code(&h, "erin@example.com").await; - let session = h - .identities - .password_signup("erin@example.com", &code, "longenough1") - .await - .unwrap(); - let tenant = h - .tenants - .find_tenant_by_email("erin@example.com") - .await - .unwrap() - .unwrap(); - - h.identities.purge_for(&tenant.id).await.unwrap(); - // Session gone (exchange fails) and the password identity gone (login fails). - assert!(h.identities.exchange_session(&session).await.is_err()); - assert!( - h.identities - .password_login("erin@example.com", "longenough1", "203.0.113.4") - .await - .is_err() - ); -} - -#[tokio::test] -async fn deregistered_tenant_cannot_exchange_or_log_in() { - // Even if the identities reactor has NOT yet purged the session/identity (best-effort - // bus), a tombstoned tenant must not mint a USER JWT or open a new session. - let h = build_harness(SEED); - let code = signup_code(&h, "frank@example.com").await; - let session = h - .identities - .password_signup("frank@example.com", &code, "longenough1") - .await - .unwrap(); - let tenant = h - .tenants - .find_tenant_by_email("frank@example.com") - .await - .unwrap() - .unwrap(); - - // Tombstone the tenant WITHOUT running the identities reactor (rows still present). - assert!(h.tenants.deregister_tenant(&tenant.id).await.unwrap()); - - // The silent exchange refuses to mint for a tombstoned tenant (session-query guard). - assert!(matches!( - h.identities.exchange_session(&session).await.unwrap_err(), - crate::error::IdentitiesError::Unauthorized(_) - )); - // And a fresh login is refused at session creation (create_session liveness check). - assert!(matches!( - h.identities - .password_login("frank@example.com", "longenough1", "203.0.113.9") - .await - .unwrap_err(), - crate::error::IdentitiesError::Unauthorized(_) - )); -} - -#[tokio::test] -async fn password_login_is_rate_limited_per_ip() { - let h = build_harness(SEED); - let code = signup_code(&h, "grace@example.com").await; - h.identities - .password_signup("grace@example.com", &code, "longenough1") - .await - .unwrap(); - - // 10 wrong attempts from one IP are each Unauthorized; the 11th is RateLimited. - let ip = "198.51.100.7"; - for _ in 0..10 { - assert!(matches!( - h.identities - .password_login("grace@example.com", "wrongpassword", ip) - .await - .unwrap_err(), - crate::error::IdentitiesError::Unauthorized(_) - )); - } - assert!(matches!( - h.identities - .password_login("grace@example.com", "longenough1", ip) - .await - .unwrap_err(), - crate::error::IdentitiesError::RateLimited(_) - )); - // A different IP is unaffected (correct credentials still succeed). - assert!( - h.identities - .password_login("grace@example.com", "longenough1", "198.51.100.8") - .await - .is_ok() - ); -} diff --git a/source/crates/tenants/src/lib.rs b/source/crates/tenants/src/lib.rs index 462ff39..2b10522 100644 --- a/source/crates/tenants/src/lib.rs +++ b/source/crates/tenants/src/lib.rs @@ -18,16 +18,8 @@ pub mod email; pub mod error; pub mod identities; pub mod mesh; +pub mod reactor; pub mod repository; pub mod service; pub mod state; -pub mod stripe; -pub mod subscription; pub mod util; - -// Mocks + fixtures shared by unit and integration tests. Doc-hidden and not -// `cfg(test)` so the integration tests in `tests/` can reach it too; carries no -// extra production dependencies. (A dedicated `wardnet-test-support` crate is the -// eventual home — see PLAN-INITIATIVE follow-ups.) -#[doc(hidden)] -pub mod test_helpers; diff --git a/source/crates/tenants/src/reactor.rs b/source/crates/tenants/src/reactor.rs new file mode 100644 index 0000000..edb90d4 --- /dev/null +++ b/source/crates/tenants/src/reactor.rs @@ -0,0 +1,31 @@ +//! The **network** reactor — reacts to `SubscriptionDeactivated` by deprovisioning +//! the tenant's networks (`TenantsService` owns the network repository). Holds an +//! `Arc` (never a repository); the reaction is **idempotent** so a +//! redelivery is harmless and the periodic reconcile re-drives a dropped event. +//! Spawn it from the binary +//! (`tokio::spawn(run_network_reactor(svc, bus.subscribe(group).await?))`). + +use std::sync::Arc; + +use wardnet_common::event::{DomainEvent, EventStream}; + +use crate::service::TenantsService; + +/// Apply a single event to the network side of the tenants aggregate: +/// `SubscriptionDeactivated` → deprovision the tenant's networks. Factored out so +/// both the live loop and the deterministic test pump share one body. +pub async fn apply_to_network(service: &TenantsService, event: &DomainEvent) { + if let DomainEvent::SubscriptionDeactivated { tenant_id } = event + && let Err(e) = service.deprovision_networks_for(tenant_id).await + { + tracing::error!(error = %e, tenant_id, "network reactor: deprovision failed"); + } +} + +/// React to `SubscriptionDeactivated` by deprovisioning the tenant's networks. +pub async fn run_network_reactor(service: Arc, mut events: Box) { + while let Some(delivery) = events.next().await { + apply_to_network(&service, delivery.event()).await; + delivery.ack().await; + } +} diff --git a/source/crates/tenants/src/repository/mod.rs b/source/crates/tenants/src/repository/mod.rs index 06df58d..f082f64 100644 --- a/source/crates/tenants/src/repository/mod.rs +++ b/source/crates/tenants/src/repository/mod.rs @@ -16,7 +16,6 @@ pub mod enrollment; pub mod identity; pub mod network; pub mod session; -pub mod subscription; pub mod tenant; pub use daemon::{Daemon, DaemonRepository, PgDaemonRepository}; @@ -28,7 +27,4 @@ pub use network::{ Network, NetworkRepository, PgNetworkRepository, ProvisioningState, RegisterNetworkOutcome, }; pub use session::{PgSessionRepository, Session, SessionRepository}; -pub use subscription::{ - Entitlement, PgSubscriptionRepository, Subscription, SubscriptionRepository, SubscriptionStatus, -}; pub use tenant::{CreateTenantOutcome, PgTenantRepository, Tenant, TenantRepository}; diff --git a/source/crates/tenants/src/service.rs b/source/crates/tenants/src/service.rs index d2142fc..9ae89e3 100644 --- a/source/crates/tenants/src/service.rs +++ b/source/crates/tenants/src/service.rs @@ -4,20 +4,23 @@ //! entitlement enforcement, and the mesh reconcile transitions consumed by the //! regional DDNS provisioner/reaper. //! -//! Billing is a **separate aggregate**: this service never touches the subscription -//! repository. It *reads* the current subscription by calling -//! [`SubscriptionService::current`](crate::subscription::SubscriptionService::current), -//! and drives subscription side-effects by **publishing domain events** (a reactor -//! reacts). Conversely the network-deprovision side-effect of a deactivated -//! subscription is [`deprovision_networks_for`](Self::deprovision_networks_for), +//! The **license** is a separate aggregate: this service never touches the +//! subscription repository or the `subscriptions` crate. It *reads* entitlement +//! through the [`SubscriptionReader`] port (an `Arc` injected by the +//! composition root), and drives subscription side-effects by **publishing domain +//! events** (a reactor reacts). Conversely the network-deprovision side-effect of a +//! deactivated subscription is [`deprovision_networks_for`](Self::deprovision_networks_for), //! invoked by the network reactor. +//! +//! [`SubscriptionReader`]: wardnet_common::ports::SubscriptionReader use std::sync::Arc; use chrono::{Duration, Utc}; use uuid::Uuid; -use wardnet_common::event::{DomainEvent, EventPublisher}; +use wardnet_common::event::{DomainEvent, EventBus}; +use wardnet_common::ports::SubscriptionReader; use wardnet_common::token::{ClaimsSpec, PrincipalType, Signer}; use wardnet_common::validation::{is_valid_name, validate_public_key}; @@ -25,10 +28,8 @@ use crate::email::EmailSender; use crate::error::TenantsError; use crate::repository::{ CreateTenantOutcome, Daemon, DaemonRepository, EnrollOutcome, EnrollmentRepository, Network, - NetworkRepository, ProvisioningState, RegisterNetworkOutcome, Subscription, Tenant, - TenantRepository, + NetworkRepository, ProvisioningState, RegisterNetworkOutcome, Tenant, TenantRepository, }; -use crate::subscription::SubscriptionService; /// Identity JWT lifetime (seconds). Offline revocation is bounded by this. const IDENTITY_JWT_TTL_SECS: i64 = 3600; @@ -52,11 +53,11 @@ pub struct TenantsService { networks: Arc, daemons: Arc, enrollment: Arc, - /// The subscription aggregate — **read-only** access (`current`) plus the - /// `is_active` policy. All subscription *writes* happen via events. - subscriptions: Arc, + /// The license aggregate — **read-only** access via the port (`current` / + /// grace-aware `is_active`). All subscription *writes* happen via events. + subscriptions: Arc, /// Domain-event sink for cross-aggregate side-effects. - events: Arc, + events: Arc, /// Transactional email for enrollment codes (Resend in prod, no-op in dev/test). email: Arc, /// Shared signing capability (also held by `IdentitiesService` to mint USER JWTs). @@ -76,8 +77,8 @@ impl TenantsService { networks: Arc, daemons: Arc, enrollment: Arc, - subscriptions: Arc, - events: Arc, + subscriptions: Arc, + events: Arc, email: Arc, signer: Arc, regions: impl IntoIterator, @@ -95,6 +96,17 @@ impl TenantsService { } } + /// Publish a domain event **best-effort**: a transport failure is logged and + /// swallowed, never propagated. Events are the fast path; the periodic reconcile is + /// the correctness guarantee (ADR-0007/0010), so a dropped publish must not fail an + /// operation whose DB write already committed. (The in-process bus never errs; this + /// matters once a durable-broker adapter lands.) + async fn publish_best_effort(&self, event: DomainEvent) { + if let Err(e) = self.events.publish(&event).await { + tracing::error!(error = %e, ?event, "failed to publish domain event; reconcile is the safety net"); + } + } + /// Whether enrollment codes are delivered by email (a real provider) — when so, /// the API does not echo the code in the response. #[must_use] @@ -124,9 +136,13 @@ impl TenantsService { }; match self.tenants.create(&tenant).await? { CreateTenantOutcome::Created => { - self.events.publish(DomainEvent::TenantCreated { + // Best-effort: a publish failure must not fail an account whose row is + // already committed — the reconcile loop backfills the trial. (Never + // errs on the in-proc bus; matters once a broker adapter lands.) + self.publish_best_effort(DomainEvent::TenantCreated { tenant_id: tenant.id.clone(), - }); + }) + .await; Ok(tenant) } CreateTenantOutcome::EmailTaken => Err(TenantsError::Conflict( @@ -211,9 +227,10 @@ impl TenantsService { // Already tombstoned — idempotent no-op. return Ok(false); } - self.events.publish(DomainEvent::TenantDeregistered { + self.publish_best_effort(DomainEvent::TenantDeregistered { tenant_id: tenant_id.to_string(), - }); + }) + .await; tracing::info!( tenant_id, "tenant deregistered; subscription cancel signalled" @@ -221,27 +238,14 @@ impl TenantsService { Ok(true) } - /// Reconcile desired state — the safety net for any dropped domain event. For - /// every live tenant: open a missing trial (only when the tenant has *no* - /// subscription history, so a reaped trial is never resurrected); and if the - /// tenant has no current subscription, deprovision its networks. Idempotent; - /// driven by the periodic reconcile loop. + /// All **live** (non-tombstoned) tenant ids — the input the composition root's + /// reconcile loop iterates (it spans the tenant *and* license aggregates, so it + /// lives at the composition root, not here — ADR-0010). /// /// # Errors /// [`TenantsError::Internal`] on a repository failure. - pub async fn reconcile(&self) -> Result<(), TenantsError> { - for tenant_id in self.tenants.list_live_ids().await? { - if self.subscriptions.current(&tenant_id).await?.is_some() { - continue; - } - // No live subscription: either the trial event was dropped (no history → - // create it), or the subscription lapsed (history exists → ensure the - // networks are deprovisioning). - if !self.subscriptions.create_trial(&tenant_id).await? { - self.deprovision_networks_for(&tenant_id).await?; - } - } - Ok(()) + pub async fn list_live_tenant_ids(&self) -> Result, TenantsError> { + Ok(self.tenants.list_live_ids().await?) } /// Delete tombstoned tenants whose networks are fully deprovisioned (FK-cascading @@ -274,21 +278,14 @@ impl TenantsService { Ok(self.networks.find_by_id(network_id).await?) } - /// Fetch a tenant plus its current subscription (the mesh-plane resource read). - /// The subscription is read via the `SubscriptionService` method — this service - /// never touches the subscription repository. + /// Fetch a tenant by id. The current subscription (a foreign aggregate) is read + /// separately by the caller via the [`SubscriptionReader`] port and composed into + /// the view — this service never touches the subscription aggregate. /// /// # Errors /// [`TenantsError::Internal`] on a repository failure. - pub async fn find_tenant( - &self, - tenant_id: &str, - ) -> Result)>, TenantsError> { - let Some(tenant) = self.tenants.find_by_id(tenant_id).await? else { - return Ok(None); - }; - let subscription = self.subscriptions.current(tenant_id).await?; - Ok(Some((tenant, subscription))) + pub async fn find_tenant(&self, tenant_id: &str) -> Result, TenantsError> { + Ok(self.tenants.find_by_id(tenant_id).await?) } /// List a tenant's daemons. @@ -444,9 +441,10 @@ impl TenantsService { tenant_created, } => { if tenant_created { - self.events.publish(DomainEvent::TenantCreated { + self.publish_best_effort(DomainEvent::TenantCreated { tenant_id: tenant_id.clone(), - }); + }) + .await; } Ok(EnrollResult { tenant_id }) } @@ -496,9 +494,9 @@ impl TenantsService { } let entitled = self .subscriptions - .current(&tenant_id) - .await? - .is_some_and(|sub| self.subscriptions.is_active(&sub, now)); + .is_active(&tenant_id) + .await + .map_err(TenantsError::Internal)?; if !entitled { return Err(TenantsError::Forbidden( "tenant subscription is not active".to_string(), @@ -577,11 +575,13 @@ impl TenantsService { .ok_or_else(|| TenantsError::NotFound("no such tenant".to_string()))?; // Entitlement is granted by the current subscription, not the tenant. Reading - // it via the SubscriptionService keeps Tenants off the subscription repo. + // it via the SubscriptionReader port keeps Tenants off the subscription repo + // (and off the subscriptions crate). let entitlement = self .subscriptions .current(tenant_id) - .await? + .await + .map_err(TenantsError::Internal)? .ok_or_else(|| { TenantsError::Forbidden("tenant has no active subscription".to_string()) })? @@ -707,6 +707,3 @@ fn generate_code() -> (String, String) { fn hash_code(code: &str) -> String { crate::util::sha256_hex(code) } - -#[cfg(test)] -mod tests; diff --git a/source/crates/tenants/src/state.rs b/source/crates/tenants/src/state.rs index 8e535c9..87525c3 100644 --- a/source/crates/tenants/src/state.rs +++ b/source/crates/tenants/src/state.rs @@ -10,22 +10,32 @@ use axum::extract::FromRef; use axum_extra::extract::cookie::Key; use wardnet_common::auth::AuthContext; +use wardnet_common::ports::{BillingPort, SubscriptionCommands, SubscriptionReader}; use wardnet_common::replay_cache::ReplayCache; use wardnet_common::token::Verifier; use crate::config::Config; use crate::identities::IdentitiesService; use crate::service::TenantsService; -use crate::subscription::SubscriptionService; /// Cloneable handle to the service's shared state. +/// +/// The license + payment aggregates are reached only through their `common` **ports** +/// (`dyn` trait objects injected by the composition root), never their concrete +/// services — so this crate (and its handlers) depend on `wardnet_common` alone, not +/// on `subscriptions`/`billing` (ADR-0010). #[derive(Clone)] pub struct AppState(Arc); struct Inner { config: Config, tenants: Arc, - subscriptions: Arc, + /// Entitlement reads over the license aggregate. + subscriptions: Arc, + /// Account-plane subscription cancel (the one command the USER plane drives). + subscription_commands: Arc, + /// Hosted Checkout/Portal + the provider webhook. + billing: Arc, identities: Arc, verifier: Verifier, replay_cache: Arc, @@ -37,11 +47,14 @@ impl AppState { /// Build the shared state. `config.cookie_key` must be ≥ 64 bytes (the `axum-extra` /// private jar requirement); `Config::from_env` validates this up front, so by the /// time we reach `Key::from` here the length is already guaranteed. + #[allow(clippy::too_many_arguments)] #[must_use] pub fn new( config: Config, tenants: Arc, - subscriptions: Arc, + subscriptions: Arc, + subscription_commands: Arc, + billing: Arc, identities: Arc, verifier: Verifier, ) -> Self { @@ -50,6 +63,8 @@ impl AppState { config, tenants, subscriptions, + subscription_commands, + billing, identities, verifier, replay_cache: Arc::new(ReplayCache::new()), @@ -74,10 +89,22 @@ impl AppState { &self.0.tenants } - /// The subscription/billing business-rule service. + /// The license aggregate's read port (entitlement / current subscription). #[must_use] - pub fn subscriptions(&self) -> &SubscriptionService { - &self.0.subscriptions + pub fn subscriptions(&self) -> &dyn SubscriptionReader { + self.0.subscriptions.as_ref() + } + + /// The license aggregate's command port (account-plane cancel). + #[must_use] + pub fn subscription_commands(&self) -> &dyn SubscriptionCommands { + self.0.subscription_commands.as_ref() + } + + /// The payment aggregate's port (Checkout/Portal + webhook). + #[must_use] + pub fn billing(&self) -> &dyn BillingPort { + self.0.billing.as_ref() } /// The replay cache (used by the bootstrap token endpoint's `PoP` check). diff --git a/source/crates/tenants/src/subscription.rs b/source/crates/tenants/src/subscription.rs deleted file mode 100644 index a0c1196..0000000 --- a/source/crates/tenants/src/subscription.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! `SubscriptionService` — owns **all** subscription/billing rules over the -//! subscription aggregate: opening the free trial, resolving the current -//! subscription, cancelling (with the network-deprovision cascade signalled by an -//! event), and expiring overdue trials / past-due subscriptions. The Stripe-driven -//! methods (`start_checkout` / `billing_portal` / `apply_stripe_event`) land with -//! the billing slice. -//! -//! It depends only on its own [`SubscriptionRepository`] and the -//! [`EventPublisher`] — **never** another aggregate's repository. Cross-aggregate -//! side-effects flow out as [`DomainEvent`]s for reactors to pick up. - -use std::sync::Arc; - -use chrono::{DateTime, Duration, Utc}; -use uuid::Uuid; - -use wardnet_common::event::{DomainEvent, EventPublisher}; - -use crate::error::SubscriptionError; -use crate::repository::SubscriptionRepository; -use crate::repository::subscription::{Entitlement, Subscription, SubscriptionStatus}; -use crate::stripe::{StripeEvent, StripeEventKind, StripeGateway, SubscriptionData}; - -pub mod reactor; - -/// Trial / grace policy, sourced from [`Config`](crate::config::Config). -#[derive(Debug, Clone, Copy)] -pub struct TrialPolicy { - /// Free-trial length (days) applied at trial creation. - pub trial_days: i64, - /// Extra days a lapsed trial keeps service before the reaper cancels it. - pub trial_grace_days: i64, - /// Extra days a `past_due` subscription keeps service before the reaper cancels it. - pub payment_grace_days: i64, -} - -/// The subscription/billing business-rule layer. -pub struct SubscriptionService { - subscriptions: Arc, - events: Arc, - stripe: Arc, - policy: TrialPolicy, -} - -impl SubscriptionService { - #[must_use] - pub fn new( - subscriptions: Arc, - events: Arc, - stripe: Arc, - policy: TrialPolicy, - ) -> Self { - Self { - subscriptions, - events, - stripe, - policy, - } - } - - /// Open the tenant's free **trial** subscription. Idempotent and safe to call - /// for any tenant: the insert only lands when the tenant has *no* subscription - /// history (so a replayed `TenantCreated` is a no-op, and a tenant whose trial - /// already lapsed is never given a fresh one). Invoked by the subscription - /// reactor on `TenantCreated`. Returns whether a trial was created. - /// - /// # Errors - /// [`SubscriptionError::Internal`] on a repository failure. - pub async fn create_trial(&self, tenant_id: &str) -> Result { - let now = Utc::now(); - let sub = Subscription { - id: Uuid::new_v4().to_string(), - tenant_id: tenant_id.to_string(), - status: SubscriptionStatus::Trialing, - entitlement: Entitlement::DEFAULT, - stripe_customer_id: None, - stripe_subscription_id: None, - price_id: None, - trial_expires_at: Some(now + Duration::days(self.policy.trial_days)), - current_period_end: None, - created_at: now, - updated_at: now, - }; - let created = self.subscriptions.create_trial(&sub).await?; - if created { - tracing::info!(tenant_id, "opened trial subscription"); - } - Ok(created) - } - - /// The tenant's current (single non-`Canceled`) subscription, if any. The - /// service-method read other services call instead of touching the repo. - /// - /// # Errors - /// [`SubscriptionError::Internal`] on a repository failure. - pub async fn current( - &self, - tenant_id: &str, - ) -> Result, SubscriptionError> { - Ok(self.subscriptions.find_current(tenant_id).await?) - } - - /// Cancel the tenant's current subscription and, if one was actually cancelled, - /// publish [`DomainEvent::SubscriptionDeactivated`] so the network reactor - /// deprovisions the tenant's networks. Idempotent — the single cancel path. - /// - /// # Errors - /// [`SubscriptionError::Internal`] on a repository failure. - pub async fn cancel(&self, tenant_id: &str) -> Result<(), SubscriptionError> { - if self.subscriptions.cancel_current(tenant_id).await? { - tracing::info!(tenant_id, "subscription cancelled"); - self.events.publish(DomainEvent::SubscriptionDeactivated { - tenant_id: tenant_id.to_string(), - }); - } - Ok(()) - } - - /// Cancel every overdue subscription — a `trialing` row past - /// `trial_expires_at + trial_grace`, or a `past_due` row past - /// `current_period_end + payment_grace`. Each cancel cascades via its event. - /// Driven by the periodic reaper loop. Returns the number cancelled. - /// - /// # Errors - /// [`SubscriptionError::Internal`] on a repository failure. - pub async fn expire_overdue(&self) -> Result { - let now = Utc::now(); - let trial_cutoff = now - Duration::days(self.policy.trial_grace_days); - let payment_cutoff = now - Duration::days(self.policy.payment_grace_days); - let tenant_ids = self - .subscriptions - .list_overdue(trial_cutoff, payment_cutoff) - .await?; - let n = tenant_ids.len() as u64; - for tenant_id in tenant_ids { - self.cancel(&tenant_id).await?; - } - if n > 0 { - tracing::info!(count = n, "expired overdue subscriptions"); - } - Ok(n) - } - - /// Whether `sub` currently entitles its tenant to service, accounting for the - /// trial / payment grace windows. The config-aware check `mint_jwt` uses (the - /// wire [`SubscriptionView`](wardnet_common::contract::SubscriptionView) embeds a - /// grace-free predicate for consumers that lack the policy). - #[must_use] - pub fn is_active(&self, sub: &Subscription, now: DateTime) -> bool { - match sub.status { - SubscriptionStatus::Active => true, - SubscriptionStatus::Trialing => sub - .trial_expires_at - .is_some_and(|t| now < t + Duration::days(self.policy.trial_grace_days)), - SubscriptionStatus::PastDue => sub - .current_period_end - .is_some_and(|c| now < c + Duration::days(self.policy.payment_grace_days)), - SubscriptionStatus::Canceled => false, - } - } - - // ── Stripe billing ─────────────────────────────────────────────────────────── - - /// Start a Stripe Checkout for `price_id`, reusing the tenant's existing Stripe - /// Customer when known. Returns the URL to redirect the user to. The - /// subscription itself is recorded later, by the `customer.subscription.created` - /// webhook. - /// - /// # Errors - /// [`SubscriptionError::Internal`] on a Stripe/repository failure. - pub async fn start_checkout( - &self, - tenant_id: &str, - email: &str, - price_id: &str, - ) -> Result { - let customer_id = self.subscriptions.latest_customer_id(tenant_id).await?; - let session = self - .stripe - .create_checkout_session(customer_id.as_deref(), email, price_id, tenant_id) - .await - .map_err(SubscriptionError::Internal)?; - // Best-effort: stamp the customer id onto the live row if Stripe surfaced one - // now (the authoritative value still arrives via the webhook). - if let Some(cid) = session.customer_id { - self.subscriptions - .stamp_customer_id(tenant_id, &cid) - .await?; - } - Ok(session.url) - } - - /// Create a Stripe Billing Portal session for the tenant's Customer. - /// - /// # Errors - /// [`SubscriptionError::BadRequest`] if the tenant has no Stripe Customer yet; - /// [`SubscriptionError::Internal`] on a Stripe failure. - pub async fn billing_portal(&self, tenant_id: &str) -> Result { - let customer_id = self - .subscriptions - .latest_customer_id(tenant_id) - .await? - .ok_or_else(|| { - SubscriptionError::BadRequest("tenant has no billing account yet".to_string()) - })?; - self.stripe - .create_billing_portal_session(&customer_id) - .await - .map_err(SubscriptionError::Internal) - } - - /// Verify a raw Stripe webhook (the signature is the credential) and apply it. - /// A bad signature is a [`SubscriptionError::BadRequest`] (the endpoint returns - /// `400`); a verified event is applied idempotently. - /// - /// # Errors - /// [`SubscriptionError::BadRequest`] on an unverifiable/malformed payload; - /// [`SubscriptionError::Internal`] on a repository failure. - pub async fn handle_webhook( - &self, - payload: &[u8], - signature: &str, - ) -> Result<(), SubscriptionError> { - let event = self - .stripe - .construct_event(payload, signature) - .map_err(|e| SubscriptionError::BadRequest(format!("invalid Stripe webhook: {e}")))?; - self.apply_stripe_event(event).await - } - - /// Apply a verified Stripe webhook event, idempotently (a redelivery whose id is - /// already recorded is a no-op). Drives trial→paid conversion, plan changes, - /// cancellation, and payment-failure transitions. - /// - /// # Errors - /// [`SubscriptionError::Internal`] on a repository failure. - pub async fn apply_stripe_event(&self, event: StripeEvent) -> Result<(), SubscriptionError> { - // Fast-path dedupe. The id is recorded only AFTER a successful apply (below), - // so a failed apply stays un-recorded and Stripe's retry re-applies it — the - // ledger must never mark an event done before its effect lands. - if self.subscriptions.is_event_processed(&event.id).await? { - tracing::debug!(event_id = %event.id, "stripe event already processed; skipping"); - return Ok(()); - } - self.apply_event_kind(event.kind).await?; - self.subscriptions - .record_event(&event.id, Utc::now()) - .await?; - Ok(()) - } - - /// Apply the event's effect. Each branch is idempotent, so an at-least-once - /// redelivery (or a retry after a recorded-but-failed apply) is safe. - async fn apply_event_kind(&self, kind: StripeEventKind) -> Result<(), SubscriptionError> { - match kind { - StripeEventKind::SubscriptionUpsert(data) => self.apply_upsert(data).await?, - StripeEventKind::SubscriptionDeleted { - stripe_subscription_id, - } => { - if let Some(sub) = self - .subscriptions - .find_by_stripe_subscription_id(&stripe_subscription_id) - .await? - { - self.cancel(&sub.tenant_id).await?; - } - } - StripeEventKind::PaymentFailed { - stripe_subscription_id, - } => { - if let Some(sub) = self - .subscriptions - .find_by_stripe_subscription_id(&stripe_subscription_id) - .await? - { - self.subscriptions - .update_from_stripe( - &stripe_subscription_id, - SubscriptionStatus::PastDue, - sub.entitlement, - sub.current_period_end, - ) - .await?; - } - } - StripeEventKind::Ignored => {} - } - Ok(()) - } - - /// `customer.subscription.created`/`.updated`: reconcile our row to Stripe's state. - async fn apply_upsert(&self, data: SubscriptionData) -> Result<(), SubscriptionError> { - if let Some(existing) = self - .subscriptions - .find_by_stripe_subscription_id(&data.stripe_subscription_id) - .await? - { - if data.status == SubscriptionStatus::Canceled { - // Route cancellation through `cancel` so it publishes the deactivation. - self.cancel(&existing.tenant_id).await?; - } else { - let entitlement = data.entitlement.unwrap_or(existing.entitlement); - self.subscriptions - .update_from_stripe( - &data.stripe_subscription_id, - data.status, - entitlement, - data.current_period_end, - ) - .await?; - } - return Ok(()); - } - - // A brand-new paid subscription. We need the tenant (from metadata) and the - // plan's entitlement; without either we decline to grant (safe-closed). - if data.status == SubscriptionStatus::Canceled { - return Ok(()); - } - let Some(tenant_id) = data.tenant_id else { - tracing::error!( - stripe_subscription_id = %data.stripe_subscription_id, - "stripe subscription has no tenant_id metadata; ignoring" - ); - return Ok(()); - }; - let Some(entitlement) = data.entitlement else { - tracing::error!( - stripe_subscription_id = %data.stripe_subscription_id, - "stripe price has no max_networks/max_daemons metadata; not granting" - ); - return Ok(()); - }; - let now = Utc::now(); - let paid = Subscription { - id: Uuid::new_v4().to_string(), - tenant_id: tenant_id.clone(), - status: data.status, - entitlement, - stripe_customer_id: Some(data.stripe_customer_id), - stripe_subscription_id: Some(data.stripe_subscription_id), - price_id: data.price_id, - trial_expires_at: None, - current_period_end: data.current_period_end, - created_at: now, - updated_at: now, - }; - self.subscriptions - .convert_trial_to_paid(&tenant_id, &paid) - .await?; - tracing::info!(tenant_id, "converted to paid subscription"); - Ok(()) - } -} - -#[cfg(test)] -mod tests; diff --git a/source/crates/tenants/src/subscription/reactor.rs b/source/crates/tenants/src/subscription/reactor.rs deleted file mode 100644 index 76af567..0000000 --- a/source/crates/tenants/src/subscription/reactor.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Domain-event reactors — the long-running loops that turn published -//! [`DomainEvent`]s into the owning service's method calls. Each holds an -//! `Arc` (never a repository), and every reaction is **idempotent** so a -//! redelivery is harmless and the periodic reconcile can re-drive a dropped event. -//! -//! Spawn these from `main` (`tokio::spawn(run_subscription_reactor(...))`). - -use std::sync::Arc; - -use tokio::sync::broadcast::Receiver; -use tokio::sync::broadcast::error::RecvError; - -use wardnet_common::event::DomainEvent; - -use crate::service::TenantsService; -use crate::subscription::SubscriptionService; - -/// Apply a single event to the **subscription** aggregate: `TenantCreated` → open -/// the trial, `TenantDeregistered` → cancel. Other events are ignored. Factored out -/// so both the live loop and the deterministic test pump share one body. -pub async fn apply_to_subscription(service: &SubscriptionService, event: &DomainEvent) { - match event { - DomainEvent::TenantCreated { tenant_id } => { - if let Err(e) = service.create_trial(tenant_id).await { - tracing::error!(error = %e, tenant_id, "subscription reactor: create_trial failed"); - } - } - DomainEvent::TenantDeregistered { tenant_id } => { - if let Err(e) = service.cancel(tenant_id).await { - tracing::error!(error = %e, tenant_id, "subscription reactor: cancel failed"); - } - } - DomainEvent::SubscriptionDeactivated { .. } => {} - } -} - -/// Apply a single event to the **network** side of the tenants aggregate: -/// `SubscriptionDeactivated` → deprovision the tenant's networks. -pub async fn apply_to_network(service: &TenantsService, event: &DomainEvent) { - if let DomainEvent::SubscriptionDeactivated { tenant_id } = event - && let Err(e) = service.deprovision_networks_for(tenant_id).await - { - tracing::error!(error = %e, tenant_id, "network reactor: deprovision failed"); - } -} - -/// React to tenant-lifecycle events by driving the subscription aggregate. -pub async fn run_subscription_reactor( - service: Arc, - mut events: Receiver, -) { - loop { - match events.recv().await { - Ok(event) => apply_to_subscription(&service, &event).await, - Err(RecvError::Lagged(skipped)) => { - tracing::warn!( - skipped, - "subscription reactor lagged; reconcile will backfill" - ); - } - Err(RecvError::Closed) => break, - } - } -} - -/// React to `SubscriptionDeactivated` by deprovisioning the tenant's networks -/// (`TenantsService` owns the network repository). -pub async fn run_network_reactor(service: Arc, mut events: Receiver) { - loop { - match events.recv().await { - Ok(event) => apply_to_network(&service, &event).await, - Err(RecvError::Lagged(skipped)) => { - tracing::warn!(skipped, "network reactor lagged; reconcile will backfill"); - } - Err(RecvError::Closed) => break, - } - } -} diff --git a/source/crates/tenants/src/subscription/tests.rs b/source/crates/tenants/src/subscription/tests.rs deleted file mode 100644 index 45fa2a0..0000000 --- a/source/crates/tenants/src/subscription/tests.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Unit tests for [`SubscriptionService`] over the shared mock store + recording -//! event publisher. - -use std::sync::Arc; - -use chrono::{Duration, Utc}; - -use wardnet_common::event::DomainEvent; - -use crate::repository::SubscriptionRepository; -use crate::repository::subscription::{Entitlement, Subscription, SubscriptionStatus}; -use crate::subscription::{SubscriptionService, TrialPolicy}; -use crate::test_helpers::{MockStore, MockStripeGateway, RecordingEventPublisher}; - -const POLICY: TrialPolicy = TrialPolicy { - trial_days: 60, - trial_grace_days: 15, - payment_grace_days: 15, -}; - -fn service() -> ( - Arc, - MockStore, - Arc, -) { - let store = MockStore::new(); - let events = Arc::new(RecordingEventPublisher::new()); - let svc = Arc::new(SubscriptionService::new( - Arc::new(store.clone()) as Arc, - events.clone(), - Arc::new(MockStripeGateway::new()), - POLICY, - )); - (svc, store, events) -} - -/// A subscription row with the given status + timestamps, all Stripe fields empty. -fn sub(tenant_id: &str, status: SubscriptionStatus) -> Subscription { - let now = Utc::now(); - Subscription { - id: format!("sub-{tenant_id}"), - tenant_id: tenant_id.to_string(), - status, - entitlement: Entitlement::DEFAULT, - stripe_customer_id: None, - stripe_subscription_id: None, - price_id: None, - trial_expires_at: None, - current_period_end: None, - created_at: now, - updated_at: now, - } -} - -#[tokio::test] -async fn create_trial_opens_one_trial_and_is_idempotent() { - let (svc, store, _events) = service(); - assert!(svc.create_trial("t1").await.unwrap()); - assert_eq!(store.subscription_count("t1"), 1); - let current = svc.current("t1").await.unwrap().unwrap(); - assert_eq!(current.status, SubscriptionStatus::Trialing); - assert!(current.trial_expires_at.is_some()); - - // A second call (replayed TenantCreated) does not open a second trial. - assert!(!svc.create_trial("t1").await.unwrap()); - assert_eq!(store.subscription_count("t1"), 1); -} - -#[tokio::test] -async fn create_trial_does_not_resurrect_a_reaped_trial() { - let (svc, store, _events) = service(); - svc.create_trial("t1").await.unwrap(); - svc.cancel("t1").await.unwrap(); - // The tenant now has a canceled row but no live one. - assert!(svc.current("t1").await.unwrap().is_none()); - // create_trial must not open a fresh trial (history exists). - assert!(!svc.create_trial("t1").await.unwrap()); - assert!(svc.current("t1").await.unwrap().is_none()); - assert_eq!(store.subscription_count("t1"), 1); -} - -#[tokio::test] -async fn cancel_publishes_deactivation_and_is_idempotent() { - let (svc, _store, events) = service(); - svc.create_trial("t1").await.unwrap(); - - svc.cancel("t1").await.unwrap(); - assert!(svc.current("t1").await.unwrap().is_none()); - assert!( - events - .published() - .contains(&DomainEvent::SubscriptionDeactivated { - tenant_id: "t1".to_string() - }) - ); - - // A second cancel (no live row) publishes nothing further. - let before = events.published().len(); - svc.cancel("t1").await.unwrap(); - assert_eq!(events.published().len(), before); -} - -#[tokio::test] -async fn expire_overdue_cancels_expired_trials_and_past_due() { - let (svc, store, events) = service(); - let now = Utc::now(); - - // A trial that lapsed past its 15-day grace. - let mut expired_trial = sub("t-trial", SubscriptionStatus::Trialing); - expired_trial.trial_expires_at = Some(now - Duration::days(20)); - store.seed_subscription(expired_trial); - - // A trial still within grace — must survive. - let mut fresh_trial = sub("t-fresh", SubscriptionStatus::Trialing); - fresh_trial.trial_expires_at = Some(now - Duration::days(2)); - store.seed_subscription(fresh_trial); - - // A past-due subscription past its payment grace. - let mut overdue_paid = sub("t-paid", SubscriptionStatus::PastDue); - overdue_paid.current_period_end = Some(now - Duration::days(20)); - store.seed_subscription(overdue_paid); - - let n = svc.expire_overdue().await.unwrap(); - assert_eq!(n, 2); - assert!(svc.current("t-trial").await.unwrap().is_none()); - assert!(svc.current("t-paid").await.unwrap().is_none()); - assert!(svc.current("t-fresh").await.unwrap().is_some()); - - // Each cancellation cascaded via an event. - let deactivations = events - .published() - .iter() - .filter(|e| matches!(e, DomainEvent::SubscriptionDeactivated { .. })) - .count(); - assert_eq!(deactivations, 2); -} - -// ── Stripe-driven lifecycle ───────────────────────────────────────────────────── - -use crate::stripe::{StripeEvent, StripeEventKind, SubscriptionData}; - -fn upsert_event( - id: &str, - tenant_id: Option<&str>, - sid: &str, - status: SubscriptionStatus, -) -> StripeEvent { - StripeEvent { - id: id.to_string(), - kind: StripeEventKind::SubscriptionUpsert(SubscriptionData { - tenant_id: tenant_id.map(str::to_string), - stripe_subscription_id: sid.to_string(), - stripe_customer_id: "cus_1".to_string(), - price_id: Some("price_pro".to_string()), - entitlement: Some(Entitlement { - max_networks: 3, - max_daemons: 10, - }), - status, - current_period_end: Some(Utc::now() + Duration::days(30)), - }), - } -} - -#[tokio::test] -async fn webhook_created_converts_trial_to_paid() { - let (svc, store, _events) = service(); - svc.create_trial("t1").await.unwrap(); - - svc.apply_stripe_event(upsert_event( - "evt_1", - Some("t1"), - "sub_x", - SubscriptionStatus::Active, - )) - .await - .unwrap(); - - let current = svc.current("t1").await.unwrap().unwrap(); - assert_eq!(current.status, SubscriptionStatus::Active); - assert_eq!(current.entitlement.max_networks, 3); - assert_eq!(current.entitlement.max_daemons, 10); - assert_eq!(current.stripe_subscription_id.as_deref(), Some("sub_x")); - // The trial row was superseded → only one live row, history has two. - assert_eq!(store.subscription_count("t1"), 2); -} - -#[tokio::test] -async fn webhook_redelivery_is_idempotent() { - let (svc, store, _events) = service(); - svc.create_trial("t1").await.unwrap(); - let event = upsert_event("evt_1", Some("t1"), "sub_x", SubscriptionStatus::Active); - - svc.apply_stripe_event(event.clone()).await.unwrap(); - // Same event id again — must not create a second paid row. - svc.apply_stripe_event(event).await.unwrap(); - assert_eq!(store.subscription_count("t1"), 2); -} - -#[tokio::test] -async fn webhook_missing_price_metadata_does_not_grant() { - let (svc, store, _events) = service(); - svc.create_trial("t1").await.unwrap(); - let mut event = upsert_event("evt_1", Some("t1"), "sub_x", SubscriptionStatus::Active); - if let StripeEventKind::SubscriptionUpsert(ref mut data) = event.kind { - data.entitlement = None; // price had no max_networks/max_daemons metadata - } - svc.apply_stripe_event(event).await.unwrap(); - // No paid row created; the trial is still the current subscription. - let current = svc.current("t1").await.unwrap().unwrap(); - assert_eq!(current.status, SubscriptionStatus::Trialing); - assert_eq!(store.subscription_count("t1"), 1); -} - -#[tokio::test] -async fn webhook_missing_tenant_id_does_not_grant() { - // A brand-new paid subscription whose metadata carries no `tenant_id` cannot be - // attributed to an account — safe-closed: decline to grant rather than guess (the - // sibling of the missing-price-metadata path). - let (svc, store, _events) = service(); - svc.create_trial("t1").await.unwrap(); - let event = upsert_event("evt_1", None, "sub_orphan", SubscriptionStatus::Active); - svc.apply_stripe_event(event).await.unwrap(); - // The existing tenant's trial is untouched; nothing was granted. - let current = svc.current("t1").await.unwrap().unwrap(); - assert_eq!(current.status, SubscriptionStatus::Trialing); - assert_eq!(store.subscription_count("t1"), 1); -} - -#[tokio::test] -async fn webhook_deleted_cancels_and_deactivates() { - let (svc, _store, events) = service(); - svc.create_trial("t1").await.unwrap(); - svc.apply_stripe_event(upsert_event( - "evt_1", - Some("t1"), - "sub_x", - SubscriptionStatus::Active, - )) - .await - .unwrap(); - - svc.apply_stripe_event(StripeEvent { - id: "evt_2".to_string(), - kind: StripeEventKind::SubscriptionDeleted { - stripe_subscription_id: "sub_x".to_string(), - }, - }) - .await - .unwrap(); - - assert!(svc.current("t1").await.unwrap().is_none()); - assert!( - events - .published() - .contains(&DomainEvent::SubscriptionDeactivated { - tenant_id: "t1".to_string() - }) - ); -} - -#[tokio::test] -async fn webhook_payment_failed_moves_to_past_due() { - let (svc, _store, _events) = service(); - svc.create_trial("t1").await.unwrap(); - svc.apply_stripe_event(upsert_event( - "evt_1", - Some("t1"), - "sub_x", - SubscriptionStatus::Active, - )) - .await - .unwrap(); - - svc.apply_stripe_event(StripeEvent { - id: "evt_2".to_string(), - kind: StripeEventKind::PaymentFailed { - stripe_subscription_id: "sub_x".to_string(), - }, - }) - .await - .unwrap(); - - let current = svc.current("t1").await.unwrap().unwrap(); - assert_eq!(current.status, SubscriptionStatus::PastDue); - // Entitlement is preserved across the payment-failed transition. - assert_eq!(current.entitlement.max_networks, 3); -} - -#[test] -fn is_active_respects_status_and_grace() { - let store = MockStore::new(); - let events = Arc::new(RecordingEventPublisher::new()); - let svc = SubscriptionService::new( - Arc::new(store) as Arc, - events, - Arc::new(MockStripeGateway::new()), - POLICY, - ); - let now = Utc::now(); - - // Active is always entitling. - assert!(svc.is_active(&sub("t", SubscriptionStatus::Active), now)); - - // Trialing: within trial_expires_at + grace true, past it false. - let mut trial = sub("t", SubscriptionStatus::Trialing); - trial.trial_expires_at = Some(now - Duration::days(10)); // 10d < 15d grace - assert!(svc.is_active(&trial, now)); - trial.trial_expires_at = Some(now - Duration::days(20)); // past grace - assert!(!svc.is_active(&trial, now)); - - // Past-due: within current_period_end + grace true, past it false. - let mut paid = sub("t", SubscriptionStatus::PastDue); - paid.current_period_end = Some(now - Duration::days(10)); - assert!(svc.is_active(&paid, now)); - paid.current_period_end = Some(now - Duration::days(20)); - assert!(!svc.is_active(&paid, now)); - - // Canceled is never entitling. - assert!(!svc.is_active(&sub("t", SubscriptionStatus::Canceled), now)); -} - -#[test] -fn is_active_grace_boundary_is_exclusive() { - // The grace check is `now < expiry + grace` (strict): at the exact cutoff the - // subscription is already inactive. Pins the operator so a `<` → `<=` regression - // (a free extra moment of service past grace) is caught. - let (svc, _store, _events) = service(); - let now = Utc::now(); - - let mut trial = sub("t", SubscriptionStatus::Trialing); - // One second inside the 15-day grace window → still active. - trial.trial_expires_at = - Some(now - Duration::days(POLICY.trial_grace_days) + Duration::seconds(1)); - assert!(svc.is_active(&trial, now)); - // Exactly at the cutoff (now == expiry + grace) → no longer active. - trial.trial_expires_at = Some(now - Duration::days(POLICY.trial_grace_days)); - assert!(!svc.is_active(&trial, now)); -} diff --git a/source/crates/tunneller/Cargo.toml b/source/crates/tunneller/Cargo.toml index 1feb007..efddd48 100644 --- a/source/crates/tunneller/Cargo.toml +++ b/source/crates/tunneller/Cargo.toml @@ -10,10 +10,6 @@ description = "Wardnet Tunneller — multi-node SNI-passthrough reverse-tunnel e name = "wardnet_tunneller" path = "src/lib.rs" -[[bin]] -name = "wardnet-tunneller" -path = "src/main.rs" - [dependencies] wardnet_common = { workspace = true } diff --git a/source/crates/tunneller/Dockerfile b/source/crates/tunneller/Dockerfile index 00fb185..801c06b 100644 --- a/source/crates/tunneller/Dockerfile +++ b/source/crates/tunneller/Dockerfile @@ -7,7 +7,7 @@ FROM rust:1-bookworm AS build WORKDIR /src COPY . . -RUN cargo build --release -p wardnet-tunneller +RUN cargo build --release -p wardnet-tunneller-bin FROM debian:bookworm-slim AS runtime RUN apt-get update \ diff --git a/source/crates/tunneller/src/test_helpers.rs b/source/crates/tunneller/src/test_helpers.rs index 9a57b88..f2b4b8e 100644 --- a/source/crates/tunneller/src/test_helpers.rs +++ b/source/crates/tunneller/src/test_helpers.rs @@ -97,9 +97,6 @@ pub fn tenant_view(id: &str, status: SubscriptionStatus) -> TenantView { id: format!("sub-{id}"), status, entitlement: Entitlement::DEFAULT, - stripe_customer_id: None, - stripe_subscription_id: None, - price_id: None, trial_expires_at: None, current_period_end: None, created_at: now, diff --git a/source/docs/adr/0006-subscriptions-aggregate.md b/source/docs/adr/0006-subscriptions-aggregate.md index 67dd71d..c2cfb83 100644 --- a/source/docs/adr/0006-subscriptions-aggregate.md +++ b/source/docs/adr/0006-subscriptions-aggregate.md @@ -1,7 +1,14 @@ # 6. Entitlement is granted by a subscription aggregate, with a card-less managed trial Date: 2026-06-18 -Status: Accepted +Status: Superseded by [0010](0010-license-billing-split-ports.md) + +> **Superseded (2026-06-28):** the card-less managed-trial *license* model below still +> holds, but the Stripe/payment concern it folded into the `subscriptions` aggregate has +> been split into a separate `billing` aggregate, and the `stripe_*` columns moved off the +> `subscriptions` row into a Billing-owned `billing_customers` table. The inter-aggregate +> boundary is now the two-port (`SubscriptionReader` / `SubscriptionCommands`) + +> `EventBus` seam. See [ADR-0010](0010-license-billing-split-ports.md). ## Context diff --git a/source/docs/adr/0010-license-billing-split-ports.md b/source/docs/adr/0010-license-billing-split-ports.md new file mode 100644 index 0000000..5cf1c83 --- /dev/null +++ b/source/docs/adr/0010-license-billing-split-ports.md @@ -0,0 +1,105 @@ +# 10. License (Subscription) vs Billing split; two-port inter-crate boundary + +Date: 2026-06-28 +Status: Accepted + +Supersedes [0006](0006-subscriptions-aggregate.md). Refines the eventing rules of +[0007](0007-domain-events-reactors.md). + +## Context + +[ADR-0006](0006-subscriptions-aggregate.md) made entitlement a `subscriptions` +aggregate with a card-less managed trial, and folded Stripe into it: the +`SubscriptionService` owned both the **license** (status / entitlement / trial+grace) +*and* the **payment provider** (Checkout/Portal, the webhook, the idempotency ledger, +the `stripe_*` reference ids on the `subscriptions` row). Two genuinely different +domains were intermingled in one crate: + +- **Subscription = the license.** *What* entitlement a tenant currently holds and its + lifecycle (`trialing → active → past_due → canceled`, grace windows). Provider-agnostic; + the source of truth for entitlement. +- **Billing = how it's paid for.** The payment provider (Stripe today), hosted + Checkout/Portal, webhooks, the idempotency ledger, the provider-reference ids. Swappable. + +The "My Account" initiative needs these as independent units that could later be lifted +into their own services without a domain rewrite. That requires a real seam *now* — one +the compiler enforces, not a convention. + +[ADR-0007](0007-domain-events-reactors.md)'s `EventPublisher` also leaked its transport: +`subscribe()` returned a `tokio::sync::broadcast::Receiver`, so no other delivery +mechanism could ever be substituted without changing every reactor signature. + +## Decision + +**Three independent aggregate crates, composed into one binary.** `subscriptions` (the +license), `billing` (the payment provider), and `tenants` (identity/networks/daemons + +the Identities aggregate) are separate workspace crates. They depend on `wardnet_common` +and **never on each other** — the boundary is a Cargo fact, so a crate physically cannot +name a sibling's concrete type. A single composition-root binary crate (`app-tenants`, +artifact `wardnet-tenants`) is the only crate that depends on all three; it instantiates +each concrete service and injects the others as `dyn` **port** trait objects. (The same +uniform lib/bin layout is applied to `ddns` and `tunneller` — a domain lib + a thin bin — +so packaging is orthogonal to domain code across the workspace.) + +**Two port mechanisms, not "everything is an event"** (all ports + the DTOs they carry +live in `wardnet_common`): + +1. **`EventBus` / `EventStream`** — fire-and-react state-change notifications + (`TenantCreated`, `TenantDeregistered`, `SubscriptionDeactivated`). The redesigned + port leaks no transport: `publish(&DomainEvent)` and `subscribe(group) -> + Box` (with `EventStream::next() -> Option`; `Delivery::ack()` + is auto-ack in-proc, a broker ack later). Only the in-process `tokio::broadcast` + adapter ships now (parity with today); the `group` arg is a no-op in-proc and the + competing-consumer key an AMQP/RabbitMQ adapter will use later. `DomainEvent` is + serde-serializable with a **stable, versioned wire format** so the broker adapter is + drop-in. + +2. **Synchronous query/command client-ports** — answers needed *now*: + - **`SubscriptionReader`** — entitlement reads (`current` / `is_active`) used by + `register_network`, daemon JWT minting, and the resource-read view / Tunneller's + `TenantView`. Returns the `SubscriptionView` DTO, never a concrete row. + - **`SubscriptionCommands`** — the one-way **Billing → Subscription** write edge + (`convert_trial_to_paid` / `update_paid` / `mark_past_due` / `cancel`). Billing's + webhook drives license transitions **only** through this; Subscription never calls + Billing (mirrors the Identities → Tenants edge). Only primitives + `common` types + cross — no `stripe_*` id ever reaches Subscription. + - **`BillingPort`** — the composition/Tenants → Billing edge (`start_checkout` / + `billing_portal` / `handle_webhook`). + + In-process adapters are direct method calls; each gets a mesh-mTLS HTTP adapter later + (out of scope here) with no change to the consuming domain code. + +**Data move.** `stripe_customer_id` / `stripe_subscription_id` / `price_id` leave the +`subscriptions` row for a new Billing-owned **`billing_customers`** table keyed by +`(tenant_id, provider)`; the `processed_stripe_events` ledger becomes Billing-owned. +`subscriptions` retains only provider-agnostic license columns. Each crate owns its own +`migrations/` dir (compile-time-relative `sqlx::migrate!`), and the composition root +merges the per-crate `Migrator`s into one ordered history against the single shared DB's +default `_sqlx_migrations` table — a forward-only `billing_customers` create + back-fill +(earlier timestamp) precedes the `subscriptions` drop-columns migration. `SubscriptionView` +drops its `stripe_*` fields accordingly (provider refs surface via Billing's own read +endpoints later). + +## Consequences + +- **The boundary is compiler-enforced.** `billing` cannot reference a `subscriptions` + or `tenants` type; the only shared surface is `wardnet_common` (ports + contract DTOs + + the event bus). A CI guard greps the three manifests/sources so it cannot regress. +- **Reconcile is the correctness guarantee across *every* transport.** Events are a + best-effort fast path; the periodic reconcile re-derives desired state. This holds for + the in-process adapter and any future broker, so **all reactors must be idempotent and + tolerate at-least-once delivery.** The broker buys durability/decoupling, never + correctness. The cross-aggregate reconcile (tenant ↔ license) now lives at the + composition root, since it spans two aggregates. +- **Atomicity relaxes at the seam.** Trial→paid was one DB transaction inside one + aggregate; it is now a Billing write (`billing_customers`) followed by a + `SubscriptionCommands` call. Billing records the provider ref *before* the command so a + retry after a partial failure still maps the subscription to its tenant, and the webhook + ledger only records an event after its effect lands — at-least-once + idempotent, per + the invariant above. +- **Strictly behavior-preserving** for clients: same endpoints, same auth, same DB + semantics. The deploy unit (`wardnet-tenants` binary, its mesh identity + `aud`) is + unchanged; only the Cargo package that owns each binary moved. +- **A future out-of-process split is an adapter + config change**, not a rewrite: swap the + in-proc port adapters for mesh-HTTP ones and the event bus for a broker, and split the + shared DB — the domain code is already blind to which it is talking to.