Sync email to Stripe when user changes email address (#2432)

* Sync owner email to Stripe when changed

When an account owner changes their email address, update the
corresponding Stripe customer record via a background job.

Also handles ownership transfers: when a user becomes the account
owner, their email is synced to Stripe.

Responsibility chain:
- User::NotifiesAccountOfEmailChange triggers on owner identity change
  or when a user becomes owner
- Account::Billing#owner_email_changed enqueues sync job
- Account::SyncStripeCustomerEmailJob performs the update with
  polynomial backoff retries
- Account::Subscription#sync_customer_email_to_stripe calls Stripe API

* Address PR feedback: error handling and test coverage

- Handle Stripe::InvalidRequestError in sync_customer_email_to_stripe
  (mirrors cancel method behavior for deleted customers)
- Add test for deactivated owner (owner with nil identity)
- Add test for deleted Stripe customer scenario
This commit is contained in:
Jeremy Daer
2026-01-25 14:20:39 -08:00
committed by GitHub
parent 4efa4da809
commit f2ff5af4f5
8 changed files with 200 additions and 0 deletions
@@ -0,0 +1,8 @@
class Account::SyncStripeCustomerEmailJob < ApplicationJob
queue_as :default
retry_on Stripe::StripeError, wait: :polynomially_longer
def perform(subscription)
subscription.sync_customer_email_to_stripe
end
end
+4
View File
@@ -31,6 +31,10 @@ module Account::Billing
reload_billing_waiver
end
def owner_email_changed
Account::SyncStripeCustomerEmailJob.perform_later(subscription) if subscription
end
private
def active_subscription
if comped?
+17
View File
@@ -47,4 +47,21 @@ class Account::Subscription < SaasRecord
# Subscription already deleted/canceled in Stripe - treat as success
Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}"
end
def sync_customer_email_to_stripe
if stripe_customer_id && (email = owner_email)
Stripe::Customer.update(stripe_customer_id, email: email)
end
rescue Stripe::InvalidRequestError => e
# Customer already deleted in Stripe - treat as success
Rails.logger.warn "Stripe customer #{stripe_customer_id} not found during email sync: #{e.message}"
end
private
# Account owner email for Stripe customer record. Returns nil when:
# - No owner exists (ownership being transferred, account in limbo)
# - Owner has no identity (deactivated user)
def owner_email
account.users.owner.first&.identity&.email_address
end
end
@@ -0,0 +1,23 @@
module User::NotifiesAccountOfEmailChange
extend ActiveSupport::Concern
included do
after_update :notify_account_of_owner_change, if: :account_owner_changed?
end
private
# Account owner changed when:
# - The current owner changed their email
# - A user just became the owner (ownership transfer)
def account_owner_changed?
owner? && identity && (saved_change_to_identity_id? || became_owner?)
end
def became_owner?
saved_change_to_role? && role_before_last_save != "owner"
end
def notify_account_of_owner_change
account.owner_email_changed
end
end
+1
View File
@@ -133,6 +133,7 @@ module Fizzy
config.to_prepare do
::Account.include Account::Billing, Account::Limited
::User.include User::NotifiesAccountOfEmailChange
::Signup.prepend Fizzy::Saas::Signup
CardsController.include(Card::LimitedCreation)
Cards::PublishesController.include(Card::LimitedPublishing)
+22
View File
@@ -68,4 +68,26 @@ class Account::BillingTest < ActiveSupport::TestCase
account.incinerate
end
test "owner_email_changed enqueues sync job when subscription exists" do
account = accounts(:"37s")
account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
assert_enqueued_with(job: Account::SyncStripeCustomerEmailJob, args: [ account.subscription ]) do
account.owner_email_changed
end
end
test "owner_email_changed does nothing without subscription" do
account = accounts(:initech)
account.subscription&.destroy
assert_no_enqueued_jobs do
account.owner_email_changed
end
end
end
@@ -117,4 +117,78 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
subscription.cancel
end
end
test "sync_customer_email_to_stripe updates Stripe customer with owner email" do
account = accounts(:"37s")
owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
subscription = account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.expects(:update).with("cus_test", email: owner.identity.email_address).once
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe does nothing without stripe_customer_id" do
account = accounts(:"37s")
subscription = account.build_subscription(
stripe_customer_id: nil,
plan_key: "free_v1",
status: "active"
)
Stripe::Customer.expects(:update).never
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe does nothing without owner" do
account = accounts(:"37s")
account.users.update_all(role: :member)
subscription = account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.expects(:update).never
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe does nothing when owner has no identity" do
account = accounts(:"37s")
owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
owner.update_column(:identity_id, nil)
subscription = account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.expects(:update).never
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe treats deleted customer as success" do
account = accounts(:"37s")
account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
subscription = account.create_subscription!(
stripe_customer_id: "cus_deleted",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.stubs(:update).raises(
Stripe::InvalidRequestError.new("No such customer", {})
)
assert_nothing_raised do
subscription.sync_customer_email_to_stripe
end
end
end
@@ -0,0 +1,51 @@
require "test_helper"
class User::NotifiesAccountOfEmailChangeTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@owner = @account.users.find_by(role: :owner) || @account.users.first.tap { |u| u.update!(role: :owner) }
@member = @account.users.where.not(id: @owner.id).first || @account.users.create!(
name: "Member",
identity: Identity.create!(email_address: "member@example.com"),
role: :member
)
end
test "notifies account when owner changes email" do
@account.expects(:owner_email_changed).once
new_identity = Identity.create!(email_address: "new-owner@example.com")
@owner.update!(identity: new_identity)
end
test "does not notify account when non-owner changes email" do
@account.expects(:owner_email_changed).never
new_identity = Identity.create!(email_address: "new-member@example.com")
@member.update!(identity: new_identity)
end
test "does not notify account when owner is deactivated" do
@account.expects(:owner_email_changed).never
@owner.update!(identity: nil)
end
test "does not notify account when identity unchanged" do
@account.expects(:owner_email_changed).never
@owner.update!(name: "New Name")
end
test "notifies account when user becomes owner" do
@account.expects(:owner_email_changed).once
@member.update!(role: :owner)
end
test "does not notify account when owner becomes member" do
@account.expects(:owner_email_changed).never
@owner.update!(role: :member)
end
end