feat(tiers): verify early-adopter/standard trials + add members-by-tier admin view #314

Merged
longjacksonle merged 7 commits from feat/BUNYIP-291-membership-tiers into main 2026-07-01 23:25:17 +02:00

What

BUNYIP-291: verify the early-adopter (90-day) vs standard (30-day) trial assignment and the no-second-trial-on-resubscribe behavior, merge the duplicate cancel control, and add the net-new admin members-by-tier view.

AC1 - trial assignment reads settings, not constants (verified + tested)

The signup grant already read early_adopter_slots / early_adopter_trial_days / standard_trial_days from the admin tier settings, not a hardcoded constant. To lock and test the 90-vs-30 boundary without a database, the inline logic is extracted into pure methods on SubscriptionTier: select (lifetime slots first, then early-adopter, then standard; exclusive boundary) and trial_days (windows from settings; None for lifetime/free). Both call sites (AuthService::maybe_grant_initial_tier and UserRepository::assign_subscription_tier) now use them, so behavior is unchanged and single-sourced. Unit tests pin the boundary and that lengths flow from config.

AC2 - trial labeled at signup

The InitialTierGranted audit entry now records trial_days and a human trial_label ("Early Adopter - 90-day trial" / "Standard - 30-day trial"). The membership page shows a tier-labeled trial banner instead of only a generic "Trial" badge.

AC3 - single cancel control

The page rendered two sibling cancel buttons ("Cancel Membership" at period end, "Cancel Now" immediate). Per product decision they are merged into one "Cancel Membership" disclosure that offers both modes as a labeled choice ("Cancel at period end - keep access until " / "Cancel immediately - lose access now"), reusing the existing /membership/cancel and /membership/cancel-now handlers. One visible cancel control; underlying behavior unchanged.

AC3 (resubscribe) / AC5 - no second free trial

Verified: checkout eligibility is !has_used_trial, and has_used_trial is set by the checkout.session.completed webhook once a trial session finalizes, so a cancel + resubscribe bills immediately. Extracted as User::trial_eligible() (computed before the customer lookup partially moves the user) with a unit test covering first-timer vs returning member.

AC4 - members-by-tier admin view (net-new)

  • API: GET /v1/admin/memberships gains an optional tier filter (early_adopter / standard / lifetime / free) taking precedence over status, ordered created_at ASC so early-adopter slot holders read in claim order (occupancy is referenceable via the existing tier column; no change to grant logic needed). Query held as a testable const.
  • Web: the admin Memberships page gains tier filter tabs and, on the Early Adopter tab, an occupancy banner ("N of M early-adopter slots filled" + "All slots filled" badge when full) sourced from the tier config; the list below shows who holds each slot. Pager carries the active tier.

Tests

cargo fmt --all --check, cargo clippy --workspace --all-targets (clean), and cargo test --workspace --lib all pass. New unit tests: slot boundary, trial-days-from-settings, trial label, resubscribe-no-new-trial, and the members-by-tier SQL (tier predicate + soft-delete exclusion + claim-order sort).

Closes BUNYIP-291.

## What BUNYIP-291: verify the early-adopter (90-day) vs standard (30-day) trial assignment and the no-second-trial-on-resubscribe behavior, merge the duplicate cancel control, and add the net-new admin members-by-tier view. ## AC1 - trial assignment reads settings, not constants (verified + tested) The signup grant already read `early_adopter_slots` / `early_adopter_trial_days` / `standard_trial_days` from the admin tier settings, not a hardcoded constant. To lock and test the 90-vs-30 boundary without a database, the inline logic is extracted into pure methods on `SubscriptionTier`: `select` (lifetime slots first, then early-adopter, then standard; exclusive boundary) and `trial_days` (windows from settings; `None` for lifetime/free). Both call sites (`AuthService::maybe_grant_initial_tier` and `UserRepository::assign_subscription_tier`) now use them, so behavior is unchanged and single-sourced. Unit tests pin the boundary and that lengths flow from config. ## AC2 - trial labeled at signup The `InitialTierGranted` audit entry now records `trial_days` and a human `trial_label` ("Early Adopter - 90-day trial" / "Standard - 30-day trial"). The membership page shows a tier-labeled trial banner instead of only a generic "Trial" badge. ## AC3 - single cancel control The page rendered two sibling cancel buttons ("Cancel Membership" at period end, "Cancel Now" immediate). Per product decision they are merged into one "Cancel Membership" disclosure that offers both modes as a labeled choice ("Cancel at period end - keep access until <date>" / "Cancel immediately - lose access now"), reusing the existing `/membership/cancel` and `/membership/cancel-now` handlers. One visible cancel control; underlying behavior unchanged. ## AC3 (resubscribe) / AC5 - no second free trial Verified: checkout eligibility is `!has_used_trial`, and `has_used_trial` is set by the `checkout.session.completed` webhook once a trial session finalizes, so a cancel + resubscribe bills immediately. Extracted as `User::trial_eligible()` (computed before the customer lookup partially moves the user) with a unit test covering first-timer vs returning member. ## AC4 - members-by-tier admin view (net-new) - API: `GET /v1/admin/memberships` gains an optional `tier` filter (early_adopter / standard / lifetime / free) taking precedence over `status`, ordered `created_at ASC` so early-adopter slot holders read in claim order (occupancy is referenceable via the existing tier column; no change to grant logic needed). Query held as a testable const. - Web: the admin Memberships page gains tier filter tabs and, on the Early Adopter tab, an occupancy banner ("N of M early-adopter slots filled" + "All slots filled" badge when full) sourced from the tier config; the list below shows who holds each slot. Pager carries the active tier. ## Tests `cargo fmt --all --check`, `cargo clippy --workspace --all-targets` (clean), and `cargo test --workspace --lib` all pass. New unit tests: slot boundary, trial-days-from-settings, trial label, resubscribe-no-new-trial, and the members-by-tier SQL (tier predicate + soft-delete exclusion + claim-order sort). Closes BUNYIP-291.
BUNYIP-291 AC1. The tier a newly-verified account receives and its trial length were computed inline inside the tier-grant transaction (auth.rs) and the assign_subscription_tier repo write, so the 90-vs-30 boundary could only be exercised with a live database. Extract them as pure methods on SubscriptionTier: `select` (lifetime slots first, then early-adopter, then standard; exclusive boundary) and `trial_days` (reads the admin-configured windows, None for lifetime/free). Also add `display_name` and a `trial_label` helper for AC2. Wire both callers to the new helpers so behavior is unchanged and single-sourced. Unit tests pin the slot boundary and that trial lengths flow from settings, not a hardcoded constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUNYIP-291 AC2. Two places now name which trial an account received rather than leaving it implicit:

