Jelajahi Sumber

Say in plain words when a text is refused for its country (#593)

* Say in plain words when a text is refused for its country

Twilio refuses a text to a country its account does not allow. Instead of
Twilio's wording the workshop now reads a translated sentence saying what it
can do: connect its own provider when the text went through the Torqvoice
account, or turn the country on in its own Twilio.

* Say the country refusal in plain words from the tire hotel and Send now too

Both rethrew the refusal from a scheduled-message send untranslated. One
helper now turns it into the person's language at every action that answers
someone, after the English message is stored for the record.
Bernt Christian Egeland 12 jam lalu
induk
melakukan
61826a3946

+ 4 - 0
messages/de/messages.json

@@ -76,5 +76,9 @@
     "sent": "An {name} gesendet",
     "sent": "An {name} gesendet",
     "sendError": "Nachricht konnte nicht gesendet werden",
     "sendError": "Nachricht konnte nicht gesendet werden",
     "templates": "Vorlagen"
     "templates": "Vorlagen"
+  },
+  "errors": {
+    "countryNotAvailable": "SMS in dieses Land können nicht über die Torqvoice-Nummer gesendet werden. Verbinden Sie in den Einstellungen Ihren eigenen SMS-Anbieter, um diesem Kunden eine SMS zu senden.",
+    "countryNotAllowedOwn": "Ihr Twilio-Konto erlaubt keine SMS in dieses Land. Aktivieren Sie das Land in Twilio unter Geo permissions und senden Sie dann erneut."
   }
   }
 }
 }

+ 4 - 0
messages/en/messages.json

@@ -76,5 +76,9 @@
     "sent": "Sent to {name}",
     "sent": "Sent to {name}",
     "sendError": "Could not send the message",
     "sendError": "Could not send the message",
     "templates": "Templates"
     "templates": "Templates"
+  },
+  "errors": {
+    "countryNotAvailable": "Texts to this country are not available from the Torqvoice number. Connect your own SMS provider in Settings to text this customer.",
+    "countryNotAllowedOwn": "Your Twilio account does not allow texts to this country. Turn the country on under Geo permissions in Twilio, then send again."
   }
   }
 }
 }

+ 4 - 0
messages/es/messages.json

@@ -76,5 +76,9 @@
     "sent": "Enviado a {name}",
     "sent": "Enviado a {name}",
     "sendError": "No se pudo enviar el mensaje",
     "sendError": "No se pudo enviar el mensaje",
     "templates": "Plantillas"
     "templates": "Plantillas"
+  },
+  "errors": {
+    "countryNotAvailable": "Los SMS a este país no están disponibles desde el número de Torqvoice. Conecte su propio proveedor de SMS en Configuración para enviar mensajes a este cliente.",
+    "countryNotAllowedOwn": "Su cuenta de Twilio no permite enviar SMS a este país. Active el país en Geo permissions de Twilio y vuelva a enviar."
   }
   }
 }
 }

+ 4 - 0
messages/fr/messages.json

@@ -76,5 +76,9 @@
     "sent": "Envoyé à {name}",
     "sent": "Envoyé à {name}",
     "sendError": "Impossible d'envoyer le message",
     "sendError": "Impossible d'envoyer le message",
     "templates": "Modèles"
     "templates": "Modèles"
+  },
+  "errors": {
+    "countryNotAvailable": "Les SMS vers ce pays ne sont pas disponibles depuis le numéro Torqvoice. Connectez votre propre fournisseur SMS dans Paramètres pour envoyer un SMS à ce client.",
+    "countryNotAllowedOwn": "Votre compte Twilio n'autorise pas les SMS vers ce pays. Activez ce pays dans Geo permissions sur Twilio, puis renvoyez le message."
   }
   }
 }
 }

+ 4 - 0
messages/it/messages.json

@@ -76,5 +76,9 @@
     "sent": "Inviato a {name}",
     "sent": "Inviato a {name}",
     "sendError": "Impossibile inviare il messaggio",
     "sendError": "Impossibile inviare il messaggio",
     "templates": "Modelli"
     "templates": "Modelli"
+  },
+  "errors": {
+    "countryNotAvailable": "Gli SMS verso questo paese non sono disponibili dal numero Torqvoice. Collega il tuo provider SMS in Impostazioni per inviare messaggi a questo cliente.",
+    "countryNotAllowedOwn": "Il tuo account Twilio non consente SMS verso questo paese. Attiva il paese in Geo permissions su Twilio, poi invia di nuovo."
   }
   }
 }
 }

