MyCase Immigration / Docketwise Integration: 0: Why We Synced · 1: Before/After · 2: OAuth2 · 3: Sync vs Migration · 4: Sync Boundary · 5: Family Graph · 6: Write Patterns · 7: Webhooks · 8: Billing
When we started building the Immigration addon billing flow, the existing addon billing service was not a service. It was a set of methods tied to a single addon ID.
The billing lifecycle logic (trial, conversion, proration, cancellation) was correct and generally applicable. The binding that made it work only for the Accounting addon was not. This post covers what was hardcoded, what the generalization looked like, and one design decision that was not obvious until we looked at the customer reversion path: Stripe soft-deactivation.
What was hardcoded
The original billing code for addon lifecycle events referenced ACCOUNTING_ADDON_ID as a constant in two places: the service that orchestrated lifecycle events and the background jobs that handled billing transitions.
The lifecycle logic itself handled: trial start, trial expiry, conversion from trial to paid, mid-cycle proration when a firm upgraded mid-month, downgrade handling, and cancellation. None of this logic was specific to Accounting. The same lifecycle applied to any addon that had a trial period and a monthly billing cycle.
What was specific to Accounting: the addon ID used to look up the subscription configuration, the billing interval, and the routing to Stripe. Accounting customers are billed via Stripe. That assumption was buried in the service without a routing decision; the service assumed Stripe without checking which provider was configured for the customer.
The Immigration addon introduced a new variable: some Immigration customers (those converting from Docketwise standalone) would be billed via Zuora, not Stripe. The hardcoded Stripe assumption in the Accounting billing service could not handle them without modification.
The generalization
The generalization had three parts.
First: the billing service became a class that accepts an addon record as its constructor argument. Instead of ACCOUNTING_ADDON_ID embedded in the code, the service looks up addon configuration from what it is passed:
class AddonBillingService
def initialize(addon:, firm:)
@addon = addon
@firm = firm
end
def begin_trial
provider_for_firm.create_trial(
firm: @firm,
addon: @addon,
trial_days: @addon.trial_duration_days
)
end
def convert_to_paid
provider_for_firm.charge_subscription(
firm: @firm,
addon: @addon,
amount: @addon.monthly_price
)
end
private
def provider_for_firm
@firm.billing_provider_zuora? ? ZuoraProvider.new : StripeProvider.new
end
end
Second: an addons config table with ActiveRecord records. Each row has the same schema: name, trial_duration_days, monthly_price, billing_interval, active. The Accounting addon row was created from the existing constants. The Immigration addon row was added with its own configuration. No code change is required to add a new addon; it requires a new row in the table and a new addon activation flow.
Third: provider routing inside the service. The provider_for_firm method checks which provider is configured for the firm and delegates to ZuoraProvider or StripeProvider accordingly. Neither provider integration was modified. The service is the routing layer; the providers are the execution layer.
The full-switch Stripe soft-deactivation
A specific customer flow required extra handling: a Docketwise standalone customer converting to MyCase Immigration.
Before the integration, these customers had a Docketwise subscription managed through Stripe. When they converted to the MyCase Immigration addon, the active billing relationship moved to Zuora.
The naive approach: cancel the Stripe subscription, create a Zuora subscription, done.
The problem with cancellation: Stripe subscription cancellation permanently ends the billing relationship. If the customer later decided to cancel the MyCase Immigration addon and revert to standalone Docketwise, they would need to re-enter payment details and go through Stripe subscription creation again. Any billing history attached to the original Stripe subscription would be separated from the new one. The reversion path, which we expected some early customers to take, required full re-onboarding through Stripe.
The soft-deactivation approach: instead of canceling the Stripe subscription, we deactivate it. The subscription moves to a cancel_at_period_end state in Stripe. The subscription record is preserved. The payment method is preserved. The billing history stays attached to the original subscription ID.
When the firm's billing period ends, Stripe does not charge. Zuora becomes the active billing relationship for the Immigration addon.
If the customer later cancels the Immigration addon and reverts to standalone Docketwise:
- Cancel the Zuora subscription.
- Reactivate the Stripe subscription by removing the
cancel_at_period_endflag and setting it back to active. - Stripe resumes billing at the next cycle.
The customer sees no re-onboarding flow. Their billing history in Stripe is continuous. Their payment method does not need to be re-entered.
Without soft-deactivation, a customer who converted and then reverted would have needed to start a new Stripe subscription from scratch. That friction was a meaningful deterrent to letting customers try the integrated product. Preserving the reversion path via soft-deactivation made conversion less risky from the customer's perspective, which mattered during the early access period when we expected higher churn than at GA.
The billing transition in code
The convert-to-immigration flow in the Rails backend:
class ConvertToImmigrationAddon
def initialize(firm:)
@firm = firm
@immigration_addon = Addon.find_by!(name: 'immigration')
end
def call
ActiveRecord::Base.transaction do
soft_deactivate_stripe_subscription
@firm.update!(billing_provider: :zuora)
AddonBillingService.new(addon: @immigration_addon, firm: @firm).begin_trial
end
end
private
def soft_deactivate_stripe_subscription
return unless @firm.stripe_subscription_id.present?
StripeProvider.new.deactivate_at_period_end(
subscription_id: @firm.stripe_subscription_id
)
end
end
The transaction wraps the Stripe deactivation, the provider switch, and the Zuora trial creation. If any step fails, the firm remains on Stripe with no changes. Stripe deactivation is called first; if it raises, the firm's provider is not changed and no Zuora subscription is started.
The reversion path:
class RevertToStandaloneDocketwise
def initialize(firm:)
@firm = firm
@immigration_addon = Addon.find_by!(name: 'immigration')
end
def call
ActiveRecord::Base.transaction do
AddonBillingService.new(addon: @immigration_addon, firm: @firm).cancel
@firm.update!(billing_provider: :stripe)
reactivate_stripe_subscription
end
end
private
def reactivate_stripe_subscription
return unless @firm.stripe_subscription_id.present?
StripeProvider.new.reactivate(subscription_id: @firm.stripe_subscription_id)
end
end
The reactivation calls Stripe's subscription update endpoint to remove the cancel_at_period_end flag. The subscription resumes normal billing.
Regression protection
After the generalization, the Accounting addon continued working. The regression test was: run the Accounting addon's existing billing test suite unchanged and confirm all tests pass.
The test suite covered trial start, conversion, proration, downgrade, and cancellation for the Accounting addon. After the generalization, AddonBillingService received the Accounting addon record as its argument instead of using ACCOUNTING_ADDON_ID directly. The behavior was identical. All tests passed without modification.
This was the validation that the generalization was correctly scoped. The lifecycle logic changed in form (parameterized instead of hardcoded) but not in behavior for the existing case. Adding a new addon did not change what the existing addon did.
What the billing refactor did not touch
The Stripe integration code was not modified. The Zuora integration code was not modified. Neither provider's API client, authentication, or payload construction changed.
The AddonBillingService sits above both provider integrations as an orchestration layer. It makes decisions about lifecycle state (is this firm in a trial? has the trial expired? is this a proration?) and delegates the actual payment operations to whichever provider is appropriate for the firm.
Adding a third addon in the future requires: a new row in the addons config table, a new activation flow, and confirmation that AddonBillingService handles the new addon's configuration correctly. No provider integration changes. No new service class. No new background job types.
The billing generalization satisfied the design constraint without a metric to attach to it. No customer was disrupted during the transition. Both providers kept running. Standalone Docketwise customers converting to MyCase Immigration saw a continuous billing experience. That is what the constraint required.
This is the final post in the series. If you came here for the webhook ordering fix, that is Part 7. If you came for the sync vs. migration decision, that is Part 3. If you came for the family graph schema problem, that is Part 5. Each post stands alone; this post is also where the billing side of the series closes.


Top comments (0)