- The InitialTierGranted audit entry records `trial_days` and a human `trial_label` ("Early Adopter - 90-day trial" / "Standard - 30-day trial") alongside the tier, so the grant is auditable at signup time.
- The membership page shows a tier-labeled trial banner ("Early Adopter trial - N days remaining" / "Standard trial - ...") instead of only the generic "Trial" badge, so the member can see which trial they hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUNYIP-291 AC3. The subscriptions page rendered two sibling cancel buttons ("Cancel Membership" = at period end, "Cancel Now" = immediate). Replace them with a single "Cancel Membership" disclosure that, when opened, offers the two distinct modes as a labeled choice: "Cancel at period end - keep access until <date>" and "Cancel immediately - lose access now". Both reuse the existing /membership/cancel and /membership/cancel-now handlers, so the underlying behavior is unchanged; the page now presents exactly one visible cancel control.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUNYIP-291 AC4 (backend). GET /v1/admin/memberships gains an optional `tier` query param that lists all non-deleted holders of a tier (early_adopter / standard / lifetime / free), taking precedence over the existing `status` filter. Results are ordered created_at ASC so early-adopter slot holders read in the deterministic order they claimed their slots, making occupancy referenceable (the ticket's "stably ordered" requirement; the tier column already identifies holders, so no change to the grant logic is needed). The query is held as a testable const with a unit test pinning the tier predicate, soft-delete exclusion, and claim-order sort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUNYIP-291 AC4 (frontend). The admin Memberships page gains tier filter tabs (All / Early Adopter / Standard / Lifetime) that drive the new API `tier` filter; unknown tier slugs fall back to the unfiltered list. When viewing the Early Adopter tab it fetches the tier config and shows an occupancy banner ("N of M early-adopter slots filled", plus an "All slots filled" badge when the pool is full), so an admin can see whether every slot is taken and, in the list below (ordered by claim order server-side), exactly who holds them. The pager carries the active tier so paging stays within the filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BUNYIP-291 AC5 (regression on BUNYIP-225). The checkout handler decided trial eligibility with an inline `!db_user.has_used_trial`. Extract it as `User::trial_eligible()` with a comment tying it to BUNYIP-209/225, compute it before the customer lookup partially moves the user, and add a unit test asserting a first-timer is eligible while a returning member (has_used_trial = true, e.g. after a cancel + resubscribe) is not. Behavior is unchanged; the rule is now documented and covered without needing Stripe or a database.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
style: satisfy rustfmt and clippy for the tier changes
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 30s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m11s
Create release / Create release from merged PR (pull_request) Has been skipped
8a3af52361
Apply rustfmt wrapping to the new SubscriptionTier helper signatures, and rewrite the members-by-tier query-param match as distinct arms. Clippy's manual_unwrap_or_default suggestion on the original `Some(t @ (...))` form was a false positive: collapsing it to unwrap_or_default() would let an unknown tier slug pass through instead of falling back to the unfiltered list, so the explicit arms preserve the validation while clearing the lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-01 23:18:56 +02:00
longjacksonle deleted branch feat/BUNYIP-291-membership-tiers 2026-07-01 23:25:17 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!314
No description provided.