+ 4 - 0
messages/lt/messages.json

@@ -76,5 +76,9 @@
     "sent": "Išsiųsta {name}",
     "sent": "Išsiųsta {name}",
     "sendError": "Nepavyko išsiųsti žinutės",
     "sendError": "Nepavyko išsiųsti žinutės",
     "templates": "Šablonai"
     "templates": "Šablonai"
+  },
+  "errors": {
+    "countryNotAvailable": "SMS žinutės į šią šalį iš Torqvoice numerio negalimos. Prijunkite savo SMS paslaugų teikėją skiltyje Nustatymai, kad galėtumėte siųsti žinutes šiam klientui.",
+    "countryNotAllowedOwn": "Jūsų Twilio paskyra neleidžia siųsti SMS žinučių į šią šalį. Įjunkite šią šalį Twilio skiltyje Geo permissions ir siųskite dar kartą."
   }
   }
 }
 }

+ 4 - 0
messages/nb/messages.json

@@ -76,5 +76,9 @@
     "sent": "Sendt til {name}",
     "sent": "Sendt til {name}",
     "sendError": "Kunne ikke sende meldingen",
     "sendError": "Kunne ikke sende meldingen",
     "templates": "Maler"
     "templates": "Maler"
+  },
+  "errors": {
+    "countryNotAvailable": "SMS til dette landet er ikke tilgjengelig fra Torqvoice-nummeret. Koble til din egen SMS-leverandør under Innstillinger for å sende SMS til denne kunden.",
+    "countryNotAllowedOwn": "Twilio-kontoen din tillater ikke SMS til dette landet. Slå på landet under Geo permissions i Twilio, og send på nytt."
   }
   }
 }
 }

+ 4 - 0
messages/nl/messages.json

@@ -76,5 +76,9 @@
     "sent": "Verzonden naar {name}",
     "sent": "Verzonden naar {name}",
     "sendError": "Kan het bericht niet versturen",
     "sendError": "Kan het bericht niet versturen",
     "templates": "Sjablonen"
     "templates": "Sjablonen"
+  },
+  "errors": {
+    "countryNotAvailable": "Sms'en naar dit land zijn niet beschikbaar via het Torqvoice-nummer. Koppel uw eigen sms-provider in Instellingen om deze klant een sms te sturen.",
+    "countryNotAllowedOwn": "Uw Twilio-account staat geen sms'en naar dit land toe. Schakel het land in onder Geo permissions in Twilio en verstuur het bericht opnieuw."
   }
   }
 }
 }

+ 4 - 0
messages/pl/messages.json

@@ -76,5 +76,9 @@
     "sent": "Wysłano do {name}",
     "sent": "Wysłano do {name}",
     "sendError": "Nie udało się wysłać wiadomości",
     "sendError": "Nie udało się wysłać wiadomości",
     "templates": "Szablony"
     "templates": "Szablony"
+  },
+  "errors": {
+    "countryNotAvailable": "SMS-y do tego kraju nie są dostępne z numeru Torqvoice. Połącz własnego dostawcę SMS w Ustawieniach, aby wysyłać SMS-y do tego klienta.",
+    "countryNotAllowedOwn": "Twoje konto Twilio nie pozwala na wysyłanie SMS-ów do tego kraju. Włącz ten kraj w sekcji Geo permissions w Twilio, a następnie wyślij ponownie."
   }
   }
 }
 }

+ 4 - 0
messages/pt-BR/messages.json

@@ -76,5 +76,9 @@
     "sent": "Enviada para {name}",
     "sent": "Enviada para {name}",
     "sendError": "Não foi possível enviar a mensagem",
     "sendError": "Não foi possível enviar a mensagem",
     "templates": "Modelos"
     "templates": "Modelos"
+  },
+  "errors": {
+    "countryNotAvailable": "SMS para este país não estão disponíveis pelo número da Torqvoice. Conecte seu próprio provedor de SMS em Configurações para enviar mensagens a este cliente.",
+    "countryNotAllowedOwn": "Sua conta Twilio não permite SMS para este país. Ative o país em Geo permissions no Twilio e envie novamente."
   }
   }
 }
 }

+ 4 - 0
messages/pt-PT/messages.json

