Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2637e39
fix
yashranaway Dec 10, 2025
5190f15
Address PR review comments
yashranaway Dec 16, 2025
8a2257d
Revert unrelated schema changes
yashranaway Dec 16, 2025
bc1e2d6
Merge branch 'main' into fix-vat
yashranaway Dec 16, 2025
cd5bc09
Merge branch 'main' into fix-vat
yashranaway Dec 17, 2025
8032530
Merge branch 'main' into fix-vat
yashranaway Dec 18, 2025
326e8e8
Merge branch 'main' into fix-vat
yashranaway Dec 22, 2025
4e63458
Merge branch 'main' into fix-vat
yashranaway Dec 23, 2025
b99361d
Merge branch 'main' into fix-vat
yashranaway Dec 27, 2025
0d0c50b
Merge branch 'main' into fix-vat
yashranaway Dec 29, 2025
8d43386
Merge branch 'main' into fix-vat
yashranaway Jan 2, 2026
3e07567
Merge branch 'main' into fix-vat
yashranaway Jan 6, 2026
df45e4a
Fix error handling test to properly verify resilience
yashranaway Jan 6, 2026
363871d
Merge branch 'main' into fix-vat
yashranaway Jan 14, 2026
3f62cfb
Merge branch 'main' into fix-vat
yashranaway Jan 17, 2026
bcb19d6
fixes
yashranaway Jan 19, 2026
be409d1
Merge branch 'main' into fix-vat
yashranaway Jan 19, 2026
a7d7e1d
comments
yashranaway Jan 19, 2026
6235f25
Merge branch 'main' into fix-vat
yashranaway Jan 20, 2026
acf7dad
Merge upstream/main - resolve schema.rb version conflict
yashranaway Jan 21, 2026
0c94609
f
yashranaway Jan 22, 2026
7862e8f
Merge branch 'main' into fix-vat
yashranaway Jan 22, 2026
4041e45
Merge branch 'main' into fix-vat
yashranaway Jan 24, 2026
7d4423f
Merge branch 'main' into fix-vat
yashranaway Jan 26, 2026
c369cc0
Merge branch 'main' into fix-vat
yashranaway Jan 26, 2026
9b19114
Merge branch 'main' into fix-vat
yashranaway Jan 27, 2026
ec61798
Merge branch 'main' into fix-vat
yashranaway Jan 29, 2026
a40e35a
Merge branch 'main' into fix-vat
yashranaway Jan 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/models/purchase.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3151,6 +3151,8 @@ def create_sales_tax_info!

self.purchase_sales_tax_info = purchase_sales_tax_info
self.purchase_sales_tax_info.save!

subscription&.update_business_vat_id!(purchase_sales_tax_info.business_vat_id) if purchase_sales_tax_info.business_vat_id.present?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If VAT ID is validated during checkout, store it on subscription for future recurring charges.

end

def charge_discover_fee?
Expand Down
45 changes: 37 additions & 8 deletions app/models/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ def build_purchase(override_params: {}, from_failed_charge_email: false)
end
purchase.affiliate = original_purchase.affiliate if original_purchase.affiliate.try(:eligible_for_credit?)
purchase.is_upgrade_purchase = is_upgrade_purchase if is_upgrade_purchase
get_vat_id_from_original_purchase(purchase)
set_vat_id_for_purchase(purchase)
purchase
end

