Skip to content

Instantly share code, notes, and snippets.

@chemicalkosek
Forked from dsumer/TaxRateModel.ts
Created February 20, 2021 15:11
Show Gist options
  • Save chemicalkosek/2c87cfc76a7b7a33d009a608a3c87627 to your computer and use it in GitHub Desktop.
Save chemicalkosek/2c87cfc76a7b7a33d009a608a3c87627 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;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment