From 6ccb0d18f9a0b91324c7e6aad920af6d337552a6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 25 Jan 2026 01:04:45 -0800 Subject: [PATCH 1/2] 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 --- .../account/sync_stripe_customer_email_job.rb | 8 +++ saas/app/models/account/billing.rb | 4 ++ saas/app/models/account/subscription.rb | 14 +++++ .../user/notifies_account_of_email_change.rb | 23 +++++++++ saas/lib/fizzy/saas/engine.rb | 1 + saas/test/models/account/billing_test.rb | 22 ++++++++ saas/test/models/account/subscription_test.rb | 41 +++++++++++++++ .../notifies_account_of_email_change_test.rb | 51 +++++++++++++++++++ 8 files changed, 164 insertions(+) create mode 100644 saas/app/jobs/account/sync_stripe_customer_email_job.rb create mode 100644 saas/app/models/user/notifies_account_of_email_change.rb create mode 100644 saas/test/models/user/notifies_account_of_email_change_test.rb diff --git a/saas/app/jobs/account/sync_stripe_customer_email_job.rb b/saas/app/jobs/account/sync_stripe_customer_email_job.rb new file mode 100644 index 0000000000..b84eafa767 --- /dev/null +++ b/saas/app/jobs/account/sync_stripe_customer_email_job.rb @@ -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 diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index ab4b75a06f..02886581a0 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -31,6 +31,10 @@ def uncomp reload_billing_waiver end + def owner_email_changed + Account::SyncStripeCustomerEmailJob.perform_later(subscription) if subscription + end + private def active_subscription if comped? diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 6a8bea0ef5..1ef8c7dc82 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -47,4 +47,18 @@ def cancel # 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 + 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 diff --git a/saas/app/models/user/notifies_account_of_email_change.rb b/saas/app/models/user/notifies_account_of_email_change.rb new file mode 100644 index 0000000000..daba4ba3df --- /dev/null +++ b/saas/app/models/user/notifies_account_of_email_change.rb @@ -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 diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 757de6bebc..d4d461e8d1 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -133,6 +133,7 @@ class Engine < ::Rails::Engine 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) diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 2fe923c1a5..0e70d37a53 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -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 diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb index ff5b0837af..e822fc12f0 100644 --- a/saas/test/models/account/subscription_test.rb +++ b/saas/test/models/account/subscription_test.rb @@ -117,4 +117,45 @@ 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 end diff --git a/saas/test/models/user/notifies_account_of_email_change_test.rb b/saas/test/models/user/notifies_account_of_email_change_test.rb new file mode 100644 index 0000000000..442a89fffe --- /dev/null +++ b/saas/test/models/user/notifies_account_of_email_change_test.rb @@ -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 From 6e8f44a0eaa1bb7798855f7d9dce7c39ac3ab8eb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 25 Jan 2026 02:16:11 -0800 Subject: [PATCH 2/2] 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 --- saas/app/models/account/subscription.rb | 3 ++ saas/test/models/account/subscription_test.rb | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 1ef8c7dc82..2ae7e3171f 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -52,6 +52,9 @@ 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 diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb index e822fc12f0..eaf7e103d4 100644 --- a/saas/test/models/account/subscription_test.rb +++ b/saas/test/models/account/subscription_test.rb @@ -158,4 +158,37 @@ class Account::SubscriptionTest < ActiveSupport::TestCase 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