Expand Down Expand Up @@ -462,7 +462,7 @@ def update_current_plan!(new_variants:, new_price:, new_quantity: nil, perceived
new_purchase.is_original_subscription_purchase = true
new_purchase.perceived_price_cents = perceived_price_cents
new_purchase.price_range = perceived_price_cents.present? ? perceived_price_cents / (link.single_unit_currency? ? 1 : 100.0) : nil
new_purchase.business_vat_id = original_purchase.purchase_sales_tax_info&.business_vat_id
new_purchase.business_vat_id = resolve_vat_id
new_purchase.quantity = new_quantity if new_quantity.present?
original_purchase.purchase_custom_fields.each { new_purchase.purchase_custom_fields << _1.dup }

Expand Down Expand Up @@ -696,6 +696,29 @@ def resubscribe!
end
end

def update_business_vat_id!(vat_id)
update!(business_vat_id: vat_id) if vat_id.present? && business_vat_id.blank?
end
Comment on lines +699 to +701
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michellelarney should we allow the buyer to use different VAT IDs in the course of a subscription? Or is it like, once they set a certain VAT in a subscription, we shouldn't allow to change that?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally I don't think people would need to change it and would usually be using the same VAT ID for all charges. I would say it should just stay the same.


Comment on lines +699 to +702
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stores VAT ID on subscription only if not already set. This prevents overwriting if customer provides a different VAT ID later.

# TODO: Remove fallback logic after running Onetime::BackfillSubscriptionVatIds
def resolve_vat_id
business_vat_id.presence ||
original_purchase&.purchase_sales_tax_info&.business_vat_id.presence ||
vat_id_from_original_purchase_refund ||
vat_id_from_any_subscription_purchase_refund
end

def vat_id_from_any_subscription_purchase_refund
Refund.joins(:purchase)
.where(purchases: { subscription_id: id })
.where("refunds.gumroad_tax_cents > 0")
.where("refunds.amount_cents = 0")
.order("refunds.created_at DESC")
.limit(10)
.find { |refund| refund.business_vat_id.present? }
&.business_vat_id
end

def last_resubscribed_at
if defined?(@_last_resubscribed_at)
@_last_resubscribed_at
Expand Down Expand Up @@ -935,12 +958,18 @@ def fetch_last_payment_option
payment_options.alive.last
end

def get_vat_id_from_original_purchase(purchase)
if original_purchase.purchase_sales_tax_info&.business_vat_id
purchase.business_vat_id = original_purchase.purchase_sales_tax_info.business_vat_id
elsif original_purchase.refunds.where("gumroad_tax_cents > 0").where("amount_cents = 0").exists?
purchase.business_vat_id = original_purchase.refunds.where("gumroad_tax_cents > 0").where("amount_cents = 0").first.business_vat_id
end
def set_vat_id_for_purchase(purchase)
vat_id = resolve_vat_id
purchase.business_vat_id = vat_id if vat_id.present?
end

def vat_id_from_original_purchase_refund
original_purchase.refunds
.where("gumroad_tax_cents > 0")
.where("amount_cents = 0")
.order(created_at: :desc)
.find { |refund| refund.business_vat_id.present? }
&.business_vat_id
end

def schedule_member_cancellation_workflow_jobs
Expand Down
3 changes: 3 additions & 0 deletions app/modules/purchase/refundable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,9 @@ def refund_gumroad_taxes!(refunding_user_id:, note: nil, business_vat_id: nil)
refunds << refund
save!
Credit.create_for_vat_refund!(refund:) if paypal_order_id.present? || merchant_account&.is_a_stripe_connect_account?

# Store VAT ID on subscription so future recurring charges are automatically VAT-exempt
subscription&.update_business_vat_id!(business_vat_id) if business_vat_id.present?
end
true
rescue ChargeProcessorAlreadyRefundedError => e
Expand Down
29 changes: 29 additions & 0 deletions app/services/onetime/backfill_subscription_vat_ids.rb
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One-time service to populate business_vat_id for existing subscriptions that have valid VAT IDs from purchases or refunds. Run after deployment: Onetime::BackfillSubscriptionVatIds.process

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module Onetime
class BackfillSubscriptionVatIds
def self.process
new.process
end

def process
count = 0

Subscription.where(business_vat_id: nil).find_each do |subscription|
vat_id = subscription.resolve_vat_id
next if vat_id.blank?

subscription.update!(business_vat_id: vat_id)
count += 1

Rails.logger.info("Backfilled VAT ID for subscription #{subscription.id}")
ReplicaLagWatcher.watch
rescue StandardError => e
Rails.logger.error("Failed to backfill VAT ID for subscription #{subscription.id}: #{e.message}")
end

Rails.logger.info("Backfilled VAT IDs for #{count} subscriptions")
count
end
end
end
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds business_vat_id column to store VAT ID on the subscription. Partial index on non-null values improves query performance for subscriptions with VAT IDs while avoiding index bloat from null values.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddBusinessVatIdToSubscriptions < ActiveRecord::Migration[7.1]
def change
add_column :subscriptions, :business_vat_id, :string, limit: 191
end
end
1 change: 1 addition & 0 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions spec/models/purchase_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3765,6 +3765,19 @@ def parse_zip(user_input_zip)
expect(purchase.purchase_sales_tax_info).to eq(purchase_sales_tax_info)
end

it "stores VAT ID on subscription when present in sales tax info" do
product = create(:subscription_product)
subscription = create(:subscription, link: product, business_vat_id: nil)
purchase = build(:free_purchase, link: product, subscription:, is_original_subscription_purchase: true,
country: "Ireland", business_vat_id: "IE6388047V")

allow(VatValidationService).to receive_message_chain(:new, :process).and_return(true)

purchase.send(:create_sales_tax_info!)

expect(subscription.reload.business_vat_id).to eq "IE6388047V"
end

it "handles invalid countries from GEOIP lookup for IP address" do
purchase = create(:purchase, price_cents: 100_00, chargeable: create(:chargeable))
purchase.sales_tax_country_code_election = Compliance::Countries::DEU.alpha2
Expand Down
201 changes: 201 additions & 0 deletions spec/models/subscription_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,51 @@
expect(charge_purchase.gumroad_tax_cents).to eq 0
end

it "transfers VAT ID from subscription's stored business_vat_id to recurring charge" do
create(:zip_tax_rate, country: "IT", zip_code: nil, state: nil, combined_rate: 0.22, is_seller_responsible: false)

subscription = create(:subscription, user: create(:user, credit_card: create(:credit_card)), link: @product, business_vat_id: "IE6388047V")
original_purchase = create(:purchase, is_original_subscription_purchase: true, link: @product,
subscription:, chargeable: build(:chargeable), purchase_state: "in_progress",
full_name: "gum stein", ip_address: "2.47.255.255", country: "Italy", created_at: 2.days.ago)
original_purchase.process!(off_session: false)
expect(original_purchase.gumroad_tax_cents).to eq 22

subscription.charge!
charge_purchase = subscription.reload.purchases.last
expect(charge_purchase.purchase_state).to eq "successful"
expect(charge_purchase.purchase_sales_tax_info.business_vat_id).to eq "IE6388047V"
expect(charge_purchase.gumroad_tax_cents).to eq 0
end

it "transfers VAT ID from a recurring charge's VAT refund to subsequent recurring charges" do
create(:zip_tax_rate, country: "IT", zip_code: nil, state: nil, combined_rate: 0.22, is_seller_responsible: false)

subscription = create(:subscription, user: create(:user, credit_card: create(:credit_card)), link: @product)
original_purchase = create(:purchase, is_original_subscription_purchase: true, link: @product,
subscription:, chargeable: build(:chargeable), purchase_state: "in_progress",
full_name: "gum stein", ip_address: "2.47.255.255", country: "Italy", created_at: 2.months.ago)

travel_to(2.months.ago) do
original_purchase.process!(off_session: false)
expect(original_purchase.gumroad_tax_cents).to eq 22
end

travel_to(1.month.ago) do
first_recurring_purchase = subscription.charge!
expect(first_recurring_purchase.purchase_state).to eq "successful"
expect(first_recurring_purchase.gumroad_tax_cents).to eq 22

first_recurring_purchase.refund_gumroad_taxes!(refunding_user_id: @product.user.id, note: "Sample Note", business_vat_id: "IE6388047V")
expect(subscription.reload.business_vat_id).to eq "IE6388047V"
end

second_recurring_purchase = subscription.charge!
expect(second_recurring_purchase.purchase_state).to eq "successful"
expect(second_recurring_purchase.purchase_sales_tax_info.business_vat_id).to eq "IE6388047V"
expect(second_recurring_purchase.gumroad_tax_cents).to eq 0
end

describe "handling of unexpected errors", :vcr do
context "when a rate limit error occurs" do
it "does not leave the purchase in in_progress state" do
Expand Down Expand Up @@ -3762,4 +3807,160 @@
end
end
end

describe "#update_business_vat_id!" do
let(:seller) { create(:user) }
let(:product) { create(:subscription_product, user: seller) }
let(:subscription) { create(:subscription, link: product, business_vat_id: nil) }

it "updates subscription's business_vat_id when not already set" do
subscription.update_business_vat_id!("IE6388047V")

expect(subscription.reload.business_vat_id).to eq "IE6388047V"
end

it "does not update subscription's business_vat_id when already set" do
subscription.update!(business_vat_id: "DE123456789")

subscription.update_business_vat_id!("IE6388047V")

expect(subscription.reload.business_vat_id).to eq "DE123456789"
end

it "does not update subscription's business_vat_id when nil is provided" do
subscription.update_business_vat_id!(nil)

expect(subscription.reload.business_vat_id).to be_nil
end

it "does not update subscription's business_vat_id when empty string is provided" do
subscription.update_business_vat_id!("")

expect(subscription.reload.business_vat_id).to be_nil
end
end

describe "#resolve_vat_id" do
let(:seller) { create(:user) }
let(:product) { create(:subscription_product, user: seller) }

before do
create(:zip_tax_rate, country: "IT", zip_code: nil, state: nil, combined_rate: 0.22, is_seller_responsible: false)
end

it "prioritizes subscription's stored business_vat_id" do
subscription = create(:subscription, link: product, business_vat_id: "SUBSCRIPTION_VAT")
original_purchase = create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)
original_purchase.create_purchase_sales_tax_info!(business_vat_id: "PURCHASE_VAT", country_code: "IT")

expect(subscription.resolve_vat_id).to eq "SUBSCRIPTION_VAT"
end

it "falls back to original purchase's sales tax info" do
subscription = create(:subscription, link: product, business_vat_id: nil)
original_purchase = create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)
original_purchase.create_purchase_sales_tax_info!(business_vat_id: "PURCHASE_VAT", country_code: "IT")

expect(subscription.resolve_vat_id).to eq "PURCHASE_VAT"
end

it "falls back to original purchase's VAT refund" do
subscription = create(:subscription, link: product, business_vat_id: nil)
original_purchase = create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:, country: "Italy")
create(:refund, purchase: original_purchase, gumroad_tax_cents: 22, amount_cents: 0, business_vat_id: "REFUND_VAT")

expect(subscription.resolve_vat_id).to eq "REFUND_VAT"
end

it "falls back to any subscription purchase's VAT refund" do
subscription = create(:subscription, link: product, business_vat_id: nil)
create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)
recurring_purchase = create(:free_purchase, is_original_subscription_purchase: false, link: product, subscription:, country: "Italy")
create(:refund, purchase: recurring_purchase, gumroad_tax_cents: 22, amount_cents: 0, business_vat_id: "RECURRING_REFUND_VAT")

expect(subscription.resolve_vat_id).to eq "RECURRING_REFUND_VAT"
end

