[PM-39431] feat: Implement SendInvoicePriceMigrationJob - #8086
[PM-39431] feat: Implement SendInvoicePriceMigrationJob#8086cyprain-okeke wants to merge 51 commits into
Conversation
…nt stored procedure
Re-add the free-tier and Teams Starter dispatch tests dropped during the handler refactor; both cover live arms of the product-tier switch. Rename Declines->ReturnsFalse and Suppresses->DoesNotSend for clarity.
send_invoice organizations never emit invoice.upcoming, so the webhook path never schedules their business-plan price migration (PM-38728). This adds a daily, cloud-only Quartz sweep that selects cohort organizations renewing in a 7-15 day window, re-verifies the collection method and renewal date against the live Stripe subscription (using the test clock's frozen time when present), and delegates the schedule -> notify -> stamp flow to IBusinessPlanMigrationCoordinator so the two paths cannot drift. Gated behind the new PM38728_SendInvoicePriceMigration feature flag.
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Re-reviewed at Code Review DetailsNo findings. Notes carried forward, informational only:
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #8086 +/- ##
==========================================
+ Coverage 62.83% 62.88% +0.04%
==========================================
Files 2299 2300 +1
Lines 100138 100332 +194
Branches 9012 9030 +18
==========================================
+ Hits 62923 63089 +166
- Misses 35030 35051 +21
- Partials 2185 2192 +7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The webhook path never sees a subscription that will not renew because Stripe emits no invoice.upcoming for it; the sweep selects on local data and must confirm renewal explicitly, or a cancelled organization gets a renewal-price email and a schedule extending past its cancellation. past_due stays eligible for parity with the webhook path.
| return true; | ||
| } | ||
| case BusinessPlanMigrationResult.NotScheduled: | ||
| _logger.LogWarning( |
There was a problem hiding this comment.
⛏️ In the unlikely scenario the schedule gets created, but we get a transient DB error following that, we'll be in a weird state that will hit this case. Might be worth using LogError here to make sure we're seeing it.
|
|
||
| private async Task<bool> ProcessAssignmentAsync(OrganizationPlanMigrationCohortAssignment assignment) | ||
| { | ||
| var organization = await organizationRepository.GetByIdAsync(assignment.OrganizationId); |
There was a problem hiding this comment.
❓ I think we might be missing a guard against churn-only cohorts here? Might be worth adding one:
var cohort = await cohortRepository.GetByIdAsync(assignment.CohortId);
if (cohort?.MigrationPathId is null)
{
return false;
}…ement-send-invoice-price-migration-job # Conflicts: # src/Core/Billing/Organizations/PlanMigration/Services/BusinessPlanMigrationCoordinator.cs # test/Core.Test/Billing/Organizations/PlanMigration/Services/BusinessPlanMigrationCoordinatorTests.cs
amorask-bitwarden
left a comment
There was a problem hiding this comment.
The changes look good, but I have concerns around how this will be tested. As written, this job will only fire one time per day at 8 AM UTC.
We used to have a JobsController that @kdenney wrote up to test a previous job using an endpoint in lower environments. It was removed here: #7424
I think it would be worth bringing that back in this PR to make sure QA can test this job more than once a day given how complex it is and the due date on the work itself.
All you'd have to do is:
- Add
src/Billing/Controllers/JobsController.cswith[Route("jobs")],[SelfHosted(NotSelfHostedOnly = true)]and[RequireLowerEnvironment], no shared secret — same shape as #6615. - Add
TriggerJobNowAsync<T>()toBaseJobsHostedServicecalling_scheduler.TriggerJob(new JobKey(typeof(T).FullName!)), and call that from the controller instead ofRunJobAdHocAsync. - Add
[DisallowConcurrentExecution]toSendInvoicePriceMigrationJob.
🎟️ Tracking
PM-39431
📔 Objective
send_invoiceorganizations never emit Stripe'sinvoice.upcomingevent (PM-38728), soUpcomingInvoiceHandlernever schedules their business-plan price migration and they would renew at the old price without notice. This addsSendInvoicePriceMigrationJob— a daily (08:00 UTC), cloud-only Quartz sweep that:PM38728_SendInvoicePriceMigrationflag is off.GetSendInvoiceCandidatesInWindowAsync(7, 15)(PM-39236's repository method — active cohort, not migrated, not yet complete, gateway IDs present,ExpirationDatein window).collection_method == send_invoiceandGetCurrentPeriodEnd()still in the 7–15 day window, usingTestClock.FrozenTimewhen present so test-clock subscriptions evaluate correctly (Organization.ExpirationDateis Admin-Portal-editable and can drift).IBusinessPlanMigrationCoordinator([PM-39430] Extract shared business-plan migration coordinator #8050), so the webhook and sweep paths cannot drift. The job maps the fiveBusinessPlanMigrationResultstates to deliberate log severities (CompletedWithoutNotification→ Error with a last-retry-day escalation, since re-selection ends when the org exits the window).StripeExceptionlogged withStripeError.Code); shutdown cancellation logs partial counters at Warning instead of a spurious error.Stacked PR: based on #8050's branch; only the four files above are new — no files owned by #8050 are touched. Coupling surface is
ExecuteAsync(Organization, Subscription)+ the result enum.Deviations from the ticket's technical breakdown (contract drift, both discussed on the ticket):
IBusinessPlanMigrationProcessor.ProcessAsyncshipped asIBusinessPlanMigrationCoordinator.ExecuteAsyncin #8050;IFeatureService.IsEnabledAsyncdoesn't exist (synchronousIsEnabled). The skeleton's defensiveGatewayCustomerIdre-check is deliberately omitted — nothing downstream reads it (the scheduler works offsubscription.CustomerId).Testing: 24 unit tests (selection args, all re-check skips incl. exact 7/15-day boundaries via frozen clock, coordinator delegation, expansion pinning, concurrency bounds, per-item continuation, summary counters, log-severity pins). Also verified end-to-end locally against a real Stripe test clock: happy path (2-phase schedule, both stamps, renewal email with plan-derived total), email-retry (re-send without re-scheduling), collection-method flip skip, selection variants against the real sproc, and flag-off no-op.
Pre-merge checklist:
pm-38728-send-invoice-price-migrationcreated, default off in all environmentsPM35215_BusinessPlanPriceMigrationon — with only PM38728 enabled, every candidate logs a dailyNotScheduledwarning and burns a Stripe call2026-07-23_02_AddRenewalNotificationSentDate…applied (sweep depends on PM-39236's column + sproc)main, re-rundotnet test test/Billing.Test, undraft