Repository navigation
[PM-39077] feat: Add extend trial option to the Admin portal - #8506
cyprain-okeke wants to merge 15 commits into
Conversation
Sales and support need to extend an organization's trial without touching Stripe directly. This adds a Trial section to the Admin portal organization page, gated behind the sales-assisted trials feature flag and a dedicated Org_ExtendTrial permission, with a command that moves the Stripe trial end and syncs the organization expiration date. Eligibility is centralized in TrialExtensionPolicy: the subscription must be trialing, have no schedule attached, and have fewer than 30 days remaining; extensions are capped at 1-30 days. Each extension writes an audit log with the acting admin, organization, day count, and before/after trial end dates for FedRAMP traceability.
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Re-reviewed at Code Review DetailsPrevious blocker clearedThe last review requested changes solely because every .NET job was red on Verified and deliberately not raised
Open threads
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8506 +/- ##
==========================================
+ Coverage 66.36% 66.60% +0.24%
==========================================
Files 2578 2584 +6
Lines 110631 110814 +183
Branches 10059 10076 +17
==========================================
+ Hits 73418 73808 +390
+ Misses 34810 34599 -211
- Partials 2403 2407 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Stripe is the source of truth and the subscription.updated webhook re-syncs the organization expiration date. Surfacing the local write failure as a failed extension told the admin to retry, which would extend the Stripe trial a second time.
…oduction-shape tests - TrialExtensionPolicy.GetIneligibilityReason is now the one definition of eligibility; IsEligible and the extend command both use it, so the Edit page and the POST can no longer disagree. - Failed extension attempts (Conflict/Unhandled) log the actor, organization and days, matching the success audit record; Conflict now surfaces the command's message instead of being overwritten by the generic error. - The sync-failure log carries the subscription id and target trial end so an operator can remediate by hand if the webhook also fails. - Tests now cover subscriptions without a Stripe test clock, which is the production shape; mutating the wall-clock fallback fails five tests where it previously failed none. - Fix a comment left stale by the previous commit.
- The extend command resolves the subscription through OrganizationSubscriptionHelpers.TryGetSubscriptionAsync like its sibling commands. A dangling GatewaySubscriptionId now yields a Conflict with the no-subscription message instead of falling through the generic Stripe catch to "please try again". The impossible null-stub test is replaced by a resource_missing test. - The Edit page helper returns the extendable trial end as DateTime? and the model exposes a single ExtendableTrialEnd; the view renders on non-null, which removes the tuple and the unreachable "-" fallback. - Drop the caller-documenting sentence from the model doc comment and replace the dated product note in the controller test with the ticket key.
kdenney
left a comment
There was a problem hiding this comment.
Nice job! I just have one non-blocking question.
| <h2>Trial</h2> | ||
| <dl class="row"> | ||
| <dt class="col-sm-4 col-lg-3">Trial End</dt> | ||
| <dd class="col-sm-8 col-lg-9">@extendableTrialEnd.ToString("yyyy-MM-dd HH:mm") UTC</dd> |
There was a problem hiding this comment.
❓ Should we show the trial end whenever there is a trial, regardless of "CanExtendTrial"? This seems like valuable information to have on the screen, even if they cannot extend for whatever reason. Although if we do, you'd have to go back to having both the bool CanExtendTrial and the TrialEnd separately on the model.
There was a problem hiding this comment.
I'm not very familiar with the ticket but this seems like a good suggestion. For what it's worth, I also like the separate, explicit bool and Date values, rather than using the null to implicitly represent the user's permissions.
There was a problem hiding this comment.
Agreed, done in 7f08625. The Trial section now renders whenever the subscription is trialing and shows the trial end; the extend form appears when eligible, otherwise the reason it is blocked. The model has explicit TrialEnd, CanExtendTrial, and TrialExtensionBlockedReason properties rather than one nullable date. The section is still gated on the feature flag and the Org_ExtendTrial permission so we only hit Stripe for users who can act on it.
| var subscription = await _subscriberService.GetSubscription( | ||
| organization, | ||
| new SubscriptionGetOptions { Expand = ["test_clock"] }); | ||
|
|
||
| return TrialExtensionPolicy.IsEligible(subscription) ? subscription.TrialEnd : null; |
There was a problem hiding this comment.
This is fairly low level Billing code: I would prefer that AC doesn't have to deal with Stripe subscription objects or handle options like { Expand = ["test_clock"] } which we don't have context for.
Can this be put behind an interface, e.g. return await trialExtensionQuery.Run(organization)? (Example only, up to you how you want to structure it.)
| <h2>Trial</h2> | ||
| <dl class="row"> | ||
| <dt class="col-sm-4 col-lg-3">Trial End</dt> | ||
| <dd class="col-sm-8 col-lg-9">@extendableTrialEnd.ToString("yyyy-MM-dd HH:mm") UTC</dd> |
There was a problem hiding this comment.
I'm not very familiar with the ticket but this seems like a good suggestion. For what it's worth, I also like the separate, explicit bool and Date values, rather than using the null to implicitly represent the user's permissions.
…gibility The Admin Edit page no longer touches Stripe subscription objects; it calls IGetOrganizationTrialQuery, which returns the trial end and the reason an extension is blocked (or null when it can be extended). The Trial section now renders whenever the subscription is trialing, with the extend form or the blocked reason beneath it, and the model exposes TrialEnd, CanExtendTrial, and TrialExtensionBlockedReason explicitly instead of a single nullable date. TrialExtensionPolicy.IsEligible had no callers left after the extraction; its unique test cases now assert against GetIneligibilityReason.
| /// When the organization's trialing subscription ends; null when there is no trial or the current user may not | ||
| /// extend trials. Set during the Edit GET. |
There was a problem hiding this comment.
I think this comment is out of date - it is now populated even if the current user cannot extend.
Also Set during the Edit GET is not a useful comment.
|
Please also add screenshots for UI changes. |
Replaces the flattened TrialEnd / CanExtendTrial / TrialExtensionBlockedReason properties with a single Trial property, so the view reads the record directly and the stale property comment goes away. Also drops a stray BOM the view had picked up.
Policy rejections (invalid day count, not trialing, 30 or more days remaining, no subscription) returned a toast but wrote nothing to the log, so a refused attempt left no audit trail. They are now logged at Warning with the actor, organization, requested days and reason; Stripe conflicts and unexpected errors stay at Error.
…nd-organization-trial # Conflicts: # src/Admin/AdminConsole/Controllers/OrganizationsController.cs
eliykat
left a comment
There was a problem hiding this comment.
Feedback is non-blocking, everything else looks good.
| /// The organization's Stripe trial; null when the subscription is not trialing or the current user lacks the | ||
| /// permission to extend trials. |
There was a problem hiding this comment.
Still incorrect; the permission is indicated by Trial.CanExtend, not by this object being nullable.
I think it can just say
| /// The organization's Stripe trial; null when the subscription is not trialing or the current user lacks the | |
| /// permission to extend trials. | |
| /// The organization's Stripe trial; null when they do not have a trial subscription. |
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-39077
📔 Objective
Lets Sales and Support extend an organization's trial from the Admin portal instead of editing the subscription in Stripe.
OrganizationTrialController.ExtendAsync, gated behind thepm-35092-auth-sales-assisted-trialsfeature flag, a newOrg_ExtendTrialpermission (granted to Owner, Admin, Billing, and Sales roles), anti-forgery, and cloud-only.ExtendOrganizationTrialCommand, which moves the Stripe trial end with no proration and then syncs the organization's expiration date so a subsequent Edit save cannot overwrite it before the webhook lands. A failure of that local sync is logged and does not fail the command, since Stripe has already moved the trial end and the subscription webhook re-syncs the date; failing would invite a retry that extends the trial twice.TrialExtensionPolicy: the subscription must be trialing, have no schedule attached, and have fewer than 30 days remaining (measured against the Stripe test clock when one is attached). Extensions are capped at 1 to 30 days.Tests cover the policy, the command (including Stripe and database failure paths), the controller outcomes and audit log, the Edit page eligibility state, the form model validation, the role mapping, and the controller's security attributes.
📸 Screenshots
Trial section on the organization Edit page for a Sales user when the trial can be extended:
Success toast after extending by 7 days:
Trial section when the trial cannot be extended because 30 or more days remain:
Recorded local QA pass (Admin portal and Stripe-side checks):
pm39077-qa-testing-full.mp4
🤖 AI-assisted review
Standard local review (
code-review-local) run on the branch diff againstorigin/main: APPROVE, no inline findings. The reviewer noted one low-confidence concern about the audit log being written after the database sync; addressed by moving the log to immediately after the Stripe update, with a test.The GitHub Claude review then flagged the related retry risk: a failed expiration sync returned Unhandled, and the "please try again" message would have led to a second Stripe extension. Addressed in a160f70 by isolating the sync failure (Error log, command still reports success), with the test updated to pin that behaviour.
A five-aspect local review (code quality, tests, silent failures, comments, type design) then ran over the branch. Its critical and important findings are addressed in 8cbb85a: a single eligibility definition shared by the Edit page and the command, Error logs with actor and organization on failed attempts, a richer sync-failure log, tests for subscriptions without a Stripe test clock (the production shape, previously untested), and one stale comment. Remaining suggestions were judged optional polish and left for reviewer input.
4b3b4c7 pre-empts the likely reviewer asks: the command resolves the subscription through
OrganizationSubscriptionHelpers.TryGetSubscriptionAsyncand returns a Conflict onresource_missinginstead of the generic error; the Edit page exposes a single nullableExtendableTrialEndinstead of a bool plus date; two comments trimmed.7f08625 addresses the human review round: the Edit page reads the trial through a new
IGetOrganizationTrialQueryin Core so the Admin controller no longer handles Stripe subscription objects, the trial end is shown regardless of eligibility with explicitTrialEnd,CanExtendTrial, andTrialExtensionBlockedReasonproperties, and the now-unusedTrialExtensionPolicy.IsEligibleis removed.0914539 addresses the second round: the three flattened trial properties on the Edit model are replaced by a single embedded
OrganizationTrial, which also retires the out-of-date property comment.929473e follows a recorded local QA pass (Admin portal flag on/off, plus Stripe-side checks through the Stripe CLI: trial end delta, no proration, live not-trialing race, subscription schedule, webhook re-sync, test clock; 53 checks passed). The one gap it surfaced was that policy rejections produced a toast but no log line, so a refused attempt left no audit trail. The controller now logs them at Warning, with two tests pinning the actor, organization, days and reason.