@@ -76,5 +76,9 @@
     "sent": "Enviada a {name}",
     "sent": "Enviada a {name}",
     "sendError": "Não foi possível enviar a mensagem",
     "sendError": "Não foi possível enviar a mensagem",
     "templates": "Modelos"
     "templates": "Modelos"
+  },
+  "errors": {
+    "countryNotAvailable": "Os SMS para este país não estão disponíveis a partir do número da Torqvoice. Ligue o seu próprio fornecedor de SMS nas Definições para enviar mensagens a este cliente.",
+    "countryNotAllowedOwn": "A sua conta Twilio não permite SMS para este país. Ative o país em Geo permissions no Twilio e envie novamente."
   }
   }
 }
 }

+ 4 - 0
messages/ru/messages.json

@@ -76,5 +76,9 @@
     "sent": "Отправлено {name}",
     "sent": "Отправлено {name}",
     "sendError": "Не удалось отправить сообщение",
     "sendError": "Не удалось отправить сообщение",
     "templates": "Шаблоны"
     "templates": "Шаблоны"
+  },
+  "errors": {
+    "countryNotAvailable": "SMS в эту страну недоступны с номера Torqvoice. Подключите собственного SMS-провайдера в разделе «Настройки», чтобы отправлять сообщения этому клиенту.",
+    "countryNotAllowedOwn": "Ваша учётная запись Twilio не разрешает отправку SMS в эту страну. Включите страну в разделе Geo permissions в Twilio и отправьте снова."
   }
   }
 }
 }

+ 4 - 0
messages/tr/messages.json

@@ -76,5 +76,9 @@
     "sent": "{name} kişisine gönderildi",
     "sent": "{name} kişisine gönderildi",
     "sendError": "Mesaj gönderilemedi",
     "sendError": "Mesaj gönderilemedi",
     "templates": "Şablonlar"
     "templates": "Şablonlar"
+  },
+  "errors": {
+    "countryNotAvailable": "Bu ülkeye Torqvoice numarasından SMS gönderilemiyor. Bu müşteriye SMS göndermek için Ayarlar'dan kendi SMS sağlayıcınızı bağlayın.",
+    "countryNotAllowedOwn": "Twilio hesabınız bu ülkeye SMS gönderilmesine izin vermiyor. Twilio'da Geo permissions altından ülkeyi etkinleştirin, ardından tekrar gönderin."
   }
   }
 }
 }

+ 49 - 0
src/__tests__/features/sms/sms-refusal-text.test.ts

