-
-
Save chemicalkosek/2c87cfc76a7b7a33d009a608a3c87627 to your computer and use it in GitHub Desktop.
Apply the correct VAT Rate to your customer in Stripe Checkout
This file contains hidden or 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 hidden or 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 hidden or 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 hidden or 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 hidden or 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