Magicians never share their secrets. But we do. Sign up for our Ruby Magic email series and receive deep insights about garbage collection, memory allocation, concurrency and much more.
Are you a European company offering subscription based services, while using Stripe to handle payments? Right... VAT. Here's how we fixed it.
A disadvantage of being based in Europe is having to comply with EU VAT regulations. It basically boils down to:
To complicate matters some more, the burden of proof is on the supplier. You have to make you sure you know the registered business addresses of all customers. You must also validate their VAT numbers using the EU's VIES check site.
Of course, a SaaS business wants to automate all of the above. All of our monthly or yearly generated invoices should only include VAT when necessary. Until somewhere last year there were little to no credit card payment gateway providers that were either aware of the problem, cared enough about it to fix it, or offer hooks for clients to add their own logic for calculating VAT. That's until Stripe launched in the Netherlands, and we came up with a pretty clean workaround that's possible because of the hooks their system offers.
Stripe offers an
invoice.created web hook. This hook gets called when a customer is ready for its next billing cycle. After an invoice is created you have a chance to modify the invoice before an actual charge is made, by telling Stripe to call a webhook. We use this to either add VAT, or to let Stripe know that the invoice is okay is as.
To start things off, we created a simple class to handle the webhooks that are sent by Stripe. This is called by a controller that receives the web hook call:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
class StripeEvent attr_reader :event_id def initialize(event_id) @event_id = event_id end def event @event ||= Stripe::Event.retrieve(event_id) end def handle case event.type when 'invoice.created' invoice_created when 'invoice.payment_succeeded' invoice_payment_succeeded end end end
invoice_created hook, a method gets called that takes the total amount of the invoice, including any discounts or prorations, and adds 21% VAT to it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
def invoice_created invoice = event.data.object account = Account.where(:stripe_customer_id => invoice.customer).first sync_contact(account) if !invoice.closed && account && account.has_to_pay_vat? Stripe::InvoiceItem.create( :customer => invoice.customer, :amount => (invoice.total * 0.21).to_i, :description => 'VAT', :currency => invoice.currency, :invoice => invoice.id ) end end
account.has_to_pay_vat? checks wether the customer is in a country for which we (potentially) have to charge VAT, as well as the validity of their VAT number. We get this data through the contact sync with MoneyBird, (a great Dutch invoicing system we’ve been using for years):
1 2 3 4
def has_to_pay_vat? country == 'nl' || (eu_countries.include?(country) && !valid_vat_number?) end
Stripe processes our response and charges a credit card, after which we receive another webhook to let us know we received some money. We then have all the information we need to create an invoice on our end:
1 2 3 4 5 6
def invoice_payment_succeeded invoice = event.data.object account = Account.where(:stripe_customer_id => invoice.customer).first create_invoice(account, invoice) end
That wraps it up. We added VAT if necessary and created an invoice if all went well. So is this system perfect? By no means. Since we get a total amount including VAT, there sometimes are one-cent rounding differences between MoneyBird and Stripe. Also, the VAT amount gets added before coupons are applied. Therefore we can’t use the line items to make a fully correct invoice in MoneyBird, but have to work backwards from the total amount.
Stripe could do a couple of things to improve the situation (from comprehensive to simple):
For the moment we're really happy that we made this work. Kudos to Stripe for making it possible with their great API, though we do hope this ordeal gets some more love from them in the future :-)
Update: This situation has much improved since writing this post, read our follow-up.