it "returns nil when no VAT ID exists" do
subscription = create(:subscription, link: product, business_vat_id: nil)
create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)

expect(subscription.resolve_vat_id).to be_nil
end
end

describe "VAT ID lookup methods" do
let(:seller) { create(:user) }
let(:product) { create(:subscription_product, user: seller) }

before do
create(:zip_tax_rate, country: "IT", zip_code: nil, state: nil, combined_rate: 0.22, is_seller_responsible: false)
end

describe "#vat_id_from_original_purchase_refund" do
it "returns the VAT ID from the most recent VAT-only refund on original purchase" do
subscription = create(:subscription, link: product)
original_purchase = create(:free_purchase, is_original_subscription_purchase: true, link: product,
subscription:, full_name: "gum stein", country: "Italy")
create(:refund, purchase: original_purchase, gumroad_tax_cents: 22, amount_cents: 0, business_vat_id: "IE6388047V")

result = subscription.send(:vat_id_from_original_purchase_refund)
expect(result).to eq "IE6388047V"
end

it "returns nil when no VAT-only refunds exist" do
subscription = create(:subscription, link: product)
create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)

result = subscription.send(:vat_id_from_original_purchase_refund)
expect(result).to be_nil
end

it "returns nil when refunds exist but don't have a business_vat_id" do
subscription = create(:subscription, link: product)
original_purchase = create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)
create(:refund, purchase: original_purchase, gumroad_tax_cents: 22, amount_cents: 0, business_vat_id: nil)

result = subscription.send(:vat_id_from_original_purchase_refund)
expect(result).to be_nil
end
end

describe "#vat_id_from_any_subscription_purchase_refund" do
it "returns the VAT ID from a VAT-only refund on any subscription purchase" do
subscription = create(:subscription, link: product)
create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)
recurring_purchase = create(:free_purchase, is_original_subscription_purchase: false, link: product,
subscription:, country: "Italy")
create(:refund, purchase: recurring_purchase, gumroad_tax_cents: 22, amount_cents: 0, business_vat_id: "DE987654321")

result = subscription.send(:vat_id_from_any_subscription_purchase_refund)
expect(result).to eq "DE987654321"
end

it "returns nil when no VAT-only refunds with business_vat_id exist" do
subscription = create(:subscription, link: product)
create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)

result = subscription.send(:vat_id_from_any_subscription_purchase_refund)
expect(result).to be_nil
end

it "returns the most recent VAT ID when multiple refunds exist" do
subscription = create(:subscription, link: product)
create(:free_purchase, is_original_subscription_purchase: true, link: product, subscription:)
recurring_purchase1 = create(:free_purchase, is_original_subscription_purchase: false, link: product,
subscription:, country: "Italy")
recurring_purchase2 = create(:free_purchase, is_original_subscription_purchase: false, link: product,
subscription:, country: "Italy")
create(:refund, purchase: recurring_purchase1, gumroad_tax_cents: 22, amount_cents: 0,
business_vat_id: "OLD_VAT_ID", created_at: 2.days.ago)
create(:refund, purchase: recurring_purchase2, gumroad_tax_cents: 22, amount_cents: 0,
business_vat_id: "NEW_VAT_ID", created_at: 1.day.ago)

result = subscription.send(:vat_id_from_any_subscription_purchase_refund)
expect(result).to eq "NEW_VAT_ID"
end
end
end
end
Loading