Skip to content

feat(billing): split license/payment into extraction-ready crates + inter-crate ports (#16)#25

Merged
pedromvgomes merged 3 commits into
mainfrom
feature/rimrock-arroyo
Jun 28, 2026
Merged

feat(billing): split license/payment into extraction-ready crates + inter-crate ports (#16)#25
pedromvgomes merged 3 commits into
mainfrom
feature/rimrock-arroyo

Conversation

@pedromvgomes

Copy link
Copy Markdown
Contributor

Closes #16. PR1 of the My Account initiative. Separates the license (Subscription) and payment (Billing/Stripe) concerns that were intermingled in the tenants crate into three mutually-independent aggregate crates, talking only through ports in wardnet_common. Strictly behavior-preserving — pure structural move + port introduction. See docs/adr/0010 (supersedes 0006, refines 0007).

The boundary (the point of the PR)

tenants, subscriptions, billing each depend on wardnet_common and never on each other — compiler-enforced, with a CI guard (build-service.yml) that greps for regressions. The only crate that depends on all three is the composition root. That seam is exactly what becomes a network boundary if an aggregate is later promoted to its own host.

Crates

  • subscriptions — provider-agnostic license aggregate (status/entitlement, trial+grace, reaper). No Stripe symbols. Implements SubscriptionReader / SubscriptionCommands.
  • billing — payment aggregate (StripeGateway, Checkout/Portal, webhook + signature verify, processed_stripe_events ledger, new Billing-owned billing_customers table the stripe_* columns moved into). Drives the license only through the ports; never calls back.
  • tenants — identity/networks/daemons/enrollment + Identities; holds Arc<dyn SubscriptionReader>.

Ports (wardnet_common)

  • EventBus / EventStream — transport-free (no tokio type in any signature); ships the in-process broadcast adapter only. DomainEvent is serde + versioned for a future broker. Replaces the tokio-leaking EventPublisher.
  • SubscriptionReader (entitlement reads), SubscriptionCommands (the one-way Billing→Subscription edge), BillingPort (checkout/portal/webhook).

Composition + packaging

  • Uniform lib/bin split across all services: each domain is a pure lib (crates/<svc>), a thin bin (crates/app-<svc>) composes it. Binary artifact names unchanged (wardnet-tenants/wardnet-ddns/wardnet-tunneller) — deploy units untouched; only the Cargo package that owns each binary moved, and build-service.yml + Dockerfiles + detect-changes were retargeted accordingly.
  • Per-crate migrations/ dirs, merged into one ordered history by the composition root against the default _sqlx_migrations table (no dangerous_set_table_name). Forward-only billing_customers create + back-fill precedes the subscriptions drop-columns migration.
  • Cross-aggregate reconcile moved to the composition root.

Data move

stripe_customer_id / stripe_subscription_id / price_id leave the subscriptions row for the Billing-owned billing_customers table (keyed by tenant_id + provider); the processed_stripe_events ledger is Billing-owned. SubscriptionView drops its stripe_* fields (provider refs surface via Billing's read endpoints in PR2).

Acceptance criteria

  • Three separate workspace crates; cargo build + cargo clippy --all-targets (pedantic) clean
  • No Stripe symbol referenced from subscriptions
  • EventBus/EventStream ports + in-proc adapter; no broadcast::Receiver in any public port signature
  • SubscriptionReader + SubscriptionCommands; Billing's webhook drives Subscription only through SubscriptionCommands
  • stripe_* columns gone from subscriptions; billing_customers migrated + back-filled (live-row-preferred)
  • Behavior-preserving: full suite passes (303 passed, 24 ignored)
  • New ADR (supersedes 0006) + CONTEXT.md updates

Reviews

Two /code-review passes; all findings fixed in-branch. Notably the second pass caught a real bug introduced during the rework: the webhook recorded the subscription→tenant mapping before the decline-to-grant check, so a misconfigured (no-metadata) plan left a mapping that a later .deleted/.payment_failed used to cancel the tenant's live trial. Fixed (record only after committing to grant) and guarded by new tests.

Out of scope (later PRs)

Billing read endpoints (PR2), account/security endpoints (PR3), the SPA (PR4), and the actual broker + mesh-HTTP port adapters (the ports are defined; only the in-proc adapters ship here).

https://claude.ai/code/session_01DX7SWGnBBRxga9L74hycXB

…nter-crate ports (#16)

Separate the **license** (Subscription) and **payment** (Billing/Stripe) concerns
that were intermingled inside the `tenants` crate into three mutually-independent
aggregate crates, talking only through ports in `wardnet_common`. Strictly
behavior-preserving — pure structural move + port introduction. PR1 of the My
Account initiative. See docs/adr/0010 (supersedes 0006, refines 0007).

Crates (compiler-enforced boundary: each depends on `wardnet_common`, never on a
sibling; a CI guard greps for regressions):
- `subscriptions` — the provider-agnostic license aggregate (status/entitlement,
  trial+grace, reaper). No Stripe symbols. Implements the SubscriptionReader /
  SubscriptionCommands ports.
- `billing` — the payment aggregate (StripeGateway, Checkout/Portal, webhook +
  signature verify, the processed_stripe_events ledger, and a new Billing-owned
  `billing_customers` table the `stripe_*` columns moved into). Drives the license
  only through the ports; never calls back.
- `tenants` — identity/networks/daemons/enrollment + Identities; holds
  `Arc<dyn SubscriptionReader>`.

Ports (in `wardnet_common`):
- EventBus / EventStream — transport-free (no tokio type in any signature); ships
  the in-process broadcast adapter only. DomainEvent is serde + versioned for a
  future broker. Replaces the tokio-leaking EventPublisher.
- SubscriptionReader (entitlement reads), SubscriptionCommands (the one-way
  Billing→Subscription edge), BillingPort (checkout/portal/webhook).

Composition + packaging:
- Uniform lib/bin split across all services: each domain is a pure lib
  (`crates/<svc>`) and a thin bin (`crates/app-<svc>`); binary artifact names
  unchanged. `app-tenants` is the sole crate depending on all three aggregates —
  it injects the concrete services as `dyn` ports and owns the cross-aggregate
  reconcile.
- Per-crate `migrations/` dirs, merged into one ordered history by the composition
  root against the default `_sqlx_migrations` table (no dangerous_set_table_name);
  forward-only billing_customers create+backfill precedes the subscriptions
  drop-columns migration.

Tests reworked into the composition crate (shared Harness in tests/common/mod.rs);
gateway/license unit tests recreated inside billing/subscriptions. Webhook
apply records the provider ref only after committing to grant (a declined plan
records nothing), guarded by new tests. `cargo fmt`/`clippy --all-targets`/`test`
green; AGENTS.md + CONTEXT.md updated.

Claude-Session: https://claude.ai/code/session_01DX7SWGnBBRxga9L74hycXB
@codecov

codecov Bot commented Jun 28, 2026

Copy link
Copy Markdown

Clears the cargo-audit failure. RUSTSEC-2026-0185 is a freshly-published advisory
against quinn-proto 0.11.14, which was already present on main (a lockfile-only,
unbuilt optional transitive of reqwest — `cargo tree -i quinn-proto` is empty), so
it was not introduced by this branch. 0.11.15 is the patched release; `cargo audit`
is clean afterwards. Lock-only change, no binary impact.

Claude-Session: https://claude.ai/code/session_01DX7SWGnBBRxga9L74hycXB
…ints

The crate split moved process-wiring main.rs files into thin app-* bin crates
(composition roots exercised by the e2e harness, not unit tests), dragging patch
coverage 0.08% under codecov's auto target (70.25% vs 70.33%). Exclude those
entrypoints from coverage accounting and add a 1% status threshold so sub-percent
noise no longer fails the (non-required) codecov status. The required
"All checks passed" aggregator is unaffected.

Claude-Session: https://claude.ai/code/session_01DX7SWGnBBRxga9L74hycXB
@pedromvgomes pedromvgomes merged commit a5bf4c2 into main Jun 28, 2026
16 checks passed
@pedromvgomes pedromvgomes deleted the feature/rimrock-arroyo branch June 28, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PR1 — Split billing/subscription into extraction-ready crates + inter-crate ports

1 participant