@@ -0,0 +1,49 @@
+/**
+ * A text refused for its country reads as a plain sentence in the person's
+ * language wherever they sent it from: the conversation, the Send dialog,
+ * the tire hotel, or "Send now" on a scheduled message.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('server-only', () => ({}))
+vi.mock('next-intl/server', async () => {
+  const nb = (await import('../../../../messages/nb/messages.json')).default as {
+    errors: Record<string, string>
+  }
+  return {
+    getTranslations: async (ns: string) => {
+      expect(ns).toBe('messages.errors')
+      return (key: string) => nb.errors[key]
+    },
+  }
+})
+
+const { smsRefusalForPerson } = await import('@/features/sms/Lib/smsRefusalText.server')
+const { SmsCountryRefusedError } = await import('@/lib/sms')
+const nb = (await import('../../../../messages/nb/messages.json')).default as {
+  errors: Record<string, string>
+}
+
+describe('the refusal a person reads', () => {
+  it('tells a workshop on the Torqvoice account to connect its own provider', async () => {
+    const refused = new SmsCountryRefusedError()
+    refused.platform = true
+
+    const shown = (await smsRefusalForPerson(refused)) as Error
+
+    expect(shown.message).toBe(nb.errors.countryNotAvailable)
+  })
+
+  it('tells a workshop on its own Twilio to turn the country on', async () => {
+    const shown = (await smsRefusalForPerson(new SmsCountryRefusedError())) as Error
+
+    expect(shown.message).toBe(nb.errors.countryNotAllowedOwn)
+  })
+
+  it('leaves every other error as it was', async () => {
+    const other = new Error('Customer not found')
+
+    expect(await smsRefusalForPerson(other)).toBe(other)
+  })
+})

+ 47 - 0
src/__tests__/features/sms/sms-send.test.ts

@@ -58,6 +58,20 @@ vi.mock('@/lib/db', () => ({
 const mockFetch = vi.fn()
 const mockFetch = vi.fn()
 vi.stubGlobal('fetch', mockFetch)
 vi.stubGlobal('fetch', mockFetch)
 
 
+// The action's own sentences, read from the English messages the way
+// next-intl would on the server.
+vi.mock('next-intl/server', async () => {
+  const en = (await import('../../../../messages/en/messages.json')).default as {
+    errors: Record<string, string>
+  }
+  return {
+    getTranslations: async (ns: string) => {
+      expect(ns).toBe('messages.errors')
+      return (key: string) => en.errors[key]
+    },
+  }
+})
+
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { sendSmsToCustomer } from '@/features/sms/Actions/smsActions'
 import { sendSmsToCustomer } from '@/features/sms/Actions/smsActions'
@@ -137,6 +151,19 @@ function mockTwilioFailure() {
   })
   })
 }
 }
 
 
+/** Twilio's refusal for a country the account's geo permissions leave off. */
+function mockTwilioCountryRefused() {
+  mockFetch.mockResolvedValueOnce({
+    ok: false,
+    statusText: 'Bad Request',
+    json: async () => ({
+      code: 21408,
+      message:
+        "Permission to send an SMS has not been enabled for the region indicated by the 'To' number: +4799999999.",
+    }),
+  })
+}
+
 function mockVonageSuccess() {
 function mockVonageSuccess() {
   mockFetch.mockResolvedValueOnce({
   mockFetch.mockResolvedValueOnce({
     ok: true,
     ok: true,
@@ -663,6 +690,26 @@ describe('SMS status reflects actual vendor response', () => {
     )
     )
   })
   })
 
 
+  it("says in plain words when the workshop's own Twilio does not allow the country", async () => {
+    setupAuth()
+    setupSmsProvider('twilio')
+    vi.mocked(db.customer.findFirst).mockResolvedValue(CUSTOMER_A as any)
+    vi.mocked(db.smsMessage.create).mockResolvedValue(SMS_RECORD as any)
+    vi.mocked(db.smsMessage.update).mockResolvedValue({} as any)
+    mockTwilioCountryRefused()
+
+    const result = await sendSmsToCustomer({ customerId: CUSTOMER_A.id, body: 'Hello' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe(
+      'Your Twilio account does not allow texts to this country. Turn the country on under Geo permissions in Twilio, then send again.'
+    )
+    expect(result.error).not.toContain('Twilio error')
+    expect(vi.mocked(db.smsMessage.update)).toHaveBeenCalledWith(
+      expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) })
+    )
+  })
+
   it('does not call vendor API if customer lookup fails', async () => {
   it('does not call vendor API if customer lookup fails', async () => {
     setupAuth()
     setupAuth()
     setupSmsProvider('twilio')
     setupSmsProvider('twilio')

+ 33 - 0
src/__tests__/lib/platform-sms.test.ts

@@ -88,8 +88,20 @@ const ownTwilio = new Map([
 /** Every text Twilio was asked to send: which account, from which number, to whom. */
 /** Every text Twilio was asked to send: which account, from which number, to whom. */
 let sent: { account: string; from: string; to: string; body: string; service?: string }[] = []
 let sent: { account: string; from: string; to: string; body: string; service?: string }[] = []
 let twilioDown = false
 let twilioDown = false
+/** Set to make Twilio refuse the destination country, as its geo permissions do. */
+let countryRefused = false
 
 
 const fetchMock = vi.fn(async (url: string, init: { body: string }) => {
 const fetchMock = vi.fn(async (url: string, init: { body: string }) => {
+  if (countryRefused) {
+    return {
+      ok: false,
+      statusText: 'Bad Request',
+      json: async () => ({
+        code: 21408,
+        message: 'Permission to send an SMS has not been enabled',
+      }),
+    }
+  }
   if (twilioDown) {
   if (twilioDown) {
     return { ok: false, statusText: 'Service Unavailable', json: async () => ({}) }
     return { ok: false, statusText: 'Service Unavailable', json: async () => ({}) }
   }
   }
@@ -117,6 +129,7 @@ beforeEach(() => {
   usage.clear()
   usage.clear()
   sent = []
   sent = []
   twilioDown = false
   twilioDown = false
+  countryRefused = false
   systemSettings = platformAccount
   systemSettings = platformAccount
   isCloudMode.mockReset().mockReturnValue(true)
   isCloudMode.mockReset().mockReturnValue(true)
   getFeatures.mockReset().mockResolvedValue(PLAN_FEATURES.pro)
   getFeatures.mockReset().mockResolvedValue(PLAN_FEATURES.pro)
@@ -211,6 +224,26 @@ describe('a paid workshop with no SMS provider of its own', () => {
     expect(sent).toHaveLength(30)
     expect(sent).toHaveLength(30)
   })
   })
 
 
+  it('says the country is not available from the Torqvoice number when Twilio refuses it', async () => {
+    countryRefused = true
+
+    await expect(text()).rejects.toMatchObject({
+      name: 'SmsCountryRefusedError',
+      platform: true,
+      message: expect.stringContaining('not available from the Torqvoice number'),
+    })
+  })
+
+  it('does not count a text to a refused country against the day', async () => {
+    countryRefused = true
+    await expect(text()).rejects.toThrow()
+    countryRefused = false
+
+    await textTimes(30)
+
+    expect(sent).toHaveLength(30)
+  })
+
   it('starts again the next day', async () => {
   it('starts again the next day', async () => {
     const send = () => Promise.resolve('sent')
     const send = () => Promise.resolve('sent')
     const monday = new Date('2026-11-16T12:00:00Z')
     const monday = new Date('2026-11-16T12:00:00Z')

+ 3 - 1
src/features/scheduled-messages/Actions/scheduledMessageActions.ts

@@ -1,5 +1,6 @@
 'use server'
 'use server'
 
 
+import { smsRefusalForPerson } from '@/features/sms/Lib/smsRefusalText.server'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { assertOwnedCustomer, assertOwnedVehicle } from '@/lib/owned-records'
 import { assertOwnedCustomer, assertOwnedVehicle } from '@/lib/owned-records'
@@ -338,7 +339,8 @@ export async function sendScheduledMessageNow(id: string) {
           where: { id },
           where: { id },
           data: { lastRunAt: now, errorMessage },
           data: { lastRunAt: now, errorMessage },
         })
         })
-        throw error
+        // Stored in English for the record; told in the person's language.
+        throw await smsRefusalForPerson(error)
       }
       }
 
 
       const updated = await db.scheduledMessage.update({
       const updated = await db.scheduledMessage.update({

+ 7 - 1
src/features/sms/Lib/deliverSms.server.ts

@@ -1,7 +1,8 @@
 import 'server-only'
 import 'server-only'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
 import { markInvoiceSent, markQuoteSent } from '@/lib/document-lock.server'
-import { getOrgSmsPhoneNumber, sendOrgSms } from '@/lib/sms'
+import { getOrgSmsPhoneNumber, SmsCountryRefusedError, sendOrgSms } from '@/lib/sms'
+import { smsRefusalForPerson } from './smsRefusalText.server'
 
 
 export interface DeliverSmsInput {
 export interface DeliverSmsInput {
   /** Already normalised to the E.164 number the provider will receive. */
   /** Already normalised to the E.164 number the provider will receive. */
@@ -73,6 +74,11 @@ export async function deliverSms(organizationId: string, input: DeliverSmsInput)
       data: { status: 'failed', errorMessage },
       data: { status: 'failed', errorMessage },
     })
     })
 
 
+    // The country is not open on the account: a plain sentence in the
+    // person's language, saying what they can do about it, instead of the
+    // provider's wording.
+    if (error instanceof SmsCountryRefusedError) throw await smsRefusalForPerson(error)
+
     throw new Error(`Failed to send SMS: ${errorMessage}`)
     throw new Error(`Failed to send SMS: ${errorMessage}`)
   }
   }
 }
 }

+ 16 - 0
src/features/sms/Lib/smsRefusalText.server.ts

@@ -0,0 +1,16 @@
+import 'server-only'
+import { getTranslations } from 'next-intl/server'
+import { SmsCountryRefusedError } from '@/lib/sms'
+
+/**
+ * The error a person should see for a failed text, in their language.
+ *
+ * A text refused for its country becomes a plain sentence saying what they can
+ * do about it; anything else comes back unchanged. Called at each action that
+ * answers a person, after the English message has been stored for the record.
+ */
+export async function smsRefusalForPerson(error: unknown): Promise<unknown> {
+  if (!(error instanceof SmsCountryRefusedError)) return error
+  const t = await getTranslations('messages.errors')
+  return new Error(t(error.platform ? 'countryNotAvailable' : 'countryNotAllowedOwn'))
+}

+ 3 - 1
src/features/tire-hotel/Actions/messageCustomerActions.ts

@@ -1,5 +1,6 @@
 'use server'
 'use server'
 
 
+import { smsRefusalForPerson } from '@/features/sms/Lib/smsRefusalText.server'
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { z } from 'zod'
 import { z } from 'zod'
 import { recipientFitsChannel } from '../Lib/recipient'
 import { recipientFitsChannel } from '../Lib/recipient'
@@ -187,7 +188,8 @@ export async function messageCustomerAboutTireSet(input: unknown) {
           where: { id: record.id },
           where: { id: record.id },
           data: { status: 'failed', lastRunAt: now, errorMessage: message },
           data: { status: 'failed', lastRunAt: now, errorMessage: message },
         })
         })
-        throw error
+        // Stored in English for the record; told in the person's language.
+        throw await smsRefusalForPerson(error)
       }
       }
 
 
       await db.scheduledMessage.update({
       await db.scheduledMessage.update({

+ 48 - 4
src/lib/sms.ts

@@ -69,6 +69,32 @@ export async function getPlatformSmsAllowance(organizationId: string): Promise<n
   return route?.source === 'platform' ? route.dailyLimit : null
   return route?.source === 'platform' ? route.dailyLimit : null
 }
 }
 
 
+// ─── Refusals worth explaining ──────────────────────────────────────────────
+
+/**
+ * Twilio's code for a destination country the account is not allowed to text
+ * (its geo permissions). The account owner decides which countries are on, so
+ * the text never went out and nothing was charged.
+ */
+const TWILIO_REGION_NOT_ALLOWED = 21408
+
+/**
+ * The provider refused the text because of the country it was going to.
+ * `platform` says whose account refused: the platform's shared one, where the
+ * workshop can only connect its own provider, or the workshop's own, where it
+ * can turn the country on itself. Callers that talk to a person turn this into
+ * a translated sentence; the message is the plain English one for logs and
+ * stored records.
+ */
+export class SmsCountryRefusedError extends Error {
+  platform = false
+
+  constructor() {
+    super('Texts to this country are not allowed by the SMS account.')
+    this.name = 'SmsCountryRefusedError'
+  }
+}
+
 // ─── Twilio ──────────────────────────────────────────────────────────────────
 // ─── Twilio ──────────────────────────────────────────────────────────────────
 
 
 interface TwilioResult {
 interface TwilioResult {
@@ -111,8 +137,9 @@ async function sendViaTwilio(
   })
   })
 
 
   if (!res.ok) {
   if (!res.ok) {
-    const err = await res.json().catch(() => ({}))
-    throw new Error(`Twilio error: ${(err as { message?: string }).message || res.statusText}`)
+    const err = (await res.json().catch(() => ({}))) as { message?: string; code?: number }
+    if (err.code === TWILIO_REGION_NOT_ALLOWED) throw new SmsCountryRefusedError()
+    throw new Error(`Twilio error: ${err.message || res.statusText}`)
   }
   }
 
 
   const data = (await res.json()) as TwilioResult
   const data = (await res.json()) as TwilioResult
@@ -314,7 +341,24 @@ export async function sendOrgSms(
   // scheduled reminders and the messages typed by hand all draw on the same
   // scheduled reminders and the messages typed by hand all draw on the same
   // allowance. A workshop's own provider is its own bill and has no limit.
   // allowance. A workshop's own provider is its own bill and has no limit.
   if (route.source === 'platform') {
   if (route.source === 'platform') {
-    return withPlatformSmsAllowance(organizationId, route.dailyLimit, options.body, send)
+    return withPlatformSmsAllowance(organizationId, route.dailyLimit, options.body, send).catch(
+      (error: unknown) => {
+        if (error instanceof SmsCountryRefusedError) {
+          error.platform = true
+          error.message =
+            'Texts to this country are not available from the Torqvoice number. ' +
+            'Connect your own SMS provider to text this customer.'
+        }
+        throw error
+      }
+    )
   }
   }
-  return send()
+  return send().catch((error: unknown) => {
+    if (error instanceof SmsCountryRefusedError) {
+      error.message =
+        'Your Twilio account does not allow texts to this country. ' +
+        'Turn the country on under Geo permissions in Twilio.'
+    }
+    throw error
+  })
 }
 }