Skip to content

Instantly share code, notes, and snippets.

@dsumer
Last active February 2, 2022 15:07
Show Gist options
  • Save dsumer/1da0740921addc3309566a81c9ad4542 to your computer and use it in GitHub Desktop.
Save dsumer/1da0740921addc3309566a81c9ad4542 to your computer and use it in GitHub Desktop.
Apply the correct VAT Rate to your customer in Stripe Checkout
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 },
];
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);
}
}
import { Model } from 'objection';
export default class TaxRateModel extends Model {
countryCode!: string;
stripeId!: string;
static tableName = 'tax_rate';
}
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('*');
}
}
}
}
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;
}
}
@dsumer
Copy link
Author

dsumer commented Jan 17, 2021

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.

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