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..2ae7e3171f 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -47,4 +47,21 @@ 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 + 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 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..eaf7e103d4 100644 --- a/saas/test/models/account/subscription_test.rb +++ b/saas/test/models/account/subscription_test.rb @@ -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 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