Skip to content

Conversation

@yashranaway
Copy link
Contributor

@yashranaway yashranaway commented Dec 10, 2025

Issue: #721

Description

Problem

Customers with recurring subscriptions had to manually request VAT refunds for each charge by entering their VAT ID through the invoice page. If they added a VAT ID after the initial purchase, future recurring charges would still have VAT applied.

The existing get_vat_id_from_original_purchase method only checked the original purchase's VAT info and its refunds, not subsequent recurring charges. If a customer added a VAT ID after the initial purchase, it wouldn't propagate to future charges.

Solution

When a valid VAT ID is stored (either at checkout or via VAT refund), all future recurring charges automatically skip VAT.

  • Store business_vat_id on Subscription model
  • Capture VAT ID at two points: checkout and VAT refund
  • Priority order for VAT ID lookup: subscription's stored ID → original purchase tax info → original purchase refunds → any subscription purchase refunds
  • Backfill service for existing subscriptions

Note

The ~6,600 added lines are primarily VCR cassettes and test coverage.
Core implementation changes are minimal and tightly scoped.


Before/After

N/A - No UI changes, this is backend logic only.


Test Results

  • VAT ID from subscription's stored business_vat_id propagates to recurring charges
  • VAT ID from recurring charge's VAT refund propagates to subsequent charges
  • update_business_vat_id! method works correctly (set when blank, ignore when already set)
  • VAT ID lookup from original purchase refunds
  • VAT ID lookup from any subscription purchase refunds
  • Backfill service correctly populates existing subscriptions
  • Backward compatibility when no VAT ID exists
image

Checklist


AI Disclosure

I used Claude Opus via Claude Code for specs and tests generation.
All code was manually reviewed and verified by me.

Comment on lines +691 to +694
def update_business_vat_id!(vat_id)
update!(business_vat_id: vat_id) if vat_id.present? && business_vat_id.blank?
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.

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

Comment on lines 934 to 941
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
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

purchase.business_vat_id = vat_id if vat_id.present?
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.

Priority order for VAT ID lookup:

  1. Subscription's stored VAT ID (new)
  2. Original purchase's sales tax info
  3. Original purchase's VAT refunds
  4. Any subscription purchase's VAT refunds (new - catches VAT ID added on recurring charges)

Comment on lines 943 to 950
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
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Finds VAT ID from VAT-only refunds on ANY subscription purchase, not just the original. Uses Ruby filtering because business_vat_id is stored in json_data column.

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

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.

@yashranaway yashranaway marked this pull request as ready for review December 10, 2025 06:14
@yashranaway
Copy link
Contributor Author

@ershad Can you please review this, thanks!

Copy link
Member

@ershad ershad left a comment

Choose a reason for hiding this comment

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

@yashranaway _a added a few comments, please take a look. Also discussed in the live stream (December 16).

Comment on lines +691 to +693
def update_business_vat_id!(vat_id)
update!(business_vat_id: vat_id) if vat_id.present? && business_vat_id.blank?
end
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 935 to 938
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
Copy link
Member

Choose a reason for hiding this comment

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

Reviewer note: Measure performance impact before merging.

@neetogit-bot neetogit-bot bot assigned yashranaway and unassigned yashranaway Dec 16, 2025
@yashranaway
Copy link
Contributor Author

yashranaway commented Dec 16, 2025

Update: Addressed all review feedback:

  • Renamed get_vat_id_from_original_purchase → set_vat_id_for_purchase
  • Made vat_id_from_any_subscription_purchase_refund public (removed .send)
  • Added error handling in backfill service so failures don't break the script
  • Added inline comment explaining json_data filtering
  • Reverted unrelated schema.rb changes (audience_export tables)
  • Business decision on VAT ID change policy (@michellelarney)

Pending:

  • Performance measurement before merge for reviewers

@yashranaway
Copy link
Contributor Author

yashranaway commented Dec 23, 2025

Hey @michellelarney just a reminder about this comment when you get a chance. thank you
#2270 (comment)

cc: @ershad

@yashranaway yashranaway requested a review from ershad December 29, 2025 15:47
@yashranaway
Copy link
Contributor Author

@ershad Friendly ping on this.

Copy link
Member

@ershad ershad left a comment

Choose a reason for hiding this comment

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

@yashranaway reviewed during PR review session, please take a look at the comments.

@yashranaway
Copy link
Contributor Author

Hey @ershad _a , addressed all comments and replied. Could you please take another look? 🙏

@neetogit-bot neetogit-bot bot assigned ershad and unassigned yashranaway Jan 22, 2026
@yashranaway
Copy link
Contributor Author

@ershad friendly ping on this, since core changes were accepted in review sessions only tests had fixes remaining which is done could you please take a look?

@yashranaway
Copy link
Contributor Author

@nyomanjyotisa I noticed you were recently assigned to this issue. This PR has already been through review, all feedback has been addressed, and tests are passing. Could you please take a look when you have time?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants