Last active
February 2, 2022 15:07
-
-
Save dsumer/1da0740921addc3309566a81c9ad4542 to your computer and use it in GitHub Desktop.
Apply the correct VAT Rate to your customer in Stripe Checkout
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const COUNTRIES = [ | |
{ name: 'United States', code: 'US' }, | |
// only EU countries included here, checkout this gist for a full country list: https://gist.github.com/keeguon/2310008 | |
//Be careful: I don't keep this list updated, it's from 17. January 2021 | |
{ name: 'Austria', code: 'AT', isEU: true, vatPercentage: 20 }, | |
{ name: 'Belgium', code: 'BE', isEU: true, vatPercentage: 21 }, | |
{ name: 'Bulgaria', code: 'BG', isEU: true, vatPercentage: 20 }, | |
{ name: 'Croatia', code: 'HR', isEU: true, vatPercentage: 25 }, | |
{ name: 'Cyprus', code: 'CY', isEU: true, vatPercentage: 19 }, | |
{ name: 'Czech Republic', code: 'CZ', isEU: true, vatPercentage: 21 }, | |
{ name: 'Denmark', code: 'DK', isEU: true, vatPercentage: 25 }, | |
{ name: 'Estonia', code: 'EE', isEU: true, vatPercentage: 20 }, | |
{ name: 'Finland', code: 'FI', isEU: true, vatPercentage: 24 }, | |
{ name: 'France', code: 'FR', isEU: true, vatPercentage: 20 }, | |
{ name: 'Germany', code: 'DE', isEU: true, vatPercentage: 19 }, | |
{ name: 'Greece', code: 'GR', isEU: true, vatPercentage: 24 }, | |
{ name: 'Hungary', code: 'HU', isEU: true, vatPercentage: 27 }, | |
{ name: 'Ireland', code: 'IE', isEU: true, vatPercentage: 23 }, | |
{ name: 'Italy', code: 'IT', isEU: true, vatPercentage: 22 }, | |
{ name: 'Latvia', code: 'LV', isEU: true, vatPercentage: 21 }, | |
{ name: 'Lithuania', code: 'LT', isEU: true, vatPercentage: 21 }, | |
{ name: 'Luxembourg', code: 'LU', isEU: true, vatPercentage: 17 }, | |
{ name: 'Malta', code: 'MT', isEU: true, vatPercentage: 18 }, | |
{ name: 'Netherlands', code: 'NL', isEU: true, vatPercentage: 21 }, | |
{ name: 'Poland', code: 'PL', isEU: true, vatPercentage: 23 }, | |
{ name: 'Portugal', code: 'PT', isEU: true, vatPercentage: 23 }, | |
{ name: 'Romania', code: 'RO', isEU: true, vatPercentage: 19 }, | |
{ name: 'Slovakia', code: 'SK', isEU: true, vatPercentage: 20 }, | |
{ name: 'Slovenia', code: 'SI', isEU: true, vatPercentage: 22 }, | |
{ name: 'Spain', code: 'ES', isEU: true, vatPercentage: 21 }, | |
{ name: 'Sweden', code: 'SE', isEU: true, vatPercentage: 25 }, | |
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export async function createStripeCheckoutSession(stripePriceId: string, user: UserModel) { | |
const userCountry = COUNTRIES.find((c) => c.code === user.billingCountryCode); | |
const isEUCustomer = !!userCountry.isEU; | |
let taxRate: TaxRateModel; | |
if (isEUCustomer) { | |
taxRate = await TaxRateModel.query().findOne({ countryCode: userCountry.code }); | |
} | |
try { | |
const session = await stripe.checkout.sessions.create({ | |
mode: 'subscription', | |
customer: user.stripeCustomerId, | |
payment_method_types: ['card'], | |
line_items: [ | |
{ | |
price: stripePriceId, | |
quantity: 1, | |
tax_rates: taxRate ? [taxRate.stripeId] : undefined, | |
}, | |
], | |
allow_promotion_codes: true, | |
// {CHECKOUT_SESSION_ID} is a string literal; do not change it! | |
// the actual Session ID is returned in the query parameter when your customer | |
// is redirected to the success page. | |
success_url: `${applicationUrl}stripe/success?session_id={CHECKOUT_SESSION_ID}`, | |
cancel_url: `${applicationUrl}profile/${user.userName}/billing`, | |
}); | |
return session.id; | |
} catch (e) { | |
throw new Error(e.message); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Model } from 'objection'; | |
export default class TaxRateModel extends Model { | |
countryCode!: string; | |
stripeId!: string; | |
static tableName = 'tax_rate'; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export async function createOrUpdateVatTaxRates() { | |
const EU_COUNTRIES = COUNTRIES.filter((c) => !!c.isEU); | |
for (const country of EU_COUNTRIES) { | |
const existingTaxRate = await TaxRateModel.query().findOne({ countryCode: country.code }); | |
if (!existingTaxRate || existingTaxRate.vatPercentage !== country.vatPercentage) { | |
getLogger().info({}, 'TaxRate changed for country: ' + country.code); | |
const taxRate = await stripe.taxRates.create({ | |
display_name: 'VAT ' + country.name, | |
inclusive: false, | |
percentage: country.vatPercentage, | |
}); | |
if (existingTaxRate) { | |
await stripe.taxRates.update(existingTaxRate.stripeId, { active: false }); | |
await existingTaxRate.$query().patch({ stripeId: taxRate.id, vatPercentage: country.vatPercentage }); | |
} else { | |
await TaxRateModel.query() | |
.insert({ | |
countryCode: country.code, | |
stripeId: taxRate.id, | |
vatPercentage: country.vatPercentage, | |
}) | |
.returning('*'); | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function mapUserToStripeCustomer(user: UserModel, isEUCustomer: boolean): Stripe.CustomerUpdateParams { | |
// our business is located in Austria, so we have to check if the customer is also from Austria | |
const COUNTRY_CODE_AUSTRIA = 'AT'; | |
let taxExempt: Stripe.CustomerUpdateParams.TaxExempt; | |
if (isEUCustomer) { | |
if (user.billingVatNumber && user.billingCountryCode !== COUNTRY_CODE_AUSTRIA) { | |
// if it is a country in the EU and not from austria, reverse charge applies | |
taxExempt = 'reverse'; | |
} else { | |
// b2c in EU or business from Austria, VAT applies normally | |
taxExempt = 'none'; | |
} | |
} else { | |
// customer from outside the EU, no VAT applies | |
taxExempt = 'exempt'; | |
} | |
return { | |
email: user.billingEmail, | |
name: user.billingName, | |
address: { | |
city: user.billingCity, | |
country: user.billingCountryCode, | |
line1: user.billingStreet, | |
postal_code: user.billingZip, | |
}, | |
tax_exempt: taxExempt, | |
}; | |
} | |
export async function upsertStripeCustomer(user: UserModel, updatedVatNumber: boolean) { | |
const userCountry = COUNTRIES.find((c) => c.code === user.billingCountryCode); | |
const isEUCustomer = !!userCountry?.isEU; | |
const baseData = mapUserToStripeCustomer(user, isEUCustomer); | |
if (user.stripeCustomerId) { | |
if (isEUCustomer && updatedVatNumber && user.billingVatNumber) { | |
await stripe.customers.createTaxId(user.stripeCustomerId, { type: 'eu_vat', value: user.billingVatNumber }); | |
} | |
await stripe.customers.update(user.stripeCustomerId, baseData); | |
return user.stripeCustomerId; | |
} else { | |
const customer = await stripe.customers.create({ | |
...baseData, | |
tax_id_data: isEUCustomer && user.billingVatNumber ? [{ type: 'eu_vat', value: user.billingVatNumber }] : undefined, | |
metadata: { | |
trueqId: user.id, | |
}, | |
}); | |
const success = (await user.$query().patch({ stripeCustomerId: customer.id })) > 0; | |
if (!success) { | |
throw new Error(`Error updating the stripe customer id (${customer.id}) of the user with id ${user.id}`); | |
} | |
return customer.id; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Be careful! I don't update the tax rates for EU countries in this gist and they can change over time.
First there is the
createOrUpdateVatTaxRates
function which should be called everytime your application boots up in order to create all necessary tax rates in stripe and save them in our database. It also detects if taxRates have changed and properly deactivates the old stripe tax rate and create a new one with the updated percentage.After that the specific taxRate can be applied to the customer when creating his stripe checkout session.
The code in the
upsertStripeCustomer
function ensures that the VAT only applies if the user is located in EU, is located in your home country or is a b2c customer. If it is a b2b customer located in EU outside your home country reverse charge is applied.