From 0fab15c58e64758ee78706fb21a80f07ea68e2df Mon Sep 17 00:00:00 2001 From: roo hutton Date: Wed, 24 Jun 2026 10:21:16 +0100 Subject: [PATCH] Merge pull request #34730 from overleaf/rh-tax-id-verify Validate tax ids and send emails on failed verification GitOrigin-RevId: 84d53fdaf00d7b23ced22dd540a071e3028ced93 --- .../app/src/Features/Email/EmailBuilder.mjs | 45 ++++++++++ .../test/unit/src/Email/EmailBuilder.test.mjs | 85 +++++++++++++++++++ services/web/types/stripe/webhook-event.ts | 13 +++ 3 files changed, 143 insertions(+) diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index 3de026eb6d..2ad9e62b09 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/services/web/app/src/Features/Email/EmailBuilder.mjs @@ -1008,6 +1008,51 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({ }, }) +function taxIdInvalidTemplate({ noun, descriptor }) { + return ctaTemplate({ + subject() { + return `Action required: Update your ${noun} on ${settings.appName}` + }, + title() { + return `Action required: Update your ${noun}` + }, + greeting() { + return 'Hi,' + }, + message() { + return [ + `We're writing to let you know that the ${descriptor} you entered did not validate successfully.`, + `Please update your ${noun} on your billing information page:`, + ] + }, + secondaryMessage() { + return [ + `If you do not update your ${noun}, your next invoice may be subject to additional taxes.`, + 'If you require a new invoice, please let us know by replying to this email.', + ] + }, + ctaText() { + return `Update ${noun}` + }, + ctaURL() { + return `${settings.siteUrl}/user/subscription` + }, + footerMessage(opts) { + return `Our reference: ${opts.stripeCustomerId}` + }, + }) +} + +templates.taxIdInvalidVat = taxIdInvalidTemplate({ + noun: 'VAT number', + descriptor: 'VAT number', +}) + +templates.taxIdInvalidNonVat = taxIdInvalidTemplate({ + noun: 'tax ID', + descriptor: 'tax identifier', +}) + templates.groupMemberLimitWarning = ctaTemplate({ subject(opts) { return `Action needed: Your Overleaf group is nearly out of licenses` diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index 508c59fe7f..06b61e4243 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/services/web/test/unit/src/Email/EmailBuilder.test.mjs @@ -900,6 +900,91 @@ describe('EmailBuilder', function () { }) }) + describe('taxIdInvalidVat', function () { + beforeEach(function (ctx) { + ctx.emailAddress = 'customer@example.com' + ctx.opts = { + to: ctx.emailAddress, + stripeCustomerId: 'cus_123456789', + } + ctx.email = ctx.EmailBuilder.buildEmail('taxIdInvalidVat', ctx.opts) + ctx.dom = cheerio.load(ctx.email.html) + }) + + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist + }) + + describe('HTML email', function () { + it('should include the Stripe customer ID', function (ctx) { + expect(ctx.email.html).to.contain(ctx.opts.stripeCustomerId) + }) + + it('should refer to the VAT number', function (ctx) { + expect(ctx.email.html).to.contain('VAT number') + }) + + it('should link to the subscription page', function (ctx) { + expect(ctx.dom('a').attr('href')).to.contain('/user/subscription') + }) + }) + + describe('plain text email', function () { + it('should include the Stripe customer ID', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.stripeCustomerId) + }) + + it('should refer to the VAT number', function (ctx) { + expect(ctx.email.text).to.contain('VAT number') + }) + }) + }) + + describe('taxIdInvalidNonVat', function () { + beforeEach(function (ctx) { + ctx.emailAddress = 'customer@example.com' + ctx.opts = { + to: ctx.emailAddress, + stripeCustomerId: 'cus_123456789', + } + ctx.email = ctx.EmailBuilder.buildEmail( + 'taxIdInvalidNonVat', + ctx.opts + ) + ctx.dom = cheerio.load(ctx.email.html) + }) + + it('should build the email', function (ctx) { + expect(ctx.email.html).to.exist + expect(ctx.email.text).to.exist + }) + + describe('HTML email', function () { + it('should include the Stripe customer ID', function (ctx) { + expect(ctx.email.html).to.contain(ctx.opts.stripeCustomerId) + }) + + it('should refer to the tax ID', function (ctx) { + expect(ctx.email.html).to.contain('tax ID') + }) + + it('should link to the subscription page', function (ctx) { + expect(ctx.dom('a').attr('href')).to.contain('/user/subscription') + }) + }) + + describe('plain text email', function () { + it('should include the Stripe customer ID', function (ctx) { + expect(ctx.email.text).to.contain(ctx.opts.stripeCustomerId) + }) + + it('should refer to the tax ID', function (ctx) { + expect(ctx.email.text).to.contain('tax ID') + }) + }) + }) + describe('groupMemberLimitWarning', function () { beforeEach(function (ctx) { ctx.emailAddress = 'example@overleaf.com' diff --git a/services/web/types/stripe/webhook-event.ts b/services/web/types/stripe/webhook-event.ts index 265f2a970b..65ba3065c5 100644 --- a/services/web/types/stripe/webhook-event.ts +++ b/services/web/types/stripe/webhook-event.ts @@ -145,6 +145,18 @@ export interface CustomerUpdatedWebhookEvent extends Stripe.EventBase { } } +export interface CustomerTaxIdUpdatedWebhookEvent extends Stripe.EventBase { + type: 'customer.tax_id.updated' + data: { + object: Stripe.TaxId + previous_attributes?: { + verification?: { + status?: Stripe.TaxId.Verification.Status + } + } + } +} + export type MandateUpdatedWebhookEvent = Stripe.MandateUpdatedEvent export type CustomerSubscriptionWebhookEvent = @@ -162,4 +174,5 @@ export type WebhookEvent = | InvoiceOverdueWebhookEvent | CustomerCreatedWebhookEvent | CustomerUpdatedWebhookEvent + | CustomerTaxIdUpdatedWebhookEvent | MandateUpdatedWebhookEvent